woods 1.6.4 → 2.0.0.beta1
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 +1879 -37
- data/CONTRIBUTING.md +195 -137
- data/README.md +162 -520
- 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 +620 -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 +415 -0
- data/docs/INTERNALS.md +415 -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 +197 -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 +39 -3
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +78 -24
- 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 +40 -1
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +18 -13
- 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/credential_index.rb +5 -53
- data/lib/woods/console/credential_scanner.rb +15 -16
- data/lib/woods/console/dispatch_pipeline.rb +46 -34
- data/lib/woods/console/embedded_executor.rb +806 -257
- 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 +24 -6
- data/lib/woods/console/rack_middleware.rb +62 -63
- data/lib/woods/console/redactor.rb +10 -24
- data/lib/woods/console/safe_context.rb +45 -45
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +136 -267
- data/lib/woods/console/sql_noise_stripper.rb +20 -51
- data/lib/woods/console/sql_table_scanner.rb +39 -90
- data/lib/woods/console/sql_validator.rb +455 -85
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +462 -88
- data/lib/woods/console/tools/tier1.rb +0 -3
- data/lib/woods/console/tools/tier4.rb +17 -7
- 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 +2503 -192
- 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 +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -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 +408 -34
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +92 -22
- 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 +108 -23
- 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 +907 -154
- 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/mcp/version_aware_tool_dispatch.rb +3 -9
- 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 +223 -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 +70 -38
- 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 +25 -6
- 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.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
- data/lib/woods/session_tracer/session_flow_document.rb +52 -6
- 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 +53 -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 +102 -30
- data/lib/woods/console/adapter_family.rb +0 -39
- 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/console/credential_scanner_registry.rb +0 -36
- data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
- data/lib/woods/console/sql_output_policy.rb +0 -535
- data/lib/woods/console/sqlite_read_guard.rb +0 -46
- 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/mcp/http_transport_options.rb +0 -15
- data/lib/woods/mcp/origin_policy.rb +0 -113
- 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
data/lib/woods/graph_analyzer.rb
CHANGED
|
@@ -23,12 +23,43 @@ module Woods
|
|
|
23
23
|
class GraphAnalyzer
|
|
24
24
|
# Types that are naturally root nodes and should not be flagged as orphans.
|
|
25
25
|
# Framework and gem sources are consumed but never referenced by application code
|
|
26
|
-
# in the dependency graph's reverse index.
|
|
27
|
-
|
|
26
|
+
# in the dependency graph's reverse index. Package units (#280) declare
|
|
27
|
+
# boundaries via metadata and `:package_dependency` edges to other
|
|
28
|
+
# packages; nothing points back at a leaf package in the reverse index,
|
|
29
|
+
# so it would otherwise be flagged as dead code it is not.
|
|
30
|
+
EXCLUDED_ORPHAN_TYPES = %i[rails_source gem_source package].freeze
|
|
31
|
+
|
|
32
|
+
# How many rounds {#assign_orphaned_units} runs before it stops pulling
|
|
33
|
+
# unnamespaced units into clusters through other unnamespaced units. The
|
|
34
|
+
# loop already stops early on the first round that assigns nothing; this
|
|
35
|
+
# bounds the pathological case (a long chain of unnamespaced units, where
|
|
36
|
+
# each round advances the frontier by one) so clustering stays linear-ish
|
|
37
|
+
# on large graphs.
|
|
38
|
+
ORPHAN_ASSIGNMENT_ROUNDS = 10
|
|
39
|
+
|
|
40
|
+
# Edge labels that come from an Active Record association reflection.
|
|
41
|
+
# Only these can cross a database boundary through Rails itself.
|
|
42
|
+
ASSOCIATION_VIAS = %w[belongs_to has_many has_one has_and_belongs_to_many].freeze
|
|
43
|
+
|
|
44
|
+
# A dependency must change at least this many times more often than
|
|
45
|
+
# its dependent to be reported by {#volatile_dependencies}.
|
|
46
|
+
DEFAULT_VOLATILE_RATIO = 3.0
|
|
47
|
+
|
|
48
|
+
# A dependency with fewer commits in the last year than this is too
|
|
49
|
+
# young to judge; POODR's rule is about things that keep changing, and
|
|
50
|
+
# a class touched four times could be settling down.
|
|
51
|
+
VOLATILE_MIN_COMMITS = 5
|
|
52
|
+
|
|
53
|
+
# How many volatile-dependency entries {#analyze} keeps. Published as
|
|
54
|
+
# `stats[:volatile_dependencies_limit]` so a reader of the array alone
|
|
55
|
+
# can tell whether it was truncated (B-182).
|
|
56
|
+
DEFAULT_VOLATILE_LIMIT = 20
|
|
28
57
|
|
|
29
58
|
# @param dependency_graph [DependencyGraph] The graph to analyze
|
|
30
|
-
|
|
59
|
+
# @param volatile_ratio [Numeric] see {#volatile_dependencies}
|
|
60
|
+
def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO)
|
|
31
61
|
@graph = dependency_graph
|
|
62
|
+
@volatile_ratio = volatile_ratio.to_f
|
|
32
63
|
end
|
|
33
64
|
|
|
34
65
|
# ══════════════════════════════════════════════════════════════════════
|
|
@@ -49,7 +80,7 @@ module Woods
|
|
|
49
80
|
|
|
50
81
|
dependents = @graph.dependents_of(identifier)
|
|
51
82
|
result << identifier if dependents.empty?
|
|
52
|
-
end
|
|
83
|
+
end.sort
|
|
53
84
|
end
|
|
54
85
|
end
|
|
55
86
|
|
|
@@ -65,7 +96,7 @@ module Woods
|
|
|
65
96
|
nodes.each_with_object([]) do |(identifier, _meta), result|
|
|
66
97
|
dependencies = @graph.dependencies_of(identifier)
|
|
67
98
|
result << identifier if dependencies.empty?
|
|
68
|
-
end
|
|
99
|
+
end.sort
|
|
69
100
|
end
|
|
70
101
|
end
|
|
71
102
|
|
|
@@ -86,10 +117,21 @@ module Woods
|
|
|
86
117
|
identifier: identifier,
|
|
87
118
|
type: meta[:type],
|
|
88
119
|
dependent_count: dependents.size,
|
|
89
|
-
|
|
120
|
+
# Sorted for the same reason the outer list tie-breaks on identifier:
|
|
121
|
+
# `dependents_of` returns graph-registration order, so an incremental
|
|
122
|
+
# run that appended a dependent would publish a different hub entry
|
|
123
|
+
# for an identical graph.
|
|
124
|
+
dependents: dependents.sort
|
|
90
125
|
}
|
|
91
126
|
end
|
|
92
|
-
|
|
127
|
+
# Tie-break on identifier. Without it, which of the (often many) nodes
|
|
128
|
+
# sharing a dependent count land inside the top-N depends on graph
|
|
129
|
+
# insertion order, so two extractions of the same tree could publish
|
|
130
|
+
# different hub lists (#164) — incremental runs append new nodes at the
|
|
131
|
+
# end where a full extraction interleaves them by extractor.
|
|
132
|
+
identifiers_with_dependents
|
|
133
|
+
.sort_by { |h| [-h[:dependent_count], h[:identifier].to_s] }
|
|
134
|
+
.first(limit)
|
|
93
135
|
end
|
|
94
136
|
|
|
95
137
|
# Detect circular dependency chains in the graph.
|
|
@@ -123,7 +165,11 @@ module Woods
|
|
|
123
165
|
nodes = graph_nodes
|
|
124
166
|
return [] if nodes.size < 3
|
|
125
167
|
|
|
126
|
-
|
|
168
|
+
# Sorted, not insertion-ordered: the seeded sample indexes into this
|
|
169
|
+
# list, so leaving it in graph order would make the sampled pairs (and
|
|
170
|
+
# therefore the bridge scores) depend on the order units happened to be
|
|
171
|
+
# registered in rather than on the graph's content (#164).
|
|
172
|
+
node_ids = nodes.keys.sort
|
|
127
173
|
scores = Hash.new(0)
|
|
128
174
|
|
|
129
175
|
# Sample random pairs of nodes for shortest-path computation.
|
|
@@ -154,6 +200,93 @@ module Woods
|
|
|
154
200
|
end
|
|
155
201
|
end
|
|
156
202
|
|
|
203
|
+
# Association and foreign-key edges whose two ends resolve to different
|
|
204
|
+
# databases (#280).
|
|
205
|
+
#
|
|
206
|
+
# Reads only node attributes (`database`, `table`, `foreign_key_tables`)
|
|
207
|
+
# and edge attributes (`through`, `through_db`, `disable_joins`), so an
|
|
208
|
+
# incremental run that loaded the graph from disk computes exactly what
|
|
209
|
+
# a full run does.
|
|
210
|
+
#
|
|
211
|
+
# Scoped to primary nodes: identifiers as registered in {#graph_nodes}.
|
|
212
|
+
# A variant, the non-primary type registered under an identifier that
|
|
213
|
+
# collides across types, is not walked separately. Association edges are
|
|
214
|
+
# read with `type: :model`, so a variant sharing the identifier under a
|
|
215
|
+
# different type cannot contribute a crossing that belongs to it alone.
|
|
216
|
+
#
|
|
217
|
+
# A foreign key never picks a target owner that lives in `from_db`, even
|
|
218
|
+
# when another database also owns the table: an owner in the source
|
|
219
|
+
# database means the key resolves locally, whatever else claims the same
|
|
220
|
+
# table name. Only when every owner sits outside `from_db`, in more than
|
|
221
|
+
# one other database, does the entry come back ambiguous.
|
|
222
|
+
#
|
|
223
|
+
# `kind`:
|
|
224
|
+
# * `join_through_across_databases`: a `has_many :through` where
|
|
225
|
+
# `disable_joins` is false and `from_db`, `through_db`, and `to_db` are
|
|
226
|
+
# not all equal (a nil `through_db` falls back to comparing the two
|
|
227
|
+
# ends). Rails will try to JOIN across connections; this is the
|
|
228
|
+
# Uchitelle rule.
|
|
229
|
+
# * `association_across_databases`: any other association edge across
|
|
230
|
+
# databases, including a through with `disable_joins`.
|
|
231
|
+
# * `foreign_key_across_databases`: a database-level foreign key whose
|
|
232
|
+
# target table's owner (or every owner, when ambiguous) lives in
|
|
233
|
+
# another database.
|
|
234
|
+
#
|
|
235
|
+
# @return [Array<Hash>] sorted by from, to, via
|
|
236
|
+
def cross_database_edges
|
|
237
|
+
@cross_database_edges ||= begin
|
|
238
|
+
nodes = graph_nodes
|
|
239
|
+
owners = table_owners(nodes)
|
|
240
|
+
entries = nodes.keys.sort.flat_map do |identifier|
|
|
241
|
+
meta = nodes[identifier]
|
|
242
|
+
from_db = meta[:database]
|
|
243
|
+
next [] unless from_db
|
|
244
|
+
|
|
245
|
+
association_crossings(identifier, from_db, nodes) +
|
|
246
|
+
foreign_key_crossings(identifier, from_db, meta, owners)
|
|
247
|
+
end
|
|
248
|
+
# Whole-hash dedup, not a `[from, to, via]` key: an ambiguous foreign
|
|
249
|
+
# key entry carries `to: nil` regardless of which table it names, so
|
|
250
|
+
# two distinct ambiguous foreign keys on the same model would
|
|
251
|
+
# otherwise collapse into one.
|
|
252
|
+
entries.uniq.sort_by { |e| [e[:from], e[:to], e[:via]] }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Edges that point at something changing much faster than the thing
|
|
257
|
+
# that depends on it: "depend on things that change less often than
|
|
258
|
+
# you do" (POODR ch. 3), made checkable because the graph now carries
|
|
259
|
+
# commit counts (#280).
|
|
260
|
+
#
|
|
261
|
+
# Report only, never a gate: young classes produce false positives, so
|
|
262
|
+
# dependencies with fewer than {VOLATILE_MIN_COMMITS} commits or a
|
|
263
|
+
# `new` change frequency are skipped. Ranked by the dependency's
|
|
264
|
+
# PageRank so the most-depended-on volatile unit comes first. `limit`
|
|
265
|
+
# caps what this method hands back; {#analyze}'s
|
|
266
|
+
# `stats[:volatile_dependency_count]` reports the full qualifying count
|
|
267
|
+
# regardless of `limit`, and `stats[:volatile_dependencies_limit]` reports
|
|
268
|
+
# the cap, so a reader of the array alone can tell it was truncated.
|
|
269
|
+
#
|
|
270
|
+
# @param limit [Integer] maximum entries
|
|
271
|
+
# @return [Array<Hash>] `{ from:, from_type:, to:, to_type:, via:, from_commits:, to_commits:, ratio:, pagerank: }`
|
|
272
|
+
def volatile_dependencies(limit: DEFAULT_VOLATILE_LIMIT)
|
|
273
|
+
all_volatile_dependencies.first(limit)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Edges that cross a Packwerk package boundary the source package never
|
|
277
|
+
# declared (#280).
|
|
278
|
+
#
|
|
279
|
+
# Membership comes from the `package` node attribute (Task 8); a
|
|
280
|
+
# declaration comes from a package unit's own `:package_dependency`
|
|
281
|
+
# edges (Task 7). Both are graph-only, so a full and an incremental run
|
|
282
|
+
# compute the same report. Enforcement stays with `packwerk check` /
|
|
283
|
+
# `pks check`; this only makes the undeclared boundary visible.
|
|
284
|
+
#
|
|
285
|
+
# @return [Array<Hash>] `{ from:, from_type:, to:, to_type:, via:, from_package:, to_package: }`, sorted
|
|
286
|
+
def undeclared_package_edges
|
|
287
|
+
@undeclared_package_edges ||= compute_undeclared_package_edges
|
|
288
|
+
end
|
|
289
|
+
|
|
157
290
|
# Group units into semantic domains using namespace prefixes and graph connectivity.
|
|
158
291
|
#
|
|
159
292
|
# Strategy:
|
|
@@ -191,13 +324,13 @@ module Woods
|
|
|
191
324
|
merge_small_clusters(clusters, min_size)
|
|
192
325
|
|
|
193
326
|
# Step 4: Enrich each cluster with hub, entry points, boundary edges
|
|
194
|
-
pagerank_scores =
|
|
327
|
+
pagerank_scores = self.pagerank_scores
|
|
195
328
|
enrich_clusters(clusters, nodes, pagerank_scores)
|
|
196
329
|
|
|
197
330
|
# Sort by member count descending
|
|
198
331
|
clusters.values
|
|
199
332
|
.select { |c| c[:members].any? }
|
|
200
|
-
.sort_by { |c| -c[:member_count] }
|
|
333
|
+
.sort_by { |c| [-c[:member_count], c[:name].to_s] }
|
|
201
334
|
end
|
|
202
335
|
|
|
203
336
|
# Full analysis report combining all structural metrics.
|
|
@@ -210,6 +343,9 @@ module Woods
|
|
|
210
343
|
computed_hubs = hubs
|
|
211
344
|
computed_cycles = cycles
|
|
212
345
|
computed_bridges = bridges(limit: 10)
|
|
346
|
+
computed_cross_database = cross_database_edges
|
|
347
|
+
computed_volatile = volatile_dependencies
|
|
348
|
+
computed_undeclared = undeclared_package_edges
|
|
213
349
|
|
|
214
350
|
{
|
|
215
351
|
orphans: computed_orphans,
|
|
@@ -217,11 +353,18 @@ module Woods
|
|
|
217
353
|
hubs: computed_hubs,
|
|
218
354
|
cycles: computed_cycles,
|
|
219
355
|
bridges: computed_bridges,
|
|
356
|
+
cross_database_edges: computed_cross_database,
|
|
357
|
+
volatile_dependencies: computed_volatile,
|
|
358
|
+
undeclared_package_edges: computed_undeclared,
|
|
220
359
|
stats: {
|
|
221
360
|
orphan_count: computed_orphans.size,
|
|
222
361
|
dead_end_count: computed_dead_ends.size,
|
|
223
362
|
hub_count: computed_hubs.size,
|
|
224
|
-
cycle_count: computed_cycles.size
|
|
363
|
+
cycle_count: computed_cycles.size,
|
|
364
|
+
cross_database_edge_count: computed_cross_database.size,
|
|
365
|
+
volatile_dependency_count: all_volatile_dependencies.size,
|
|
366
|
+
volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT,
|
|
367
|
+
undeclared_package_edge_count: computed_undeclared.size
|
|
225
368
|
}
|
|
226
369
|
}
|
|
227
370
|
end
|
|
@@ -258,41 +401,72 @@ module Woods
|
|
|
258
401
|
end
|
|
259
402
|
|
|
260
403
|
# Assign units with no namespace prefix to their most-connected cluster.
|
|
404
|
+
#
|
|
405
|
+
# Order-free (EXTB-7). Each round scores *every* still-unassigned unit
|
|
406
|
+
# against one membership snapshot taken before the round, then applies all
|
|
407
|
+
# of that round's assignments at once. Assigning inside the scoring loop —
|
|
408
|
+
# as this did — let a unit whose only connection is another unnamespaced
|
|
409
|
+
# unit join a cluster or not depending on which of the two the graph
|
|
410
|
+
# happened to enumerate first, i.e. on registration order, which differs
|
|
411
|
+
# between a full and an incremental run.
|
|
412
|
+
#
|
|
413
|
+
# Rounds are bounded: each one either assigns at least one unit or ends the
|
|
414
|
+
# loop, and {ORPHAN_ASSIGNMENT_ROUNDS} caps how far a chain of unnamespaced
|
|
415
|
+
# units can pull its successors in. Units past that depth stay unassigned —
|
|
416
|
+
# deterministically, which is the property that matters here.
|
|
261
417
|
def assign_orphaned_units(clusters, filtered_ids, _nodes)
|
|
262
418
|
return if clusters.empty?
|
|
263
419
|
|
|
264
|
-
|
|
420
|
+
pending = filtered_ids.select { |id| cluster_prefix(id).nil? }.sort
|
|
265
421
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
next unless best_cluster
|
|
422
|
+
ORPHAN_ASSIGNMENT_ROUNDS.times do
|
|
423
|
+
break if pending.empty?
|
|
269
424
|
|
|
270
|
-
clusters[
|
|
271
|
-
|
|
425
|
+
membership = clusters.transform_values { |cluster| cluster[:member_set] }.freeze
|
|
426
|
+
assignments = pending.filter_map do |id|
|
|
427
|
+
best_cluster = find_most_connected_cluster(id, clusters.keys, membership)
|
|
428
|
+
[id, best_cluster] if best_cluster
|
|
429
|
+
end
|
|
430
|
+
break if assignments.empty?
|
|
431
|
+
|
|
432
|
+
assignments.each do |id, name|
|
|
433
|
+
clusters[name][:members] << id
|
|
434
|
+
clusters[name][:member_set].add(id)
|
|
435
|
+
end
|
|
436
|
+
pending -= assignments.map(&:first)
|
|
272
437
|
end
|
|
273
438
|
end
|
|
274
439
|
|
|
275
440
|
# Find which cluster a unit has the most connections to.
|
|
276
|
-
|
|
441
|
+
#
|
|
442
|
+
# @param identifier [String]
|
|
443
|
+
# @param cluster_names [Array<String>]
|
|
444
|
+
# @param membership [Hash{String => Set<String>}] name => members, read as
|
|
445
|
+
# of the start of the assignment round (see {#assign_orphaned_units})
|
|
446
|
+
# @return [String, nil]
|
|
447
|
+
def find_most_connected_cluster(identifier, cluster_names, membership)
|
|
277
448
|
connections = Hash.new(0)
|
|
278
449
|
|
|
279
450
|
# Check forward edges (dependencies)
|
|
280
451
|
@graph.dependencies_of(identifier).each do |dep|
|
|
281
|
-
|
|
282
|
-
connections[name] += 1 if
|
|
452
|
+
cluster_names.each do |name|
|
|
453
|
+
connections[name] += 1 if membership[name].include?(dep)
|
|
283
454
|
end
|
|
284
455
|
end
|
|
285
456
|
|
|
286
457
|
# Check reverse edges (dependents)
|
|
287
458
|
@graph.dependents_of(identifier).each do |dep|
|
|
288
|
-
|
|
289
|
-
connections[name] += 1 if
|
|
459
|
+
cluster_names.each do |name|
|
|
460
|
+
connections[name] += 1 if membership[name].include?(dep)
|
|
290
461
|
end
|
|
291
462
|
end
|
|
292
463
|
|
|
293
464
|
return nil if connections.empty?
|
|
294
465
|
|
|
295
|
-
|
|
466
|
+
# Tie-break on cluster name. `max_by` alone returns whichever equal-count
|
|
467
|
+
# cluster the hash happened to enumerate first, which is registration
|
|
468
|
+
# order — the one determinism hole left in this class.
|
|
469
|
+
connections.max_by { |name, count| [count, name] }.first
|
|
296
470
|
end
|
|
297
471
|
|
|
298
472
|
# Merge clusters smaller than min_size into their most-connected neighbor.
|
|
@@ -302,7 +476,7 @@ module Woods
|
|
|
302
476
|
break if small.empty?
|
|
303
477
|
|
|
304
478
|
# Merge the smallest cluster first
|
|
305
|
-
name, cluster = small.min_by { |
|
|
479
|
+
name, cluster = small.min_by { |cluster_name, c| [c[:members].size, cluster_name] }
|
|
306
480
|
|
|
307
481
|
# Find which other cluster this one connects to most
|
|
308
482
|
target = find_merge_target(cluster, clusters, name)
|
|
@@ -331,17 +505,26 @@ module Woods
|
|
|
331
505
|
|
|
332
506
|
return nil if connections.empty?
|
|
333
507
|
|
|
334
|
-
|
|
508
|
+
# Tie-break on cluster name. `max_by` alone returns whichever equal-count
|
|
509
|
+
# cluster the hash happened to enumerate first, which is registration
|
|
510
|
+
# order — the one determinism hole left in this class.
|
|
511
|
+
connections.max_by { |name, count| [count, name] }.first
|
|
335
512
|
end
|
|
336
513
|
|
|
337
514
|
# Enrich clusters with hub, entry points, boundary edges, and type breakdown.
|
|
338
515
|
def enrich_clusters(clusters, nodes, pagerank_scores)
|
|
339
516
|
clusters.each_value do |cluster|
|
|
340
|
-
|
|
517
|
+
# Sorted, like orphans/dead_ends/hubs. Members accumulate in graph
|
|
518
|
+
# registration order, and an incremental run appends where a full
|
|
519
|
+
# extraction interleaves by extractor — so an unsorted list publishes
|
|
520
|
+
# a different cluster for an identical graph. Everything derived below
|
|
521
|
+
# (entry points, boundary edges) inherits this order too.
|
|
522
|
+
members = cluster[:members].sort
|
|
523
|
+
cluster[:members] = members
|
|
341
524
|
member_set = cluster[:member_set]
|
|
342
525
|
|
|
343
526
|
# Hub: highest PageRank within the cluster
|
|
344
|
-
hub_id = members.max_by { |id| pagerank_scores[id] || 0 }
|
|
527
|
+
hub_id = members.max_by { |id| [pagerank_scores[id] || 0, id] }
|
|
345
528
|
cluster[:hub] = hub_id
|
|
346
529
|
|
|
347
530
|
# Entry points: controllers and GraphQL resolvers in the cluster's dependents
|
|
@@ -353,7 +536,7 @@ module Woods
|
|
|
353
536
|
entry_points.add(dep) if meta && entry_types.include?(meta[:type].to_s)
|
|
354
537
|
end
|
|
355
538
|
end
|
|
356
|
-
cluster[:entry_points] = entry_points.to_a
|
|
539
|
+
cluster[:entry_points] = entry_points.to_a.sort
|
|
357
540
|
|
|
358
541
|
# Boundary edges: connections that cross cluster boundaries
|
|
359
542
|
boundary = []
|
|
@@ -377,7 +560,8 @@ module Woods
|
|
|
377
560
|
end
|
|
378
561
|
end
|
|
379
562
|
# Deduplicate and limit boundary edges
|
|
380
|
-
cluster[:boundary_edges] = boundary.uniq { |e| [e[:from], e[:to]] }
|
|
563
|
+
cluster[:boundary_edges] = boundary.uniq { |e| [e[:from], e[:to]] }
|
|
564
|
+
.sort_by { |e| [e[:from].to_s, e[:to].to_s] }.first(20)
|
|
381
565
|
|
|
382
566
|
# Type breakdown
|
|
383
567
|
type_counts = members.each_with_object(Hash.new(0)) do |id, counts|
|
|
@@ -410,11 +594,196 @@ module Woods
|
|
|
410
594
|
@graph_nodes ||= graph_data[:nodes]
|
|
411
595
|
end
|
|
412
596
|
|
|
413
|
-
#
|
|
597
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
598
|
+
# Cross-database helpers
|
|
599
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
# table name => { database name => sorted identifiers of the model nodes
|
|
602
|
+
# in that database owning it }. Sorted identifier iteration makes a
|
|
603
|
+
# table owned by several nodes in one database resolve to the same
|
|
604
|
+
# first identifier every run; nodes with no table or no database
|
|
605
|
+
# contribute no ownership claim.
|
|
606
|
+
#
|
|
607
|
+
# @param nodes [Hash]
|
|
608
|
+
# @return [Hash{String => Hash{String => Array<String>}}]
|
|
609
|
+
def table_owners(nodes)
|
|
610
|
+
nodes.keys.sort.each_with_object({}) do |identifier, owners|
|
|
611
|
+
node = nodes[identifier]
|
|
612
|
+
table = node[:table]
|
|
613
|
+
database = node[:database]
|
|
614
|
+
next unless table && database
|
|
615
|
+
|
|
616
|
+
by_database = (owners[table] ||= {})
|
|
617
|
+
(by_database[database] ||= []) << identifier
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# @return [Array<Hash>] association edges from `identifier` that land in another database
|
|
622
|
+
def association_crossings(identifier, from_db, nodes)
|
|
623
|
+
@graph.edge_records(identifier, type: :model).filter_map do |edge|
|
|
624
|
+
via = edge[:via].to_s
|
|
625
|
+
next unless ASSOCIATION_VIAS.include?(via)
|
|
626
|
+
|
|
627
|
+
target = nodes[edge[:target]]
|
|
628
|
+
to_db = target && target[:database]
|
|
629
|
+
through_db = edge[:through] ? edge[:through_db] : nil
|
|
630
|
+
databases = [from_db, to_db, through_db].compact.uniq
|
|
631
|
+
next if databases.size < 2
|
|
632
|
+
|
|
633
|
+
disable_joins = edge[:disable_joins] == true
|
|
634
|
+
kind = edge[:through] && !disable_joins ? 'join_through_across_databases' : 'association_across_databases'
|
|
635
|
+
{
|
|
636
|
+
from: identifier, to: edge[:target], via: via, from_db: from_db, to_db: to_db,
|
|
637
|
+
through: edge[:through], through_db: through_db, disable_joins: disable_joins, kind: kind
|
|
638
|
+
}
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# @return [Array<Hash>] foreign keys from `identifier`'s table into a table owned by another database
|
|
643
|
+
def foreign_key_crossings(identifier, from_db, meta, owners)
|
|
644
|
+
Array(meta[:foreign_key_tables]).filter_map do |table|
|
|
645
|
+
foreign_key_crossing(identifier, from_db, table, owners)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# A single foreign key's crossing entry, or nil when an owner of `table`
|
|
650
|
+
# lives in `from_db` (the key resolves locally regardless of what else
|
|
651
|
+
# claims the table name) or when no node claims the table at all.
|
|
652
|
+
#
|
|
653
|
+
# @return [Hash, nil]
|
|
654
|
+
def foreign_key_crossing(identifier, from_db, table, owners)
|
|
655
|
+
by_database = owners[table]
|
|
656
|
+
return nil if by_database.nil? || by_database.key?(from_db)
|
|
657
|
+
|
|
658
|
+
base = {
|
|
659
|
+
from: identifier, via: 'foreign_key', from_db: from_db,
|
|
660
|
+
through: nil, through_db: nil, disable_joins: false, kind: 'foreign_key_across_databases'
|
|
661
|
+
}
|
|
662
|
+
databases = by_database.keys.sort
|
|
663
|
+
if databases.size == 1
|
|
664
|
+
owner_db = databases.first
|
|
665
|
+
base.merge(to: by_database[owner_db].first, to_db: owner_db)
|
|
666
|
+
else
|
|
667
|
+
base.merge(to: nil, to_db: nil, ambiguous_owners: by_database.values.flatten.sort)
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
672
|
+
# Volatile dependency helpers
|
|
673
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
# PageRank computed once per analyzer instance.
|
|
676
|
+
#
|
|
677
|
+
# @return [Hash{String => Float}]
|
|
678
|
+
def pagerank_scores
|
|
679
|
+
@pagerank_scores ||= @graph.pagerank
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Every qualifying edge, unranked by {#volatile_dependencies}'s `limit`.
|
|
683
|
+
# {#analyze} needs the full count separately from the persisted top 20.
|
|
684
|
+
#
|
|
685
|
+
# @return [Array<Hash>] sorted by pagerank, ratio, from, to, via
|
|
686
|
+
def all_volatile_dependencies
|
|
687
|
+
@all_volatile_dependencies ||= compute_volatile_dependencies
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# Short-circuits to `[]`, skipping the PageRank computation entirely,
|
|
691
|
+
# when no node carries an Integer `commit_count` (git enrichment never
|
|
692
|
+
# ran): there is nothing to rank.
|
|
693
|
+
#
|
|
694
|
+
# @return [Array<Hash>]
|
|
695
|
+
def compute_volatile_dependencies
|
|
696
|
+
nodes = graph_nodes
|
|
697
|
+
return [] unless nodes.each_value.any? { |meta| meta[:commit_count].is_a?(Integer) }
|
|
698
|
+
|
|
699
|
+
scores = pagerank_scores
|
|
700
|
+
entries = nodes.keys.sort.flat_map do |from|
|
|
701
|
+
from_meta = nodes[from]
|
|
702
|
+
from_commits = from_meta[:commit_count]
|
|
703
|
+
next [] unless from_commits.is_a?(Integer)
|
|
704
|
+
|
|
705
|
+
@graph.edge_records(from, type: from_meta[:type]).filter_map do |edge|
|
|
706
|
+
volatile_entry(from, from_meta, from_commits, edge, nodes, scores)
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
entries.uniq { |e| [e[:from], e[:to], e[:via]] }
|
|
710
|
+
.sort_by { |e| [-e[:pagerank], -e[:ratio], e[:from], e[:to], e[:via]] }
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# @return [Hash, nil] the report entry for one edge, or nil when it is not volatile
|
|
714
|
+
def volatile_entry(from, from_meta, from_commits, edge, nodes, scores)
|
|
715
|
+
to = edge[:target]
|
|
716
|
+
to_meta = nodes[to]
|
|
717
|
+
return nil unless to_meta
|
|
718
|
+
|
|
719
|
+
to_commits = to_meta[:commit_count]
|
|
720
|
+
return nil unless to_commits.is_a?(Integer) && to_commits >= VOLATILE_MIN_COMMITS
|
|
721
|
+
return nil if to_meta[:change_frequency] == 'new'
|
|
722
|
+
|
|
723
|
+
ratio = to_commits.to_f / [from_commits, 1].max
|
|
724
|
+
return nil if ratio < @volatile_ratio
|
|
725
|
+
|
|
726
|
+
{
|
|
727
|
+
from: from, from_type: from_meta[:type], to: to, to_type: to_meta[:type], via: edge[:via].to_s,
|
|
728
|
+
from_commits: from_commits, to_commits: to_commits,
|
|
729
|
+
ratio: ratio.round(2), pagerank: (scores[to] || 0.0).round(4)
|
|
730
|
+
}
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
734
|
+
# Package boundary helpers
|
|
735
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
# Short-circuits to `[]`, skipping declaration lookup entirely, when no
|
|
738
|
+
# node carries a `package` attribute (no package extraction ran).
|
|
414
739
|
#
|
|
415
|
-
# @return [Hash]
|
|
416
|
-
def
|
|
417
|
-
|
|
740
|
+
# @return [Array<Hash>]
|
|
741
|
+
def compute_undeclared_package_edges
|
|
742
|
+
nodes = graph_nodes
|
|
743
|
+
return [] unless nodes.each_value.any? { |meta| meta.key?(:package) }
|
|
744
|
+
|
|
745
|
+
declared = package_declarations(nodes)
|
|
746
|
+
entries = nodes.keys.sort.flat_map do |from|
|
|
747
|
+
meta = nodes[from]
|
|
748
|
+
from_package = meta[:package]
|
|
749
|
+
next [] if from_package.nil? || meta[:type] == :package
|
|
750
|
+
|
|
751
|
+
@graph.edge_records(from, type: meta[:type]).filter_map do |edge|
|
|
752
|
+
undeclared_entry(from, meta, from_package, edge, nodes, declared)
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
entries.uniq.sort_by { |e| [e[:from], e[:to], e[:via]] }
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# package identifier => Set of package identifiers it declares as
|
|
759
|
+
# dependencies, read from that package unit's own `:package_dependency`
|
|
760
|
+
# edges. A root package (`.`) declaring nothing is just another entry
|
|
761
|
+
# with an empty Set, no special-cased root handling.
|
|
762
|
+
#
|
|
763
|
+
# @param nodes [Hash]
|
|
764
|
+
# @return [Hash{String => Set<String>}]
|
|
765
|
+
def package_declarations(nodes)
|
|
766
|
+
nodes.each_with_object({}) do |(identifier, meta), declared|
|
|
767
|
+
next unless meta[:type] == :package
|
|
768
|
+
|
|
769
|
+
declared[identifier] = @graph.dependencies_of(identifier, via: :package_dependency).to_set
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# @return [Hash, nil] the report entry, or nil when the edge stays inside declared boundaries
|
|
774
|
+
def undeclared_entry(from, from_meta, from_package, edge, nodes, declared)
|
|
775
|
+
to = edge[:target]
|
|
776
|
+
to_meta = nodes[to]
|
|
777
|
+
to_package = to_meta && to_meta[:package]
|
|
778
|
+
return nil if to_package.nil? || to_package == from_package
|
|
779
|
+
|
|
780
|
+
declared_deps = declared[from_package] || Set.new
|
|
781
|
+
return nil if declared_deps.include?(to_package)
|
|
782
|
+
|
|
783
|
+
{
|
|
784
|
+
from: from, from_type: from_meta[:type], to: to, to_type: to_meta[:type], via: edge[:via].to_s,
|
|
785
|
+
from_package: from_package, to_package: to_package
|
|
786
|
+
}
|
|
418
787
|
end
|
|
419
788
|
|
|
420
789
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -444,7 +813,7 @@ module Woods
|
|
|
444
813
|
found_cycles = []
|
|
445
814
|
seen_cycle_signatures = Set.new
|
|
446
815
|
|
|
447
|
-
nodes.
|
|
816
|
+
nodes.keys.sort.each do |start_node|
|
|
448
817
|
next unless color[start_node] == white
|
|
449
818
|
|
|
450
819
|
# Iterative DFS using an explicit stack.
|
|
@@ -470,6 +839,9 @@ module Woods
|
|
|
470
839
|
path.push(node)
|
|
471
840
|
stack.push([node, :exit])
|
|
472
841
|
|
|
842
|
+
# Not sorted, deliberately: this list is the unit's own declared
|
|
843
|
+
# dependency order, which is identical in a full and an incremental
|
|
844
|
+
# run, so sorting it would change nothing any test can observe.
|
|
473
845
|
neighbors = @graph.dependencies_of(node)
|
|
474
846
|
neighbors.each do |neighbor|
|
|
475
847
|
case color[neighbor]
|
|
@@ -492,6 +864,8 @@ module Woods
|
|
|
492
864
|
end
|
|
493
865
|
end
|
|
494
866
|
|
|
867
|
+
# Deterministic without a final sort: the DFS starts from a sorted
|
|
868
|
+
# node list, so cycles are discovered in the same order every run.
|
|
495
869
|
found_cycles
|
|
496
870
|
end
|
|
497
871
|
|