woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -57,7 +57,12 @@ module Woods
|
|
|
57
57
|
# @param event_map [Hash] Mutable map of event_name => {publishers:, subscribers:, pattern:}
|
|
58
58
|
# @return [void]
|
|
59
59
|
def scan_file(file_path, event_map)
|
|
60
|
-
source =
|
|
60
|
+
source = cached_source(file_path)
|
|
61
|
+
unless source
|
|
62
|
+
Rails.logger.error("Failed to scan #{file_path} for events: file unreadable")
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
61
66
|
scan_active_support_notifications(source, file_path, event_map)
|
|
62
67
|
scan_wisper_patterns(source, file_path, event_map)
|
|
63
68
|
rescue StandardError => e
|
|
@@ -97,16 +102,43 @@ module Woods
|
|
|
97
102
|
# @return [void]
|
|
98
103
|
def scan_wisper_patterns(source, file_path, event_map)
|
|
99
104
|
if source.match?(/include\s+Wisper/)
|
|
100
|
-
|
|
105
|
+
# `\s*\(?\s*` accepts both call styles. Requiring whitespace before
|
|
106
|
+
# the symbol missed `broadcast(:order_created, order)` — Wisper's
|
|
107
|
+
# README-canonical form — so no publisher was registered and, with
|
|
108
|
+
# no subscriber naming the event either, the event unit did not
|
|
109
|
+
# exist at all (EXTB-3). The AS::Notifications and `.on(` scans
|
|
110
|
+
# already accept parens.
|
|
111
|
+
source.scan(/\b(?:publish|broadcast)\s*\(?\s*:(\w+)/) do |m|
|
|
101
112
|
register_publisher(event_map, m[0], file_path, :wisper)
|
|
102
113
|
end
|
|
103
114
|
end
|
|
104
115
|
|
|
116
|
+
# `.on(:sym)` is far too common a shape to treat as evidence on its own:
|
|
117
|
+
# `socket.on(:message)`, `emitter.on(:close)`, and every other
|
|
118
|
+
# callback-registration API in the ecosystem match it, each minting a
|
|
119
|
+
# phantom event unit and its edges. Publishers were already gated on
|
|
120
|
+
# Wisper context in the file; subscribers were not. Require the file to
|
|
121
|
+
# mention Wisper somewhere before believing a bare `.on`.
|
|
122
|
+
return unless wisper_context?(source)
|
|
123
|
+
|
|
105
124
|
source.scan(/\.on\s*\(\s*:(\w+)/) do |m|
|
|
106
125
|
register_subscriber(event_map, m[0], file_path, :wisper)
|
|
107
126
|
end
|
|
108
127
|
end
|
|
109
128
|
|
|
129
|
+
# Does this file give any indication it is using Wisper?
|
|
130
|
+
#
|
|
131
|
+
# Deliberately broader than the publisher gate's `include Wisper` —
|
|
132
|
+
# subscribers commonly live in files that reference Wisper without
|
|
133
|
+
# including it (`Wisper.subscribe(listener)`, `extend
|
|
134
|
+
# Wisper::Publisher`, a global-listener initializer).
|
|
135
|
+
#
|
|
136
|
+
# @param source [String] Ruby source code
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def wisper_context?(source)
|
|
139
|
+
source.match?(/\bWisper\b/)
|
|
140
|
+
end
|
|
141
|
+
|
|
110
142
|
# ──────────────────────────────────────────────────────────────────────
|
|
111
143
|
# Event Map Mutation
|
|
112
144
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -179,11 +211,30 @@ module Woods
|
|
|
179
211
|
# @param file_paths [Array<String>] File paths to read
|
|
180
212
|
# @return [String] Combined source
|
|
181
213
|
def load_source_files(file_paths)
|
|
182
|
-
file_paths.filter_map
|
|
214
|
+
file_paths.filter_map { |path| cached_source(path) }.join("\n")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# One read per distinct path per extractor instance (audit P2).
|
|
218
|
+
#
|
|
219
|
+
# Pass 2 ({#build_unit}) recombines the same publisher/subscriber files
|
|
220
|
+
# for every event that references them, so a widely-shared file was
|
|
221
|
+
# re-read once per event on top of the pass-1 {#scan_file} read. The
|
|
222
|
+
# bytes cannot change mid-run, so the first read answers the rest.
|
|
223
|
+
#
|
|
224
|
+
# A failed read is memoized as nil, matching the per-event skip it
|
|
225
|
+
# produced before; the output is identical either way.
|
|
226
|
+
#
|
|
227
|
+
# @param path [String] File path
|
|
228
|
+
# @return [String, nil] File contents, or nil when unreadable
|
|
229
|
+
def cached_source(path)
|
|
230
|
+
cache = (@source_files ||= {})
|
|
231
|
+
return cache[path] if cache.key?(path)
|
|
232
|
+
|
|
233
|
+
cache[path] = begin
|
|
183
234
|
File.read(path)
|
|
184
235
|
rescue StandardError
|
|
185
236
|
nil
|
|
186
|
-
end
|
|
237
|
+
end
|
|
187
238
|
end
|
|
188
239
|
|
|
189
240
|
# Build annotated source annotation for the event unit.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'line_neutralizer'
|
|
3
4
|
require_relative 'shared_utility_methods'
|
|
4
5
|
require_relative 'shared_dependency_scanner'
|
|
5
6
|
|
|
@@ -64,6 +65,13 @@ module Woods
|
|
|
64
65
|
# transient attributes. Each factory block (including nested factories within
|
|
65
66
|
# a parent factory) produces one entry in the returned array.
|
|
66
67
|
#
|
|
68
|
+
# Block accounting runs against the {LineNeutralizer}-neutralized line —
|
|
69
|
+
# comment, string, and heredoc bodies blanked — while names are still
|
|
70
|
+
# read from the raw line, where their literals live. Counting keywords in
|
|
71
|
+
# raw source meant `title { "things to do" }` opened a `do` block nothing
|
|
72
|
+
# closed; two such attributes left the factory on the stack at EOF and
|
|
73
|
+
# the unit was dropped entirely (EXTB-2).
|
|
74
|
+
#
|
|
67
75
|
# @param source [String] Factory file source code
|
|
68
76
|
# @return [Array<Hash>] Parsed factory data hashes
|
|
69
77
|
def parse_factories(source)
|
|
@@ -72,26 +80,38 @@ module Woods
|
|
|
72
80
|
depth = 0
|
|
73
81
|
in_transient = false
|
|
74
82
|
transient_depth = nil
|
|
83
|
+
scannable = LineNeutralizer.neutralize_lines(source)
|
|
75
84
|
|
|
76
85
|
source.lines.each_with_index do |line, index|
|
|
77
86
|
stripped = line.strip
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
code = scannable[index].to_s.strip
|
|
88
|
+
next if code.empty?
|
|
89
|
+
|
|
90
|
+
# Factory definition. A factory with a block goes on the stack and is
|
|
91
|
+
# completed when its `end` brings depth back; a blockless one —
|
|
92
|
+
# `factory :admin, parent: :user` — has nothing to wait for and is
|
|
93
|
+
# complete as soon as it is parsed. Requiring a block used to drop
|
|
94
|
+
# those silently, which is the very shape the parent-inheritance
|
|
95
|
+
# parsing below exists to support.
|
|
80
96
|
if (factory_data = match_factory(stripped, depth, index + 1))
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
if opens_block?(code)
|
|
98
|
+
factory_stack.push(factory_data)
|
|
99
|
+
depth += 1
|
|
100
|
+
else
|
|
101
|
+
completed << factory_data
|
|
102
|
+
end
|
|
83
103
|
next
|
|
84
104
|
end
|
|
85
105
|
|
|
86
106
|
# Trait definition — record trait in current factory, open block
|
|
87
|
-
if (trait_match =
|
|
107
|
+
if (trait_match = code.match(/\Atrait\s+:(\w+)\s+do/))
|
|
88
108
|
factory_stack.last[:traits] << trait_match[1] if factory_stack.any?
|
|
89
109
|
depth += 1
|
|
90
110
|
next
|
|
91
111
|
end
|
|
92
112
|
|
|
93
113
|
# Transient block — start collecting transient attributes
|
|
94
|
-
if
|
|
114
|
+
if code.match?(/\Atransient\s+do/)
|
|
95
115
|
in_transient = true
|
|
96
116
|
transient_depth = depth
|
|
97
117
|
depth += 1
|
|
@@ -119,12 +139,12 @@ module Woods
|
|
|
119
139
|
end
|
|
120
140
|
|
|
121
141
|
# Generic block openers — factory/trait/transient already handled above with next
|
|
122
|
-
if block_opener?(
|
|
142
|
+
if block_opener?(code)
|
|
123
143
|
depth += 1
|
|
124
144
|
next
|
|
125
145
|
end
|
|
126
146
|
|
|
127
|
-
next unless
|
|
147
|
+
next unless code == 'end'
|
|
128
148
|
|
|
129
149
|
depth -= 1
|
|
130
150
|
|
|
@@ -150,13 +170,17 @@ module Woods
|
|
|
150
170
|
# factory :name, class: ClassName do
|
|
151
171
|
# factory :name, class: 'ClassName' do
|
|
152
172
|
# factory :name, parent: :other do
|
|
173
|
+
# factory :name, parent: :other # blockless
|
|
174
|
+
#
|
|
175
|
+
# Whether the line opens a block is the caller's concern — see
|
|
176
|
+
# {#parse_factories}.
|
|
153
177
|
#
|
|
154
178
|
# @param line [String] Stripped source line
|
|
155
179
|
# @param depth [Integer] Current block depth when factory would be opened
|
|
156
180
|
# @param line_number [Integer] 1-based line number
|
|
157
181
|
# @return [Hash, nil] Initialized factory data or nil if not a factory line
|
|
158
182
|
def match_factory(line, depth, line_number)
|
|
159
|
-
return nil unless line.match?(/\Afactory\s+:/)
|
|
183
|
+
return nil unless line.match?(/\Afactory\s+:/)
|
|
160
184
|
|
|
161
185
|
name_match = line.match(/\Afactory\s+:(\w+)/)
|
|
162
186
|
return nil unless name_match
|
|
@@ -194,6 +218,19 @@ module Woods
|
|
|
194
218
|
name.split('_').map(&:capitalize).join
|
|
195
219
|
end
|
|
196
220
|
|
|
221
|
+
# Does this factory line open a `do` block?
|
|
222
|
+
#
|
|
223
|
+
# Separate from {#block_opener?}, which deliberately excludes factory
|
|
224
|
+
# lines so they can carry their own depth bookkeeping. A factory without
|
|
225
|
+
# a block (`factory :admin, parent: :user`) is complete on the line it
|
|
226
|
+
# appears and must not push a frame nothing will pop.
|
|
227
|
+
#
|
|
228
|
+
# @param stripped [String] Stripped line content
|
|
229
|
+
# @return [Boolean]
|
|
230
|
+
def opens_block?(stripped)
|
|
231
|
+
stripped.match?(/\bdo\b/)
|
|
232
|
+
end
|
|
233
|
+
|
|
197
234
|
# Check if a stripped line opens a new block.
|
|
198
235
|
#
|
|
199
236
|
# Excludes factory, trait, and transient lines — those are handled
|
|
@@ -207,7 +244,7 @@ module Woods
|
|
|
207
244
|
return false if stripped.match?(/\Atransient\s+do/)
|
|
208
245
|
return true if stripped.match?(/\b(do|def|case|begin|class|module|while|until|for)\b.*(?<!\bend)\s*$/)
|
|
209
246
|
|
|
210
|
-
stripped.match?(
|
|
247
|
+
stripped.match?(/(?:\A|=\s*)(?:if|unless)\b.*(?<!\bend)\s*$/)
|
|
211
248
|
end
|
|
212
249
|
|
|
213
250
|
# Build an ExtractedUnit from parsed factory data.
|
|
@@ -254,7 +291,8 @@ module Woods
|
|
|
254
291
|
sequences: factory_data[:sequences],
|
|
255
292
|
parent_factory: factory_data[:parent_factory],
|
|
256
293
|
callbacks: factory_data[:callbacks].uniq,
|
|
257
|
-
transient_attributes: factory_data[:transient_attributes]
|
|
294
|
+
transient_attributes: factory_data[:transient_attributes],
|
|
295
|
+
line_number: factory_data[:line_number]
|
|
258
296
|
}
|
|
259
297
|
end
|
|
260
298
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'shared_utility_methods'
|
|
4
4
|
require_relative 'shared_dependency_scanner'
|
|
5
|
+
require_relative 'source_nesting'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Extractors
|
|
@@ -30,6 +31,7 @@ module Woods
|
|
|
30
31
|
class GraphQLExtractor
|
|
31
32
|
include SharedUtilityMethods
|
|
32
33
|
include SharedDependencyScanner
|
|
34
|
+
include SourceNesting
|
|
33
35
|
|
|
34
36
|
# Standard directory for graphql-ruby applications
|
|
35
37
|
GRAPHQL_DIRECTORY = 'app/graphql'
|
|
@@ -43,14 +45,46 @@ module Woods
|
|
|
43
45
|
@runtime_types = load_runtime_types
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
# The type classes runtime introspection can reach.
|
|
49
|
+
#
|
|
50
|
+
# Shared with the incremental path's class reconciliation (#167), which
|
|
51
|
+
# uses it for **additions only**. A runtime-defined type — built by a
|
|
52
|
+
# schema builder or a DSL rather than a literal `class ... <
|
|
53
|
+
# Types::BaseObject` — has no source file, so no changed path ever
|
|
54
|
+
# dispatches to it and `extract_graphql_file` cannot reproduce it. Only a
|
|
55
|
+
# full extraction emitted such units, which meant an incremental run
|
|
56
|
+
# could never create them and an index built incrementally from empty
|
|
57
|
+
# never had them at all.
|
|
58
|
+
#
|
|
59
|
+
# Empty when graphql-ruby is not loaded or the app has no schema, which is
|
|
60
|
+
# why the reconciliation must not read absence here as deletion — see
|
|
61
|
+
# `reconcile_removals: false` in {Extractor::CLASS_BASED_DISCOVERY}.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Class>]
|
|
64
|
+
def discoverable_classes
|
|
65
|
+
@runtime_types.values.reject { |type_class| type_class.name.nil? }
|
|
66
|
+
end
|
|
67
|
+
|
|
46
68
|
# Extract all GraphQL types, mutations, queries, and resolvers
|
|
47
69
|
#
|
|
48
|
-
# Returns an empty array
|
|
49
|
-
#
|
|
70
|
+
# Returns an empty array when the app has neither an `app/graphql`
|
|
71
|
+
# directory nor a loadable schema class.
|
|
72
|
+
#
|
|
73
|
+
# Deliberately **not** gated on graphql-ruby being loaded. The file pass
|
|
74
|
+
# is static — a regex over source plus a `safe_constantize` that is
|
|
75
|
+
# allowed to fail — so it works perfectly well without the gem, and
|
|
76
|
+
# `app/graphql/*.rb` is real source an agent wants indexed either way.
|
|
77
|
+
# Gating it produced a genuine full-vs-incremental divergence: the
|
|
78
|
+
# `PathDispatcher` rule routes those paths at `extract_graphql_file`,
|
|
79
|
+
# which never had the gate, so an app whose GraphQL gem was not loaded at
|
|
80
|
+
# extraction time got its types indexed incrementally and dropped by a
|
|
81
|
+
# full run. Runtime introspection stays optional and additive — it makes
|
|
82
|
+
# the units richer when the gem is there, it does not decide whether they
|
|
83
|
+
# exist.
|
|
50
84
|
#
|
|
51
85
|
# @return [Array<ExtractedUnit>] List of GraphQL units
|
|
52
86
|
def extract_all
|
|
53
|
-
return [] unless
|
|
87
|
+
return [] unless graphql_source_present?
|
|
54
88
|
|
|
55
89
|
units = []
|
|
56
90
|
seen_identifiers = Set.new
|
|
@@ -94,8 +128,14 @@ module Woods
|
|
|
94
128
|
return nil unless class_name
|
|
95
129
|
return nil unless graphql_class?(source)
|
|
96
130
|
|
|
97
|
-
unit_type = classify_unit_type(file_path, source)
|
|
98
131
|
runtime_class = class_name.safe_constantize
|
|
132
|
+
# Classify from the resolved runtime class first, matching
|
|
133
|
+
# {#extract_from_runtime_type}'s classifier. Keeps the two passes
|
|
134
|
+
# from disagreeing on a type's unit_type (a mutation that looks like
|
|
135
|
+
# a type by path/regex) and leaving a stale duplicate unit behind.
|
|
136
|
+
# Falls back to the source/path heuristic when nothing resolves
|
|
137
|
+
# (gem not loaded, autoloading not booted).
|
|
138
|
+
unit_type = runtime_class ? classify_runtime_type(runtime_class) : classify_unit_type(file_path, source)
|
|
99
139
|
|
|
100
140
|
unit = ExtractedUnit.new(
|
|
101
141
|
type: unit_type,
|
|
@@ -115,20 +155,62 @@ module Woods
|
|
|
115
155
|
nil
|
|
116
156
|
end
|
|
117
157
|
|
|
158
|
+
# Extract a unit from a runtime-loaded GraphQL type class.
|
|
159
|
+
#
|
|
160
|
+
# Public because {Extractor::CLASS_BASED_DISCOVERY} names it as the
|
|
161
|
+
# graphql entry's `method:` and the reconciler invokes it with
|
|
162
|
+
# `public_send` (#167). Private, it raised NoMethodError for every
|
|
163
|
+
# candidate — swallowed by the reconciler's `rescue StandardError` into a
|
|
164
|
+
# warn — so the incremental path silently added nothing, which is exactly
|
|
165
|
+
# what #167 set out to fix. `spec/extractor_spec.rb` asserts every
|
|
166
|
+
# CLASS_BASED_DISCOVERY method is publicly callable on its real
|
|
167
|
+
# extractor so no future entry can regress the same way.
|
|
168
|
+
#
|
|
169
|
+
# @param type_class [Class] A graphql-ruby type class
|
|
170
|
+
# @return [ExtractedUnit, nil]
|
|
171
|
+
def extract_from_runtime_type(type_class)
|
|
172
|
+
return nil unless type_class.respond_to?(:name) && type_class.name
|
|
173
|
+
# Skip anonymous or internal graphql-ruby classes
|
|
174
|
+
return nil if type_class.name.start_with?('GraphQL::')
|
|
175
|
+
|
|
176
|
+
file_path = source_file_for_class(type_class)
|
|
177
|
+
source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
|
|
178
|
+
unit_type = classify_runtime_type(type_class)
|
|
179
|
+
|
|
180
|
+
unit = ExtractedUnit.new(
|
|
181
|
+
type: unit_type,
|
|
182
|
+
identifier: type_class.name,
|
|
183
|
+
file_path: file_path
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
unit.namespace = extract_namespace(type_class.name)
|
|
187
|
+
unit.source_code = build_annotated_source(source, type_class.name, unit_type, type_class)
|
|
188
|
+
unit.metadata = build_metadata(source, type_class.name, unit_type, type_class)
|
|
189
|
+
unit.dependencies = extract_dependencies(source, type_class.name)
|
|
190
|
+
unit.chunks = build_chunks(unit, type_class) if unit.needs_chunking?(threshold: CHUNK_THRESHOLD)
|
|
191
|
+
|
|
192
|
+
unit
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
Rails.logger.error("Failed to extract GraphQL type #{type_class.name}: #{e.message}") if defined?(Rails)
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
118
198
|
private
|
|
119
199
|
|
|
120
200
|
# ──────────────────────────────────────────────────────────────────────
|
|
121
201
|
# Schema and Runtime Discovery
|
|
122
202
|
# ──────────────────────────────────────────────────────────────────────
|
|
123
203
|
|
|
124
|
-
#
|
|
204
|
+
# Does this app have GraphQL source to extract?
|
|
205
|
+
#
|
|
206
|
+
# Note what this does *not* ask: whether graphql-ruby is loaded. See
|
|
207
|
+
# {#extract_all} — the gem enriches the units, it does not gate them.
|
|
125
208
|
#
|
|
126
209
|
# @return [Boolean]
|
|
127
|
-
def
|
|
128
|
-
return
|
|
129
|
-
return false unless @graphql_dir&.directory? || @schema_class
|
|
210
|
+
def graphql_source_present?
|
|
211
|
+
return true if @graphql_dir&.directory?
|
|
130
212
|
|
|
131
|
-
|
|
213
|
+
!@schema_class.nil?
|
|
132
214
|
end
|
|
133
215
|
|
|
134
216
|
# Find the application's schema class (descendant of GraphQL::Schema)
|
|
@@ -164,54 +246,34 @@ module Woods
|
|
|
164
246
|
{}
|
|
165
247
|
end
|
|
166
248
|
|
|
167
|
-
#
|
|
168
|
-
# Runtime Type Extraction
|
|
169
|
-
# ──────────────────────────────────────────────────────────────────────
|
|
170
|
-
|
|
171
|
-
# Extract a unit from a runtime-loaded GraphQL type class
|
|
249
|
+
# Locate the file this type was defined in, or nil when there is none.
|
|
172
250
|
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
file_path = source_file_for_class(type_class)
|
|
181
|
-
source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
|
|
182
|
-
unit_type = classify_runtime_type(type_class)
|
|
183
|
-
|
|
184
|
-
unit = ExtractedUnit.new(
|
|
185
|
-
type: unit_type,
|
|
186
|
-
identifier: type_class.name,
|
|
187
|
-
file_path: file_path
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
unit.namespace = extract_namespace(type_class.name)
|
|
191
|
-
unit.source_code = build_annotated_source(source, type_class.name, unit_type, type_class)
|
|
192
|
-
unit.metadata = build_metadata(source, type_class.name, unit_type, type_class)
|
|
193
|
-
unit.dependencies = extract_dependencies(source, type_class.name)
|
|
194
|
-
unit.chunks = build_chunks(unit, type_class) if unit.needs_chunking?(threshold: CHUNK_THRESHOLD)
|
|
195
|
-
|
|
196
|
-
unit
|
|
197
|
-
rescue StandardError => e
|
|
198
|
-
Rails.logger.error("Failed to extract GraphQL type #{type_class.name}: #{e.message}") if defined?(Rails)
|
|
199
|
-
nil
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# Determine the source file for a runtime-loaded class, validating that
|
|
203
|
-
# paths are within Rails.root to avoid returning graphql gem internals.
|
|
251
|
+
# nil, not a fabricated convention path. A runtime-defined type
|
|
252
|
+
# (built by a schema builder or DSL) has no file, and inventing
|
|
253
|
+
# `app/graphql/<constant>.rb` for it created a unit indistinguishable from a
|
|
254
|
+
# file-defined one whose file had since been deleted. That ambiguity is
|
|
255
|
+
# unresolvable downstream: the conventional location of `Types::FooType`
|
|
256
|
+
# *is* `app/graphql/types/foo_type.rb`, so no path comparison can separate
|
|
257
|
+
# "never had a file" from "file was deleted".
|
|
204
258
|
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
259
|
+
# Returning nil resolves it at the only point where the answer is known.
|
|
260
|
+
# `DependencyGraph#register` skips nil paths, so such a unit never enters
|
|
261
|
+
# `file_map` and the deletion sweep — which walks registered paths — cannot
|
|
262
|
+
# reach it. No predicate required, and a full extraction produces the same
|
|
263
|
+
# nil, so the two paths still agree.
|
|
264
|
+
#
|
|
265
|
+
# `resolve_source_location` is tried first and has three tiers of real
|
|
266
|
+
# discovery (`const_source_location`, then instance and singleton method
|
|
267
|
+
# locations). It reaches its fallback only when Ruby cannot place the class
|
|
268
|
+
# in app source at all, which is precisely the no-file case.
|
|
207
269
|
#
|
|
208
270
|
# @param klass [Class]
|
|
209
|
-
# @return [String]
|
|
271
|
+
# @return [String, nil]
|
|
210
272
|
def source_file_for_class(klass)
|
|
211
273
|
convention_path = Rails.root.join("#{GRAPHQL_DIRECTORY}/#{klass.name.underscore}.rb").to_s
|
|
212
274
|
return convention_path if File.exist?(convention_path)
|
|
213
275
|
|
|
214
|
-
resolve_source_location(klass, app_root: Rails.root.to_s, fallback:
|
|
276
|
+
resolve_source_location(klass, app_root: Rails.root.to_s, fallback: nil)
|
|
215
277
|
end
|
|
216
278
|
|
|
217
279
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -286,19 +348,32 @@ module Woods
|
|
|
286
348
|
|
|
287
349
|
# Extract the fully-qualified class name from source or file path
|
|
288
350
|
#
|
|
351
|
+
# The identifier is the *outermost* class: the first `class` declaration
|
|
352
|
+
# qualified by the modules actually open at that position, via the
|
|
353
|
+
# position-aware nesting scanner ({SourceNesting}, #174). Declarations
|
|
354
|
+
# after the first `class` are nested — joining every declaration in the
|
|
355
|
+
# file made a mutation with an inner `class InvalidInput < StandardError`
|
|
356
|
+
# identify as `Mutations::CreateUser::InvalidInput`, so the file-pass
|
|
357
|
+
# unit never deduped against the runtime unit in {#extract_all}:
|
|
358
|
+
# double-indexing with graphql-ruby loaded, dangling edges without it
|
|
359
|
+
# (#202). Tracking nesting by position also stops a sibling module that
|
|
360
|
+
# closed *before* the class opened from polluting the prefix — the old
|
|
361
|
+
# end-blind scan turned `module Helpers ... end; module Mutations; class
|
|
362
|
+
# CreateUser` into `Helpers::Mutations::CreateUser`. Module-only files
|
|
363
|
+
# (e.g. interfaces) keep their identifier via the outer module chain.
|
|
364
|
+
#
|
|
289
365
|
# @param file_path [String]
|
|
290
366
|
# @param source [String]
|
|
291
367
|
# @return [String, nil]
|
|
292
368
|
def extract_class_name(file_path, source)
|
|
293
|
-
#
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
#
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
modules.join('::')
|
|
369
|
+
# Zeitwerk-governed naming first (G-1) — managed graphql/ files are
|
|
370
|
+
# named for the constant their path spells, and the expected path can
|
|
371
|
+
# only match the outer, file-named declaration, so the #202
|
|
372
|
+
# outermost-class contract below is preserved by construction. Then
|
|
373
|
+
# the position-aware nesting scan (#174), then the outer module
|
|
374
|
+
# chain for module-only files (interfaces).
|
|
375
|
+
governed_class_name(file_path, source) ||
|
|
376
|
+
qualified_first_class_name(source) || qualified_outer_module_name(source)
|
|
302
377
|
rescue StandardError
|
|
303
378
|
# Fall back to convention from file path
|
|
304
379
|
return nil unless defined?(Rails)
|
|
@@ -636,13 +711,20 @@ module Woods
|
|
|
636
711
|
def extract_complexity(source)
|
|
637
712
|
complexities = []
|
|
638
713
|
|
|
639
|
-
|
|
714
|
+
# `[^\n]*?` keeps the field name and its `complexity:` on one
|
|
715
|
+
# declaration line. With `.*?` under /m the match crossed
|
|
716
|
+
# declarations, so a field with no complexity absorbed the *next*
|
|
717
|
+
# field's — and the real owner lost it, because `scan` resumes after
|
|
718
|
+
# the match (EXTB-8). The lambda value may still span lines.
|
|
719
|
+
source.scan(/field\s+:(\w+)[^\n]*?complexity:\s*(\d+|->.*?(?:end|\}))/m) do |name, value|
|
|
640
720
|
complexities << { field: name, complexity: value.strip }
|
|
641
721
|
end
|
|
642
722
|
|
|
643
|
-
# Max complexity on schema level
|
|
644
|
-
|
|
645
|
-
|
|
723
|
+
# Max complexity on schema level. `match`, not `match?` — the latter
|
|
724
|
+
# never populates `$~`/`Regexp.last_match`, so the capture always
|
|
725
|
+
# read back as 0 (or a stale match from elsewhere in the method).
|
|
726
|
+
if (schema_match = source.match(/max_complexity\s+(\d+)/))
|
|
727
|
+
complexities << { field: :schema, complexity: schema_match[1].to_i }
|
|
646
728
|
end
|
|
647
729
|
|
|
648
730
|
complexities
|
|
@@ -750,15 +832,29 @@ module Woods
|
|
|
750
832
|
deps << { type: :model, target: model_ref, via: :code_reference }
|
|
751
833
|
end
|
|
752
834
|
|
|
753
|
-
|
|
835
|
+
# The per-constant follow-up check used to run one full-source scan
|
|
836
|
+
# for every unique capitalized constant (audit P9c). One combined
|
|
837
|
+
# scan collects the constants that are actually followed by a model
|
|
838
|
+
# call; only the exact word at a position can match (each candidate
|
|
839
|
+
# is a maximal word in this source and `\.` must immediately follow),
|
|
840
|
+
# so the collected set is identical. Candidate order drives emission
|
|
841
|
+
# below, unchanged.
|
|
842
|
+
candidates = source.scan(/\b([A-Z][a-z][a-zA-Z]*)\b/).flatten.uniq
|
|
843
|
+
model_callers = if candidates.empty?
|
|
844
|
+
{}
|
|
845
|
+
else
|
|
846
|
+
source.scan(
|
|
847
|
+
/\b(#{candidates.map { |c| Regexp.escape(c) }.join('|')})\.(?:find|where|find_by|create|new|first|last|all)\b/
|
|
848
|
+
).flatten.to_h { |const| [const, true] }
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
candidates.each do |const_ref|
|
|
754
852
|
if const_ref.match?(/\A(Types|Mutations|Resolvers|GraphQL|Base|String|Integer|Float|Boolean|Array|Hash|Set|Struct|Module|Class|Object|ID|Int|ISO8601)\z/)
|
|
755
853
|
next
|
|
756
854
|
end
|
|
757
855
|
next if deps.any? { |d| d[:target] == const_ref }
|
|
758
856
|
|
|
759
|
-
if
|
|
760
|
-
deps << { type: :model, target: const_ref, via: :code_reference }
|
|
761
|
-
end
|
|
857
|
+
deps << { type: :model, target: const_ref, via: :code_reference } if model_callers.key?(const_ref)
|
|
762
858
|
end
|
|
763
859
|
|
|
764
860
|
deps.concat(scan_service_dependencies(source))
|
|
@@ -45,7 +45,12 @@ module Woods
|
|
|
45
45
|
# @return [ExtractedUnit, nil] The extracted unit or nil on failure
|
|
46
46
|
def extract_i18n_file(file_path)
|
|
47
47
|
source = File.read(file_path)
|
|
48
|
-
|
|
48
|
+
# aliases: true — locale files commonly share defaults via anchors
|
|
49
|
+
# (`<<: *defaults`); without it Psych 4+ raises AliasesNotEnabled and
|
|
50
|
+
# the whole file is silently dropped (#203). Alias expansion only
|
|
51
|
+
# re-references the scalar/hash types already permitted, so it adds
|
|
52
|
+
# no deserialization risk.
|
|
53
|
+
data = YAML.safe_load(source, permitted_classes: [Symbol, Date, Time, Regexp], aliases: true)
|
|
49
54
|
|
|
50
55
|
return nil unless data.is_a?(Hash) && data.any?
|
|
51
56
|
|