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
|
@@ -48,12 +48,22 @@ module Woods
|
|
|
48
48
|
#
|
|
49
49
|
# @return [Array<ExtractedUnit>] List of model units
|
|
50
50
|
def extract_all
|
|
51
|
+
discoverable_classes.map { |model| extract_model(model) }.compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The model classes this extractor would extract from the running app.
|
|
55
|
+
#
|
|
56
|
+
# Shared with the incremental path, which reconciles this set against
|
|
57
|
+
# the identifiers already in the dependency graph to spot classes added
|
|
58
|
+
# since the last extraction (#164). Keeping discovery in one method is
|
|
59
|
+
# what makes that reconciliation exact rather than a re-guess.
|
|
60
|
+
#
|
|
61
|
+
# @return [Array<Class>]
|
|
62
|
+
def discoverable_classes
|
|
51
63
|
ActiveRecord::Base.descendants
|
|
52
|
-
.reject
|
|
64
|
+
.reject { |m| abstract_class?(m) }
|
|
53
65
|
.reject { |m| m.name.nil? } # Skip anonymous classes
|
|
54
66
|
.reject { |m| habtm_join_model?(m) }
|
|
55
|
-
.map { |model| extract_model(model) }
|
|
56
|
-
.compact
|
|
57
67
|
end
|
|
58
68
|
|
|
59
69
|
# Extract a single model
|
|
@@ -71,12 +81,15 @@ module Woods
|
|
|
71
81
|
source = source_path && File.exist?(source_path) ? File.read(source_path) : nil
|
|
72
82
|
|
|
73
83
|
unit.namespace = model.module_parent.name unless model.module_parent == Object
|
|
74
|
-
|
|
75
|
-
unit.
|
|
84
|
+
inlined_source, inlined_concerns = build_model_source_with_concerns(model, source)
|
|
85
|
+
unit.source_code = build_composite_source(model, inlined_source)
|
|
86
|
+
unit.metadata = extract_metadata(model, source, inlined_concerns: inlined_concerns)
|
|
76
87
|
unit.dependencies = extract_dependencies(model, source)
|
|
77
88
|
|
|
78
|
-
# Enrich callbacks with side-effect analysis
|
|
79
|
-
|
|
89
|
+
# Enrich callbacks with side-effect analysis. The analyzer parses real
|
|
90
|
+
# Ruby, so it gets the raw analysis composite — the display composite
|
|
91
|
+
# comments concern bodies out, which Prism cannot see.
|
|
92
|
+
enrich_callbacks_with_side_effects(unit, source, analysis_source: build_analysis_source(model, source))
|
|
80
93
|
|
|
81
94
|
# Build semantic chunks for all models (summary, associations, callbacks, validations)
|
|
82
95
|
unit.chunks = build_chunks(unit)
|
|
@@ -90,11 +103,28 @@ module Woods
|
|
|
90
103
|
|
|
91
104
|
private
|
|
92
105
|
|
|
106
|
+
# Is this an abstract AR base class? Guarded exactly like
|
|
107
|
+
# {Woods::ModelNameCache#abstract_model?}: a descendant that raises on
|
|
108
|
+
# the call (a half-loaded class under the NameError fallback) is kept
|
|
109
|
+
# rather than dropped. An unguarded `.reject(&:abstract_class?)` here
|
|
110
|
+
# let that raise escape {#discoverable_classes} and abort the whole
|
|
111
|
+
# models phase (L1); per-class extraction failures are already handled
|
|
112
|
+
# by {#extract_model}'s own rescue.
|
|
113
|
+
#
|
|
114
|
+
# @param klass [Class]
|
|
115
|
+
# @return [Boolean] true when the class is an abstract AR base class
|
|
116
|
+
def abstract_class?(klass)
|
|
117
|
+
klass.respond_to?(:abstract_class?) && klass.abstract_class?
|
|
118
|
+
rescue StandardError
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
93
122
|
# Find the source file for a model, handling STI and namespacing.
|
|
94
123
|
#
|
|
95
124
|
# Uses convention path first (most reliable for models), then falls
|
|
96
|
-
# back to introspection via {#resolve_source_location} which
|
|
97
|
-
#
|
|
125
|
+
# back to introspection via {#resolve_source_location}, which prefers
|
|
126
|
+
# app-owned locations and keeps a gem-owned class (an engine model) at
|
|
127
|
+
# its real gem path rather than a synthesized app path.
|
|
98
128
|
def source_file_for(model)
|
|
99
129
|
convention_path = Rails.root.join("app/models/#{model.name.underscore}.rb").to_s
|
|
100
130
|
return convention_path if File.exist?(convention_path)
|
|
@@ -110,17 +140,15 @@ module Woods
|
|
|
110
140
|
model.name.demodulize.start_with?('HABTM_')
|
|
111
141
|
end
|
|
112
142
|
|
|
113
|
-
# Build composite source with schema header and inlined
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
parts.compact.join("\n\n")
|
|
143
|
+
# Build composite source with schema header and the concern-inlined
|
|
144
|
+
# model source.
|
|
145
|
+
#
|
|
146
|
+
# @param model [Class] The ActiveRecord model class
|
|
147
|
+
# @param inlined_source [String] Model source with concerns already
|
|
148
|
+
# inlined (see {#build_model_source_with_concerns})
|
|
149
|
+
# @return [String]
|
|
150
|
+
def build_composite_source(model, inlined_source)
|
|
151
|
+
[build_schema_comment(model), inlined_source].compact.join("\n\n")
|
|
124
152
|
end
|
|
125
153
|
|
|
126
154
|
# Generate schema comment block with columns, indexes, and foreign keys
|
|
@@ -181,39 +209,93 @@ module Woods
|
|
|
181
209
|
[]
|
|
182
210
|
end
|
|
183
211
|
|
|
184
|
-
# Read model source and inline all included concerns
|
|
212
|
+
# Read model source and inline all included concerns.
|
|
213
|
+
#
|
|
214
|
+
# Concern code is inserted as '# '-prefixed comment lines right after
|
|
215
|
+
# the model's class declaration, matching both nested (+class Book+)
|
|
216
|
+
# and compact (+class Library::Book+) declaration styles. When no
|
|
217
|
+
# declaration line can be found in unconventional source, the block is
|
|
218
|
+
# appended at the end instead of being silently discarded, and a
|
|
219
|
+
# warning naming the model is recorded.
|
|
220
|
+
#
|
|
221
|
+
# @param model [Class] The ActiveRecord model class
|
|
222
|
+
# @param source [String, nil] Pre-read model source (read from disk when nil)
|
|
223
|
+
# @return [Array(String, Array<String>)] The concern-inlined source and
|
|
224
|
+
# the demodulized names of the concerns actually inlined into it.
|
|
225
|
+
# Returning the pair keeps metadata[:inlined_concerns] truthful —
|
|
226
|
+
# it is derived from the insertion result, never recomputed
|
|
227
|
+
# independently of what the composite source carries.
|
|
185
228
|
def build_model_source_with_concerns(model, source = nil)
|
|
186
229
|
if source.nil?
|
|
187
230
|
source_path = source_file_for(model)
|
|
188
|
-
return '' unless source_path && File.exist?(source_path)
|
|
231
|
+
return ['', []] unless source_path && File.exist?(source_path)
|
|
189
232
|
|
|
190
233
|
source = File.read(source_path)
|
|
191
234
|
end
|
|
192
235
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
236
|
+
concern_sources = resolved_concern_sources(model)
|
|
237
|
+
return [source, []] if concern_sources.empty?
|
|
238
|
+
|
|
239
|
+
[insert_concern_block(model, source, build_concern_block(concern_sources)),
|
|
240
|
+
concern_sources.map { |name, _code| name.demodulize }]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Resolve [name, code] pairs for every concern included in the model
|
|
244
|
+
# whose source file can be located.
|
|
245
|
+
#
|
|
246
|
+
# @param model [Class] The ActiveRecord model class
|
|
247
|
+
# @return [Array<Array(String, String)>]
|
|
248
|
+
def resolved_concern_sources(model)
|
|
249
|
+
extract_included_modules(model).filter_map { |mod| concern_source(mod) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Render concern sources as a '#'-commented display block showing
|
|
253
|
+
# what's mixed in.
|
|
254
|
+
#
|
|
255
|
+
# @param concern_sources [Array<Array(String, String)>] [name, code] pairs
|
|
256
|
+
# @return [String]
|
|
257
|
+
def build_concern_block(concern_sources)
|
|
258
|
+
concern_sources.map do |name, code|
|
|
259
|
+
indented = code.lines.map { |l| " # #{l.rstrip}" }.join("\n")
|
|
260
|
+
<<~CONCERN
|
|
261
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
262
|
+
# │ Included from: #{name.ljust(54)}│
|
|
263
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
264
|
+
#{indented}
|
|
265
|
+
# ─────────────────────────── End #{name} ───────────────────────────
|
|
266
|
+
CONCERN
|
|
267
|
+
end.join("\n\n")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Insert the concern block after the model's class declaration line.
|
|
271
|
+
#
|
|
272
|
+
# Falls back to appending at end-of-source (recording a warning) when
|
|
273
|
+
# no declaration matches — dropping the block silently would leave
|
|
274
|
+
# source_code contradicting metadata[:inlined_concerns].
|
|
275
|
+
#
|
|
276
|
+
# @param model [Class] The ActiveRecord model class
|
|
277
|
+
# @param source [String] The model source
|
|
278
|
+
# @param concern_block [String] Commented concern block to insert
|
|
279
|
+
# @return [String]
|
|
280
|
+
def insert_concern_block(model, source, concern_block)
|
|
281
|
+
pattern = class_declaration_pattern(model)
|
|
282
|
+
return source.sub(pattern) { "#{::Regexp.last_match(1)}\n\n#{concern_block}" } if source.match?(pattern)
|
|
283
|
+
|
|
284
|
+
@warnings << "[#{model.name}] No class declaration matched for concern inlining; " \
|
|
285
|
+
'appending inlined concern block at end of source'
|
|
286
|
+
"#{source.chomp}\n\n#{concern_block}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Regexp matching the model's class declaration in either nested
|
|
290
|
+
# (+class Book+) or compact (+class Library::Book+) style. The word
|
|
291
|
+
# boundary stops +class BookmarkError+ from claiming model Book's
|
|
292
|
+
# concern block; the +(?!::)+ lookahead stops a nested
|
|
293
|
+
# +class Book::Error+ from claiming it either.
|
|
294
|
+
#
|
|
295
|
+
# @param model [Class] The ActiveRecord model class
|
|
296
|
+
# @return [Regexp]
|
|
297
|
+
def class_declaration_pattern(model)
|
|
298
|
+
/(class\s+(?:[\w:]+::)?#{Regexp.escape(model.name.demodulize)}\b(?!::).*$)/
|
|
217
299
|
end
|
|
218
300
|
|
|
219
301
|
# Get modules included specifically in this model (not inherited)
|
|
@@ -315,11 +397,21 @@ module Woods
|
|
|
315
397
|
# ──────────────────────────────────────────────────────────────────────
|
|
316
398
|
|
|
317
399
|
# Extract comprehensive metadata for retrieval and filtering
|
|
318
|
-
|
|
400
|
+
#
|
|
401
|
+
# @param model [Class] The ActiveRecord model class
|
|
402
|
+
# @param source [String, nil] The model source code
|
|
403
|
+
# @param inlined_concerns [Array<String>, nil] Demodulized names of the
|
|
404
|
+
# concerns actually inlined into the unit's source_code (from
|
|
405
|
+
# {#build_model_source_with_concerns}). When nil, derived by running
|
|
406
|
+
# the same inlining — the metadata must never claim a concern the
|
|
407
|
+
# composite source does not carry.
|
|
408
|
+
# @return [Hash]
|
|
409
|
+
def extract_metadata(model, source = nil, inlined_concerns: nil)
|
|
319
410
|
{
|
|
320
411
|
# Core identifiers
|
|
321
412
|
table_name: model.table_name,
|
|
322
413
|
primary_key: model.primary_key,
|
|
414
|
+
database: database_name_for(model),
|
|
323
415
|
|
|
324
416
|
# Relationships and behaviors
|
|
325
417
|
associations: extract_associations(model),
|
|
@@ -327,9 +419,7 @@ module Woods
|
|
|
327
419
|
callbacks: extract_callbacks(model),
|
|
328
420
|
scopes: extract_scopes(model, source),
|
|
329
421
|
enums: extract_enums(model),
|
|
330
|
-
inlined_concerns:
|
|
331
|
-
.select { |mod| mod.name && concern_source(mod) }
|
|
332
|
-
.map { |mod| mod.name.demodulize },
|
|
422
|
+
inlined_concerns: inlined_concerns || build_model_source_with_concerns(model, source).last,
|
|
333
423
|
|
|
334
424
|
# API surface
|
|
335
425
|
class_methods: model.methods(false).sort,
|
|
@@ -359,6 +449,11 @@ module Woods
|
|
|
359
449
|
[]
|
|
360
450
|
end,
|
|
361
451
|
|
|
452
|
+
# Foreign keys as the connection reports them. The target table's
|
|
453
|
+
# database is a graph-level concern, resolved by the
|
|
454
|
+
# cross_database_edges report, not stored per model here (#280).
|
|
455
|
+
foreign_keys: extract_foreign_keys(model),
|
|
456
|
+
|
|
362
457
|
# ActiveStorage / ActionText
|
|
363
458
|
active_storage_attachments: extract_active_storage_attachments(source),
|
|
364
459
|
action_text_fields: extract_action_text_fields(source),
|
|
@@ -464,7 +559,15 @@ module Woods
|
|
|
464
559
|
# Extract all associations with full details.
|
|
465
560
|
# Broken associations (e.g. missing class_name) are skipped with a warning
|
|
466
561
|
# instead of aborting the entire model extraction.
|
|
562
|
+
#
|
|
563
|
+
# `from_db` and `to_db` name the database each side resolves to (nil on
|
|
564
|
+
# Rails 6.0, where `connection_db_config` does not exist, and nil on
|
|
565
|
+
# the target side for a polymorphic or unresolvable class). `through_db`
|
|
566
|
+
# names the database a has_many :through join model resolves to (#280),
|
|
567
|
+
# nil for a plain association.
|
|
568
|
+
# `disable_joins` is the Rails 7.0+ option read from the reflection.
|
|
467
569
|
def extract_associations(model)
|
|
570
|
+
from_db = database_name_for(model)
|
|
468
571
|
model.reflect_on_all_associations.filter_map do |assoc|
|
|
469
572
|
{
|
|
470
573
|
name: assoc.name,
|
|
@@ -472,9 +575,13 @@ module Woods
|
|
|
472
575
|
target: assoc.class_name,
|
|
473
576
|
options: extract_association_options(assoc),
|
|
474
577
|
through: assoc.options[:through],
|
|
578
|
+
through_db: through_association_database(assoc),
|
|
475
579
|
polymorphic: assoc.polymorphic?,
|
|
476
580
|
foreign_key: assoc.foreign_key,
|
|
477
|
-
inverse_of: assoc.inverse_of&.name
|
|
581
|
+
inverse_of: assoc.inverse_of&.name,
|
|
582
|
+
from_db: from_db,
|
|
583
|
+
to_db: association_target_database(assoc),
|
|
584
|
+
disable_joins: assoc.options[:disable_joins] == true
|
|
478
585
|
}
|
|
479
586
|
rescue NameError => e
|
|
480
587
|
@warnings << "[#{model.name}] Skipping broken association #{assoc.name}: #{e.message}"
|
|
@@ -487,7 +594,7 @@ module Woods
|
|
|
487
594
|
:dependent, :through, :source, :source_type,
|
|
488
595
|
:foreign_key, :primary_key, :inverse_of,
|
|
489
596
|
:counter_cache, :touch, :optional, :required,
|
|
490
|
-
:class_name, :as, :foreign_type
|
|
597
|
+
:class_name, :as, :foreign_type, :disable_joins
|
|
491
598
|
)
|
|
492
599
|
end
|
|
493
600
|
|
|
@@ -615,11 +722,34 @@ module Woods
|
|
|
615
722
|
# Dependency Extraction
|
|
616
723
|
# ──────────────────────────────────────────────────────────────────────
|
|
617
724
|
|
|
618
|
-
# Extract what this model depends on
|
|
725
|
+
# Extract what this model depends on.
|
|
726
|
+
#
|
|
727
|
+
# Association reflections normally become a +:model+ edge labeled with
|
|
728
|
+
# the association macro. Polymorphic associations are the exception:
|
|
729
|
+
# `belongs_to :commentable, polymorphic: true` names an *interface*,
|
|
730
|
+
# not a model — +AssociationReflection#class_name+ camelizes the
|
|
731
|
+
# association name ("Commentable") without constantizing it, so the
|
|
732
|
+
# NameError rescue never fires and the edge pointed at a nonexistent
|
|
733
|
+
# node, or worse, at an unrelated real constant (an app's Commentable
|
|
734
|
+
# concern) mislabeled as a +:belongs_to+ model edge (#199). Those
|
|
735
|
+
# edges now carry +via: :polymorphic_interface+ instead of the macro:
|
|
736
|
+
# the interface name stays visible in the graph but is distinguishable
|
|
737
|
+
# from a resolvable model reference.
|
|
619
738
|
def extract_dependencies(model, source = nil)
|
|
620
|
-
# Associations point to other models
|
|
739
|
+
# Associations point to other models. `through`, `through_db`, and
|
|
740
|
+
# `disable_joins` ride on the edge so the graph can report a
|
|
741
|
+
# has_many :through that crosses databases without disable_joins,
|
|
742
|
+
# or whose join model itself sits on a third database (#280).
|
|
621
743
|
deps = model.reflect_on_all_associations.filter_map do |assoc|
|
|
622
|
-
|
|
744
|
+
via = polymorphic_reflection?(assoc) ? :polymorphic_interface : assoc.macro
|
|
745
|
+
dep = { type: :model, target: assoc.class_name, via: via }
|
|
746
|
+
if assoc.options[:through]
|
|
747
|
+
dep[:through] = assoc.options[:through].to_s
|
|
748
|
+
through_db = through_association_database(assoc)
|
|
749
|
+
dep[:through_db] = through_db if through_db
|
|
750
|
+
end
|
|
751
|
+
dep[:disable_joins] = true if assoc.options[:disable_joins] == true
|
|
752
|
+
dep
|
|
623
753
|
rescue NameError => e
|
|
624
754
|
@warnings << "[#{model.name}] Skipping broken association dep #{assoc.name}: #{e.message}"
|
|
625
755
|
nil
|
|
@@ -657,18 +787,123 @@ module Woods
|
|
|
657
787
|
consolidate_dependencies(deps)
|
|
658
788
|
end
|
|
659
789
|
|
|
790
|
+
# Whether an association reflection declares a polymorphic interface.
|
|
791
|
+
#
|
|
792
|
+
# Guarded with +respond_to?+ because not every reflection type across
|
|
793
|
+
# Rails versions exposes +#polymorphic?+ — a reflection without it is
|
|
794
|
+
# treated as a plain (non-polymorphic) association.
|
|
795
|
+
#
|
|
796
|
+
# @param assoc [ActiveRecord::Reflection::AbstractReflection]
|
|
797
|
+
# @return [Boolean]
|
|
798
|
+
def polymorphic_reflection?(assoc)
|
|
799
|
+
assoc.respond_to?(:polymorphic?) && assoc.polymorphic?
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# The database a class resolves to, by runtime reflection.
|
|
803
|
+
#
|
|
804
|
+
# `connection_db_config` (Rails 6.1+) climbs to the abstract class that
|
|
805
|
+
# declared `connects_to`, so a concrete model that inherits its
|
|
806
|
+
# connection answers with the inherited database. This is what a
|
|
807
|
+
# regex over `connects_to database:` in the source could never see.
|
|
808
|
+
# Rails 6.0 has no `connection_db_config`; the answer degrades to nil.
|
|
809
|
+
#
|
|
810
|
+
# @param klass [Class]
|
|
811
|
+
# @return [String, nil]
|
|
812
|
+
def database_name_for(klass)
|
|
813
|
+
return nil unless klass.respond_to?(:connection_db_config)
|
|
814
|
+
|
|
815
|
+
config = klass.connection_db_config
|
|
816
|
+
config.respond_to?(:name) ? config.name.to_s : nil
|
|
817
|
+
rescue StandardError
|
|
818
|
+
nil
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# The database an association's target resolves to. Nil for a
|
|
822
|
+
# polymorphic interface (no single class) and for a target that does
|
|
823
|
+
# not constantize.
|
|
824
|
+
#
|
|
825
|
+
# @param assoc [ActiveRecord::Reflection::AbstractReflection]
|
|
826
|
+
# @return [String, nil]
|
|
827
|
+
def association_target_database(assoc)
|
|
828
|
+
return nil if polymorphic_reflection?(assoc)
|
|
829
|
+
|
|
830
|
+
database_name_for(assoc.klass)
|
|
831
|
+
rescue StandardError
|
|
832
|
+
nil
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# The database a has_many :through association's join model resolves
|
|
836
|
+
# to. Nil for a plain association, when the through reflection cannot
|
|
837
|
+
# resolve its class, and on Rails 6.0 (no connection_db_config).
|
|
838
|
+
#
|
|
839
|
+
# @param assoc [ActiveRecord::Reflection::AbstractReflection]
|
|
840
|
+
# @return [String, nil]
|
|
841
|
+
def through_association_database(assoc)
|
|
842
|
+
return nil unless assoc.options[:through]
|
|
843
|
+
|
|
844
|
+
database_name_for(assoc.through_reflection.klass)
|
|
845
|
+
rescue StandardError
|
|
846
|
+
nil
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# Foreign keys declared on the model's table, as the connection
|
|
850
|
+
# reports them. The target table's database is intentionally not
|
|
851
|
+
# resolved here, that is left to the graph-level cross_database_edges
|
|
852
|
+
# report, which resolves it from table owners.
|
|
853
|
+
#
|
|
854
|
+
# @param model [Class]
|
|
855
|
+
# @return [Array<Hash>] `{ from_table:, to_table:, column: }`
|
|
856
|
+
def extract_foreign_keys(model)
|
|
857
|
+
return [] unless model.table_exists?
|
|
858
|
+
|
|
859
|
+
model.connection.foreign_keys(model.table_name).map do |fk|
|
|
860
|
+
{ from_table: fk.from_table.to_s, to_table: fk.to_table.to_s, column: fk.column.to_s }
|
|
861
|
+
end
|
|
862
|
+
rescue StandardError
|
|
863
|
+
[]
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
# Build a parse-friendly composite for callback side-effect analysis.
|
|
867
|
+
#
|
|
868
|
+
# The displayed source_code inlines concern code as comment lines — a
|
|
869
|
+
# deliberate presentation choice — but Prism cannot see commented
|
|
870
|
+
# code, so the standard idiom (a concern's +included do+ registering a
|
|
871
|
+
# callback whose method is defined in the concern body) was invisible
|
|
872
|
+
# to CallbackAnalyzer. This composite concatenates the raw model
|
|
873
|
+
# source with each concern's raw module source: top-level modules
|
|
874
|
+
# parse fine and the analyzer's method search walks the whole tree.
|
|
875
|
+
#
|
|
876
|
+
# @param model [Class] The ActiveRecord model class
|
|
877
|
+
# @param source [String, nil] The raw model source
|
|
878
|
+
# @return [String, nil] Concatenated raw sources, or +source+ when no
|
|
879
|
+
# concern source is resolvable
|
|
880
|
+
def build_analysis_source(model, source)
|
|
881
|
+
return source unless source
|
|
882
|
+
|
|
883
|
+
concern_codes = resolved_concern_sources(model).map { |_name, code| code }
|
|
884
|
+
return source if concern_codes.empty?
|
|
885
|
+
|
|
886
|
+
([source] + concern_codes).join("\n\n")
|
|
887
|
+
end
|
|
888
|
+
|
|
660
889
|
# Enrich callback metadata with side-effect analysis.
|
|
661
890
|
#
|
|
662
891
|
# Uses CallbackAnalyzer to find each callback's method body and
|
|
663
892
|
# classify its side effects (column writes, job enqueues, etc.).
|
|
893
|
+
# The analyzer parses real Ruby, so callers should hand it a
|
|
894
|
+
# parse-friendly +analysis_source+ (raw model + raw concern code, see
|
|
895
|
+
# {#build_analysis_source}) rather than the display composite whose
|
|
896
|
+
# concern bodies are commented out. Falls back to +unit.source_code+
|
|
897
|
+
# when no analysis source is given.
|
|
664
898
|
#
|
|
665
899
|
# @param unit [ExtractedUnit] The model unit with metadata[:callbacks] set
|
|
666
900
|
# @param source [String, nil] The model source code
|
|
667
|
-
|
|
901
|
+
# @param analysis_source [String, nil] Parse-friendly composite source
|
|
902
|
+
def enrich_callbacks_with_side_effects(unit, source, analysis_source: nil)
|
|
668
903
|
return unless source && unit.metadata[:callbacks]&.any?
|
|
669
904
|
|
|
670
905
|
analyzer = CallbackAnalyzer.new(
|
|
671
|
-
source_code: unit.source_code,
|
|
906
|
+
source_code: analysis_source || unit.source_code,
|
|
672
907
|
column_names: unit.metadata[:column_names] || []
|
|
673
908
|
)
|
|
674
909
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Extractors
|
|
8
|
+
# PackageExtractor reads Packwerk / pks package boundaries (#280).
|
|
9
|
+
#
|
|
10
|
+
# One unit per `package.yml`, identified the way Packwerk names packages:
|
|
11
|
+
# the package directory relative to Rails.root, with `.` for the root
|
|
12
|
+
# package. Declared dependencies become `:package_dependency` edges, so
|
|
13
|
+
# the graph can tell a declared cross-package call from an undeclared
|
|
14
|
+
# one ({Woods::GraphAnalyzer#undeclared_package_edges}). Enforcement stays
|
|
15
|
+
# with `packwerk check` / `pks check`; Woods only makes the boundary
|
|
16
|
+
# visible before an agent writes the call.
|
|
17
|
+
#
|
|
18
|
+
# Pure file read, no Rails boot needed. Registered as a whole-app
|
|
19
|
+
# extractor: a package root decides which package every other unit
|
|
20
|
+
# belongs to, so any `package.yml` change re-runs it wholesale.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# extractor = PackageExtractor.new
|
|
24
|
+
# extractor.extract_all.map(&:identifier) # => [".", "packs/billing"]
|
|
25
|
+
# extractor.package_for("packs/billing/app/x.rb") # => "packs/billing"
|
|
26
|
+
#
|
|
27
|
+
class PackageExtractor
|
|
28
|
+
PACKAGE_FILE = 'package.yml'
|
|
29
|
+
PACKWERK_CONFIG = 'packwerk.yml'
|
|
30
|
+
ROOT_PACKAGE = '.'
|
|
31
|
+
|
|
32
|
+
# Packwerk's own defaults when packwerk.yml is absent or silent.
|
|
33
|
+
DEFAULT_PACKAGE_PATHS = ['**/'].freeze
|
|
34
|
+
DEFAULT_EXCLUDE = ['{bin,node_modules,script,tmp,vendor}/**/*'].freeze
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@rails_root = Pathname.new(Rails.root.to_s)
|
|
38
|
+
@packwerk = load_packwerk_config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Array<ExtractedUnit>] one unit per package
|
|
42
|
+
def extract_all
|
|
43
|
+
package_files.filter_map { |path| extract_package_file(path) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Extract one package.yml into a unit.
|
|
47
|
+
#
|
|
48
|
+
# @param file_path [String] absolute path to a package.yml
|
|
49
|
+
# @return [ExtractedUnit, nil] nil when the file is not a YAML mapping
|
|
50
|
+
def extract_package_file(file_path)
|
|
51
|
+
source = File.read(file_path)
|
|
52
|
+
data = YAML.safe_load(source, permitted_classes: [Symbol], aliases: true) || {}
|
|
53
|
+
raise ArgumentError, 'package.yml is not a mapping' unless data.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
name = package_name(file_path)
|
|
56
|
+
dependencies = Array(data['dependencies']).map(&:to_s).uniq.sort
|
|
57
|
+
|
|
58
|
+
unit = ExtractedUnit.new(type: :package, identifier: name, file_path: file_path)
|
|
59
|
+
unit.source_code = source
|
|
60
|
+
unit.metadata = {
|
|
61
|
+
name: name,
|
|
62
|
+
dependencies: dependencies,
|
|
63
|
+
enforce_dependencies: normalize_enforcement(data['enforce_dependencies']),
|
|
64
|
+
enforce_privacy: normalize_enforcement(data['enforce_privacy']),
|
|
65
|
+
layer: data['layer'],
|
|
66
|
+
public_path: data['public_path'] || 'app/public',
|
|
67
|
+
owner: data.dig('metadata', 'owner') || data['owner']
|
|
68
|
+
}
|
|
69
|
+
unit.dependencies = dependencies.map { |dep| { type: :package, target: dep, via: :package_dependency } }
|
|
70
|
+
unit
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Rails.logger.error("Failed to extract package from #{file_path}: #{e.message}")
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Package names, longest path first so a nested package wins over its
|
|
77
|
+
# parent, with the root package last. Memoized per instance: an
|
|
78
|
+
# instance lives for one extraction run.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<String>]
|
|
81
|
+
def package_roots
|
|
82
|
+
@package_roots ||= package_files.map { |path| package_name(path) }
|
|
83
|
+
.sort_by { |name| [name == ROOT_PACKAGE ? 1 : 0, -name.length, name] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The package a path belongs to.
|
|
87
|
+
#
|
|
88
|
+
# @param path [String, Pathname] absolute or Rails.root-relative
|
|
89
|
+
# @return [String, nil] nil outside Rails.root or when no package claims it
|
|
90
|
+
def package_for(path)
|
|
91
|
+
relative = relativize(path)
|
|
92
|
+
return nil if relative.nil?
|
|
93
|
+
|
|
94
|
+
package_roots.find { |root| root == ROOT_PACKAGE || relative.start_with?("#{root}/") }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Every package.yml the packwerk configuration admits, sorted.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<String>] absolute paths
|
|
102
|
+
def package_files
|
|
103
|
+
@package_files ||= begin
|
|
104
|
+
patterns = Array(@packwerk['package_paths'] || DEFAULT_PACKAGE_PATHS)
|
|
105
|
+
excludes = Array(@packwerk['exclude'] || DEFAULT_EXCLUDE)
|
|
106
|
+
found = patterns.flat_map do |pattern|
|
|
107
|
+
Dir.glob(@rails_root.join(pattern.to_s, PACKAGE_FILE).to_s)
|
|
108
|
+
end
|
|
109
|
+
found.uniq.reject { |path| excluded?(relativize(path), excludes) }.sort
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def excluded?(relative, excludes)
|
|
114
|
+
excludes.any? { |glob| File.fnmatch?(glob.to_s, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Hash] parsed packwerk.yml, or {} when absent or unreadable
|
|
118
|
+
def load_packwerk_config
|
|
119
|
+
path = @rails_root.join(PACKWERK_CONFIG)
|
|
120
|
+
return {} unless path.file?
|
|
121
|
+
|
|
122
|
+
data = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true)
|
|
123
|
+
data.is_a?(Hash) ? data : {}
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
Rails.logger.warn("[Woods] Could not read #{PACKWERK_CONFIG}: #{e.message}")
|
|
126
|
+
{}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @param file_path [String] absolute path to a package.yml
|
|
130
|
+
# @return [String] Packwerk package name
|
|
131
|
+
def package_name(file_path)
|
|
132
|
+
dir = File.dirname(relativize(file_path).to_s)
|
|
133
|
+
dir == '.' ? ROOT_PACKAGE : dir
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [String, nil] Rails.root-relative path, nil when outside the root
|
|
137
|
+
def relativize(path)
|
|
138
|
+
string = path.to_s
|
|
139
|
+
return string unless string.start_with?('/')
|
|
140
|
+
|
|
141
|
+
prefix = "#{@rails_root}/"
|
|
142
|
+
string.start_with?(prefix) ? string.delete_prefix(prefix) : nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Packwerk accepts true, false, and "strict"; keep the value shape.
|
|
146
|
+
def normalize_enforcement(value)
|
|
147
|
+
return false if value.nil? || value == false
|
|
148
|
+
return true if value == true
|
|
149
|
+
|
|
150
|
+
value.to_s
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'component_discovery'
|
|
3
4
|
require_relative 'shared_utility_methods'
|
|
4
5
|
require_relative 'shared_dependency_scanner'
|
|
5
6
|
require_relative 'route_helper_resolver'
|
|
@@ -22,6 +23,7 @@ module Woods
|
|
|
22
23
|
# card = units.find { |u| u.identifier == "Components::CardComponent" }
|
|
23
24
|
#
|
|
24
25
|
class PhlexExtractor
|
|
26
|
+
include ComponentDiscovery
|
|
25
27
|
include SharedUtilityMethods
|
|
26
28
|
include SharedDependencyScanner
|
|
27
29
|
include RouteHelperResolver
|
|
@@ -45,13 +47,26 @@ module Woods
|
|
|
45
47
|
#
|
|
46
48
|
# @return [Array<ExtractedUnit>] List of component units
|
|
47
49
|
def extract_all
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@component_base.descendants.map do |component|
|
|
50
|
+
discoverable_classes.map do |component|
|
|
51
51
|
extract_component(component)
|
|
52
52
|
end.compact
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# The component classes this extractor would extract from the running
|
|
56
|
+
# app. Shared with the incremental path's class reconciliation (#164).
|
|
57
|
+
#
|
|
58
|
+
# The component directories are walked first: `descendants` only knows
|
|
59
|
+
# what something has already loaded, and a component under an autoloaded
|
|
60
|
+
# but not eager-loaded subtree of app/views is loaded by nothing (B-184).
|
|
61
|
+
#
|
|
62
|
+
# @return [Array<Class>]
|
|
63
|
+
def discoverable_classes
|
|
64
|
+
return [] unless @component_base
|
|
65
|
+
|
|
66
|
+
load_component_files
|
|
67
|
+
@component_base.descendants
|
|
68
|
+
end
|
|
69
|
+
|
|
55
70
|
# Extract a single component
|
|
56
71
|
#
|
|
57
72
|
# @param component [Class] The component class
|