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
data/lib/woods/graph_analyzer.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
3
4
|
require 'set'
|
|
4
5
|
|
|
5
6
|
module Woods
|
|
@@ -23,12 +24,62 @@ module Woods
|
|
|
23
24
|
class GraphAnalyzer
|
|
24
25
|
# Types that are naturally root nodes and should not be flagged as orphans.
|
|
25
26
|
# Framework and gem sources are consumed but never referenced by application code
|
|
26
|
-
# in the dependency graph's reverse index.
|
|
27
|
-
|
|
27
|
+
# in the dependency graph's reverse index. Package units (#280) declare
|
|
28
|
+
# boundaries via metadata and `:package_dependency` edges to other
|
|
29
|
+
# packages; nothing points back at a leaf package in the reverse index,
|
|
30
|
+
# so it would otherwise be flagged as dead code it is not.
|
|
31
|
+
EXCLUDED_ORPHAN_TYPES = %i[rails_source gem_source package].freeze
|
|
32
|
+
|
|
33
|
+
# How many rounds {#assign_orphaned_units} runs before it stops pulling
|
|
34
|
+
# unnamespaced units into clusters through other unnamespaced units. The
|
|
35
|
+
# loop already stops early on the first round that assigns nothing; this
|
|
36
|
+
# bounds the pathological case (a long chain of unnamespaced units, where
|
|
37
|
+
# each round advances the frontier by one) so clustering stays linear-ish
|
|
38
|
+
# on large graphs.
|
|
39
|
+
ORPHAN_ASSIGNMENT_ROUNDS = 10
|
|
40
|
+
|
|
41
|
+
# Edge labels that come from an Active Record association reflection.
|
|
42
|
+
# Only these can cross a database boundary through Rails itself.
|
|
43
|
+
ASSOCIATION_VIAS = %w[belongs_to has_many has_one has_and_belongs_to_many].freeze
|
|
44
|
+
|
|
45
|
+
# A dependency must change at least this many times more often than
|
|
46
|
+
# its dependent to be reported by {#volatile_dependencies}.
|
|
47
|
+
DEFAULT_VOLATILE_RATIO = 3.0
|
|
48
|
+
|
|
49
|
+
# A dependency with fewer commits in the last year than this is too
|
|
50
|
+
# young to judge; POODR's rule is about things that keep changing, and
|
|
51
|
+
# a class touched four times could be settling down.
|
|
52
|
+
VOLATILE_MIN_COMMITS = 5
|
|
53
|
+
|
|
54
|
+
# How many volatile-dependency entries {#analyze} keeps. Published as
|
|
55
|
+
# `stats[:volatile_dependencies_limit]` so a reader of the array alone
|
|
56
|
+
# can tell whether it was truncated (B-182).
|
|
57
|
+
DEFAULT_VOLATILE_LIMIT = 20
|
|
58
|
+
|
|
59
|
+
# How many distinct cycles {#detect_cycles} enumerates before it stops.
|
|
60
|
+
# The DFS finds one cycle per back-edge, and a dense graph has tens of
|
|
61
|
+
# thousands of them; nobody reads past the first few hundred, and the
|
|
62
|
+
# per-cycle signature work is what made analysis the fixed floor of every
|
|
63
|
+
# incremental run. `nil` means no cap.
|
|
64
|
+
DEFAULT_CYCLE_LIMIT = 500
|
|
65
|
+
|
|
66
|
+
# The longest cycle {#detect_cycles} will record, counted in distinct
|
|
67
|
+
# nodes. A back-edge deep in a DFS closes a cycle as long as the path,
|
|
68
|
+
# which on a large graph is thousands of nodes: unreadable as a report and
|
|
69
|
+
# expensive to canonicalize. `nil` means no cap.
|
|
70
|
+
DEFAULT_CYCLE_MAX_LENGTH = 50
|
|
28
71
|
|
|
29
72
|
# @param dependency_graph [DependencyGraph] The graph to analyze
|
|
30
|
-
|
|
73
|
+
# @param volatile_ratio [Numeric] see {#volatile_dependencies}
|
|
74
|
+
# @param cycle_limit [Integer, nil] see {DEFAULT_CYCLE_LIMIT}
|
|
75
|
+
# @param cycle_max_length [Integer, nil] see {DEFAULT_CYCLE_MAX_LENGTH}
|
|
76
|
+
def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO,
|
|
77
|
+
cycle_limit: DEFAULT_CYCLE_LIMIT, cycle_max_length: DEFAULT_CYCLE_MAX_LENGTH)
|
|
31
78
|
@graph = dependency_graph
|
|
79
|
+
@volatile_ratio = volatile_ratio.to_f
|
|
80
|
+
@cycle_limit = cycle_limit
|
|
81
|
+
@cycle_max_length = cycle_max_length
|
|
82
|
+
@cycle_limit_reached = false
|
|
32
83
|
end
|
|
33
84
|
|
|
34
85
|
# ══════════════════════════════════════════════════════════════════════
|
|
@@ -49,7 +100,7 @@ module Woods
|
|
|
49
100
|
|
|
50
101
|
dependents = @graph.dependents_of(identifier)
|
|
51
102
|
result << identifier if dependents.empty?
|
|
52
|
-
end
|
|
103
|
+
end.sort
|
|
53
104
|
end
|
|
54
105
|
end
|
|
55
106
|
|
|
@@ -65,7 +116,7 @@ module Woods
|
|
|
65
116
|
nodes.each_with_object([]) do |(identifier, _meta), result|
|
|
66
117
|
dependencies = @graph.dependencies_of(identifier)
|
|
67
118
|
result << identifier if dependencies.empty?
|
|
68
|
-
end
|
|
119
|
+
end.sort
|
|
69
120
|
end
|
|
70
121
|
end
|
|
71
122
|
|
|
@@ -86,10 +137,21 @@ module Woods
|
|
|
86
137
|
identifier: identifier,
|
|
87
138
|
type: meta[:type],
|
|
88
139
|
dependent_count: dependents.size,
|
|
89
|
-
|
|
140
|
+
# Sorted for the same reason the outer list tie-breaks on identifier:
|
|
141
|
+
# `dependents_of` returns graph-registration order, so an incremental
|
|
142
|
+
# run that appended a dependent would publish a different hub entry
|
|
143
|
+
# for an identical graph.
|
|
144
|
+
dependents: dependents.sort
|
|
90
145
|
}
|
|
91
146
|
end
|
|
92
|
-
|
|
147
|
+
# Tie-break on identifier. Without it, which of the (often many) nodes
|
|
148
|
+
# sharing a dependent count land inside the top-N depends on graph
|
|
149
|
+
# insertion order, so two extractions of the same tree could publish
|
|
150
|
+
# different hub lists (#164) — incremental runs append new nodes at the
|
|
151
|
+
# end where a full extraction interleaves them by extractor.
|
|
152
|
+
identifiers_with_dependents
|
|
153
|
+
.sort_by { |h| [-h[:dependent_count], h[:identifier].to_s] }
|
|
154
|
+
.first(limit)
|
|
93
155
|
end
|
|
94
156
|
|
|
95
157
|
# Detect circular dependency chains in the graph.
|
|
@@ -105,6 +167,19 @@ module Woods
|
|
|
105
167
|
@cycles ||= detect_cycles
|
|
106
168
|
end
|
|
107
169
|
|
|
170
|
+
# Whether {#cycles} is a truncated view of the graph's cycles.
|
|
171
|
+
#
|
|
172
|
+
# True when either cap fired: the count cap stopped enumeration, or at
|
|
173
|
+
# least one cycle was longer than the length cap and was skipped. A reader
|
|
174
|
+
# of the array alone cannot tell, so this is published as
|
|
175
|
+
# `stats[:cycle_limit_reached]` alongside it.
|
|
176
|
+
#
|
|
177
|
+
# @return [Boolean]
|
|
178
|
+
def cycle_limit_reached?
|
|
179
|
+
cycles
|
|
180
|
+
@cycle_limit_reached
|
|
181
|
+
end
|
|
182
|
+
|
|
108
183
|
# Units that bridge different types in the graph.
|
|
109
184
|
#
|
|
110
185
|
# Computes a simplified betweenness centrality metric — for each unit, we
|
|
@@ -123,7 +198,11 @@ module Woods
|
|
|
123
198
|
nodes = graph_nodes
|
|
124
199
|
return [] if nodes.size < 3
|
|
125
200
|
|
|
126
|
-
|
|
201
|
+
# Sorted, not insertion-ordered: the seeded sample indexes into this
|
|
202
|
+
# list, so leaving it in graph order would make the sampled pairs (and
|
|
203
|
+
# therefore the bridge scores) depend on the order units happened to be
|
|
204
|
+
# registered in rather than on the graph's content (#164).
|
|
205
|
+
node_ids = nodes.keys.sort
|
|
127
206
|
scores = Hash.new(0)
|
|
128
207
|
|
|
129
208
|
# Sample random pairs of nodes for shortest-path computation.
|
|
@@ -133,6 +212,7 @@ module Woods
|
|
|
133
212
|
|
|
134
213
|
pairs.each do |source, target|
|
|
135
214
|
path = bfs_shortest_path(source, target)
|
|
215
|
+
|
|
136
216
|
next unless path && path.size > 2
|
|
137
217
|
|
|
138
218
|
# Credit intermediate nodes (exclude source and target)
|
|
@@ -154,6 +234,93 @@ module Woods
|
|
|
154
234
|
end
|
|
155
235
|
end
|
|
156
236
|
|
|
237
|
+
# Association and foreign-key edges whose two ends resolve to different
|
|
238
|
+
# databases (#280).
|
|
239
|
+
#
|
|
240
|
+
# Reads only node attributes (`database`, `table`, `foreign_key_tables`)
|
|
241
|
+
# and edge attributes (`through`, `through_db`, `disable_joins`), so an
|
|
242
|
+
# incremental run that loaded the graph from disk computes exactly what
|
|
243
|
+
# a full run does.
|
|
244
|
+
#
|
|
245
|
+
# Scoped to primary nodes: identifiers as registered in {#graph_nodes}.
|
|
246
|
+
# A variant, the non-primary type registered under an identifier that
|
|
247
|
+
# collides across types, is not walked separately. Association edges are
|
|
248
|
+
# read with `type: :model`, so a variant sharing the identifier under a
|
|
249
|
+
# different type cannot contribute a crossing that belongs to it alone.
|
|
250
|
+
#
|
|
251
|
+
# A foreign key never picks a target owner that lives in `from_db`, even
|
|
252
|
+
# when another database also owns the table: an owner in the source
|
|
253
|
+
# database means the key resolves locally, whatever else claims the same
|
|
254
|
+
# table name. Only when every owner sits outside `from_db`, in more than
|
|
255
|
+
# one other database, does the entry come back ambiguous.
|
|
256
|
+
#
|
|
257
|
+
# `kind`:
|
|
258
|
+
# * `join_through_across_databases`: a `has_many :through` where
|
|
259
|
+
# `disable_joins` is false and `from_db`, `through_db`, and `to_db` are
|
|
260
|
+
# not all equal (a nil `through_db` falls back to comparing the two
|
|
261
|
+
# ends). Rails will try to JOIN across connections; this is the
|
|
262
|
+
# Uchitelle rule.
|
|
263
|
+
# * `association_across_databases`: any other association edge across
|
|
264
|
+
# databases, including a through with `disable_joins`.
|
|
265
|
+
# * `foreign_key_across_databases`: a database-level foreign key whose
|
|
266
|
+
# target table's owner (or every owner, when ambiguous) lives in
|
|
267
|
+
# another database.
|
|
268
|
+
#
|
|
269
|
+
# @return [Array<Hash>] sorted by from, to, via
|
|
270
|
+
def cross_database_edges
|
|
271
|
+
@cross_database_edges ||= begin
|
|
272
|
+
nodes = graph_nodes
|
|
273
|
+
owners = table_owners(nodes)
|
|
274
|
+
entries = nodes.keys.sort.flat_map do |identifier|
|
|
275
|
+
meta = nodes[identifier]
|
|
276
|
+
from_db = meta[:database]
|
|
277
|
+
next [] unless from_db
|
|
278
|
+
|
|
279
|
+
association_crossings(identifier, from_db, nodes) +
|
|
280
|
+
foreign_key_crossings(identifier, from_db, meta, owners)
|
|
281
|
+
end
|
|
282
|
+
# Whole-hash dedup, not a `[from, to, via]` key: an ambiguous foreign
|
|
283
|
+
# key entry carries `to: nil` regardless of which table it names, so
|
|
284
|
+
# two distinct ambiguous foreign keys on the same model would
|
|
285
|
+
# otherwise collapse into one.
|
|
286
|
+
entries.uniq.sort_by { |e| [e[:from], e[:to], e[:via]] }
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Edges that point at something changing much faster than the thing
|
|
291
|
+
# that depends on it: "depend on things that change less often than
|
|
292
|
+
# you do" (POODR ch. 3), made checkable because the graph now carries
|
|
293
|
+
# commit counts (#280).
|
|
294
|
+
#
|
|
295
|
+
# Report only, never a gate: young classes produce false positives, so
|
|
296
|
+
# dependencies with fewer than {VOLATILE_MIN_COMMITS} commits or a
|
|
297
|
+
# `new` change frequency are skipped. Ranked by the dependency's
|
|
298
|
+
# PageRank so the most-depended-on volatile unit comes first. `limit`
|
|
299
|
+
# caps what this method hands back; {#analyze}'s
|
|
300
|
+
# `stats[:volatile_dependency_count]` reports the full qualifying count
|
|
301
|
+
# regardless of `limit`, and `stats[:volatile_dependencies_limit]` reports
|
|
302
|
+
# the cap, so a reader of the array alone can tell it was truncated.
|
|
303
|
+
#
|
|
304
|
+
# @param limit [Integer] maximum entries
|
|
305
|
+
# @return [Array<Hash>] `{ from:, from_type:, to:, to_type:, via:, from_commits:, to_commits:, ratio:, pagerank: }`
|
|
306
|
+
def volatile_dependencies(limit: DEFAULT_VOLATILE_LIMIT)
|
|
307
|
+
all_volatile_dependencies.first(limit)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Edges that cross a Packwerk package boundary the source package never
|
|
311
|
+
# declared (#280).
|
|
312
|
+
#
|
|
313
|
+
# Membership comes from the `package` node attribute (Task 8); a
|
|
314
|
+
# declaration comes from a package unit's own `:package_dependency`
|
|
315
|
+
# edges (Task 7). Both are graph-only, so a full and an incremental run
|
|
316
|
+
# compute the same report. Enforcement stays with `packwerk check` /
|
|
317
|
+
# `pks check`; this only makes the undeclared boundary visible.
|
|
318
|
+
#
|
|
319
|
+
# @return [Array<Hash>] `{ from:, from_type:, to:, to_type:, via:, from_package:, to_package: }`, sorted
|
|
320
|
+
def undeclared_package_edges
|
|
321
|
+
@undeclared_package_edges ||= compute_undeclared_package_edges
|
|
322
|
+
end
|
|
323
|
+
|
|
157
324
|
# Group units into semantic domains using namespace prefixes and graph connectivity.
|
|
158
325
|
#
|
|
159
326
|
# Strategy:
|
|
@@ -191,13 +358,13 @@ module Woods
|
|
|
191
358
|
merge_small_clusters(clusters, min_size)
|
|
192
359
|
|
|
193
360
|
# Step 4: Enrich each cluster with hub, entry points, boundary edges
|
|
194
|
-
pagerank_scores =
|
|
361
|
+
pagerank_scores = self.pagerank_scores
|
|
195
362
|
enrich_clusters(clusters, nodes, pagerank_scores)
|
|
196
363
|
|
|
197
364
|
# Sort by member count descending
|
|
198
365
|
clusters.values
|
|
199
366
|
.select { |c| c[:members].any? }
|
|
200
|
-
.sort_by { |c| -c[:member_count] }
|
|
367
|
+
.sort_by { |c| [-c[:member_count], c[:name].to_s] }
|
|
201
368
|
end
|
|
202
369
|
|
|
203
370
|
# Full analysis report combining all structural metrics.
|
|
@@ -210,6 +377,9 @@ module Woods
|
|
|
210
377
|
computed_hubs = hubs
|
|
211
378
|
computed_cycles = cycles
|
|
212
379
|
computed_bridges = bridges(limit: 10)
|
|
380
|
+
computed_cross_database = cross_database_edges
|
|
381
|
+
computed_volatile = volatile_dependencies
|
|
382
|
+
computed_undeclared = undeclared_package_edges
|
|
213
383
|
|
|
214
384
|
{
|
|
215
385
|
orphans: computed_orphans,
|
|
@@ -217,11 +387,19 @@ module Woods
|
|
|
217
387
|
hubs: computed_hubs,
|
|
218
388
|
cycles: computed_cycles,
|
|
219
389
|
bridges: computed_bridges,
|
|
390
|
+
cross_database_edges: computed_cross_database,
|
|
391
|
+
volatile_dependencies: computed_volatile,
|
|
392
|
+
undeclared_package_edges: computed_undeclared,
|
|
220
393
|
stats: {
|
|
221
394
|
orphan_count: computed_orphans.size,
|
|
222
395
|
dead_end_count: computed_dead_ends.size,
|
|
223
396
|
hub_count: computed_hubs.size,
|
|
224
|
-
cycle_count: computed_cycles.size
|
|
397
|
+
cycle_count: computed_cycles.size,
|
|
398
|
+
cycle_limit_reached: cycle_limit_reached?,
|
|
399
|
+
cross_database_edge_count: computed_cross_database.size,
|
|
400
|
+
volatile_dependency_count: all_volatile_dependencies.size,
|
|
401
|
+
volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT,
|
|
402
|
+
undeclared_package_edge_count: computed_undeclared.size
|
|
225
403
|
}
|
|
226
404
|
}
|
|
227
405
|
end
|
|
@@ -258,41 +436,72 @@ module Woods
|
|
|
258
436
|
end
|
|
259
437
|
|
|
260
438
|
# Assign units with no namespace prefix to their most-connected cluster.
|
|
439
|
+
#
|
|
440
|
+
# Order-free (EXTB-7). Each round scores *every* still-unassigned unit
|
|
441
|
+
# against one membership snapshot taken before the round, then applies all
|
|
442
|
+
# of that round's assignments at once. Assigning inside the scoring loop —
|
|
443
|
+
# as this did — let a unit whose only connection is another unnamespaced
|
|
444
|
+
# unit join a cluster or not depending on which of the two the graph
|
|
445
|
+
# happened to enumerate first, i.e. on registration order, which differs
|
|
446
|
+
# between a full and an incremental run.
|
|
447
|
+
#
|
|
448
|
+
# Rounds are bounded: each one either assigns at least one unit or ends the
|
|
449
|
+
# loop, and {ORPHAN_ASSIGNMENT_ROUNDS} caps how far a chain of unnamespaced
|
|
450
|
+
# units can pull its successors in. Units past that depth stay unassigned —
|
|
451
|
+
# deterministically, which is the property that matters here.
|
|
261
452
|
def assign_orphaned_units(clusters, filtered_ids, _nodes)
|
|
262
453
|
return if clusters.empty?
|
|
263
454
|
|
|
264
|
-
|
|
455
|
+
pending = filtered_ids.select { |id| cluster_prefix(id).nil? }.sort
|
|
456
|
+
|
|
457
|
+
ORPHAN_ASSIGNMENT_ROUNDS.times do
|
|
458
|
+
break if pending.empty?
|
|
265
459
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
460
|
+
membership = clusters.transform_values { |cluster| cluster[:member_set] }.freeze
|
|
461
|
+
assignments = pending.filter_map do |id|
|
|
462
|
+
best_cluster = find_most_connected_cluster(id, clusters.keys, membership)
|
|
463
|
+
[id, best_cluster] if best_cluster
|
|
464
|
+
end
|
|
465
|
+
break if assignments.empty?
|
|
269
466
|
|
|
270
|
-
|
|
271
|
-
|
|
467
|
+
assignments.each do |id, name|
|
|
468
|
+
clusters[name][:members] << id
|
|
469
|
+
clusters[name][:member_set].add(id)
|
|
470
|
+
end
|
|
471
|
+
pending -= assignments.map(&:first)
|
|
272
472
|
end
|
|
273
473
|
end
|
|
274
474
|
|
|
275
475
|
# Find which cluster a unit has the most connections to.
|
|
276
|
-
|
|
476
|
+
#
|
|
477
|
+
# @param identifier [String]
|
|
478
|
+
# @param cluster_names [Array<String>]
|
|
479
|
+
# @param membership [Hash{String => Set<String>}] name => members, read as
|
|
480
|
+
# of the start of the assignment round (see {#assign_orphaned_units})
|
|
481
|
+
# @return [String, nil]
|
|
482
|
+
def find_most_connected_cluster(identifier, cluster_names, membership)
|
|
277
483
|
connections = Hash.new(0)
|
|
278
484
|
|
|
279
485
|
# Check forward edges (dependencies)
|
|
280
486
|
@graph.dependencies_of(identifier).each do |dep|
|
|
281
|
-
|
|
282
|
-
connections[name] += 1 if
|
|
487
|
+
cluster_names.each do |name|
|
|
488
|
+
connections[name] += 1 if membership[name].include?(dep)
|
|
283
489
|
end
|
|
284
490
|
end
|
|
285
491
|
|
|
286
492
|
# Check reverse edges (dependents)
|
|
287
493
|
@graph.dependents_of(identifier).each do |dep|
|
|
288
|
-
|
|
289
|
-
connections[name] += 1 if
|
|
494
|
+
cluster_names.each do |name|
|
|
495
|
+
connections[name] += 1 if membership[name].include?(dep)
|
|
290
496
|
end
|
|
291
497
|
end
|
|
292
498
|
|
|
293
499
|
return nil if connections.empty?
|
|
294
500
|
|
|
295
|
-
|
|
501
|
+
# Tie-break on cluster name. `max_by` alone returns whichever equal-count
|
|
502
|
+
# cluster the hash happened to enumerate first, which is registration
|
|
503
|
+
# order — the one determinism hole left in this class.
|
|
504
|
+
connections.max_by { |name, count| [count, name] }.first
|
|
296
505
|
end
|
|
297
506
|
|
|
298
507
|
# Merge clusters smaller than min_size into their most-connected neighbor.
|
|
@@ -302,7 +511,7 @@ module Woods
|
|
|
302
511
|
break if small.empty?
|
|
303
512
|
|
|
304
513
|
# Merge the smallest cluster first
|
|
305
|
-
name, cluster = small.min_by { |
|
|
514
|
+
name, cluster = small.min_by { |cluster_name, c| [c[:members].size, cluster_name] }
|
|
306
515
|
|
|
307
516
|
# Find which other cluster this one connects to most
|
|
308
517
|
target = find_merge_target(cluster, clusters, name)
|
|
@@ -331,17 +540,26 @@ module Woods
|
|
|
331
540
|
|
|
332
541
|
return nil if connections.empty?
|
|
333
542
|
|
|
334
|
-
|
|
543
|
+
# Tie-break on cluster name. `max_by` alone returns whichever equal-count
|
|
544
|
+
# cluster the hash happened to enumerate first, which is registration
|
|
545
|
+
# order — the one determinism hole left in this class.
|
|
546
|
+
connections.max_by { |name, count| [count, name] }.first
|
|
335
547
|
end
|
|
336
548
|
|
|
337
549
|
# Enrich clusters with hub, entry points, boundary edges, and type breakdown.
|
|
338
550
|
def enrich_clusters(clusters, nodes, pagerank_scores)
|
|
339
551
|
clusters.each_value do |cluster|
|
|
340
|
-
|
|
552
|
+
# Sorted, like orphans/dead_ends/hubs. Members accumulate in graph
|
|
553
|
+
# registration order, and an incremental run appends where a full
|
|
554
|
+
# extraction interleaves by extractor — so an unsorted list publishes
|
|
555
|
+
# a different cluster for an identical graph. Everything derived below
|
|
556
|
+
# (entry points, boundary edges) inherits this order too.
|
|
557
|
+
members = cluster[:members].sort
|
|
558
|
+
cluster[:members] = members
|
|
341
559
|
member_set = cluster[:member_set]
|
|
342
560
|
|
|
343
561
|
# Hub: highest PageRank within the cluster
|
|
344
|
-
hub_id = members.max_by { |id| pagerank_scores[id] || 0 }
|
|
562
|
+
hub_id = members.max_by { |id| [pagerank_scores[id] || 0, id] }
|
|
345
563
|
cluster[:hub] = hub_id
|
|
346
564
|
|
|
347
565
|
# Entry points: controllers and GraphQL resolvers in the cluster's dependents
|
|
@@ -353,7 +571,7 @@ module Woods
|
|
|
353
571
|
entry_points.add(dep) if meta && entry_types.include?(meta[:type].to_s)
|
|
354
572
|
end
|
|
355
573
|
end
|
|
356
|
-
cluster[:entry_points] = entry_points.to_a
|
|
574
|
+
cluster[:entry_points] = entry_points.to_a.sort
|
|
357
575
|
|
|
358
576
|
# Boundary edges: connections that cross cluster boundaries
|
|
359
577
|
boundary = []
|
|
@@ -377,7 +595,8 @@ module Woods
|
|
|
377
595
|
end
|
|
378
596
|
end
|
|
379
597
|
# Deduplicate and limit boundary edges
|
|
380
|
-
cluster[:boundary_edges] = boundary.uniq { |e| [e[:from], e[:to]] }
|
|
598
|
+
cluster[:boundary_edges] = boundary.uniq { |e| [e[:from], e[:to]] }
|
|
599
|
+
.sort_by { |e| [e[:from].to_s, e[:to].to_s] }.first(20)
|
|
381
600
|
|
|
382
601
|
# Type breakdown
|
|
383
602
|
type_counts = members.each_with_object(Hash.new(0)) do |id, counts|
|
|
@@ -410,11 +629,196 @@ module Woods
|
|
|
410
629
|
@graph_nodes ||= graph_data[:nodes]
|
|
411
630
|
end
|
|
412
631
|
|
|
413
|
-
#
|
|
632
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
633
|
+
# Cross-database helpers
|
|
634
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
# table name => { database name => sorted identifiers of the model nodes
|
|
637
|
+
# in that database owning it }. Sorted identifier iteration makes a
|
|
638
|
+
# table owned by several nodes in one database resolve to the same
|
|
639
|
+
# first identifier every run; nodes with no table or no database
|
|
640
|
+
# contribute no ownership claim.
|
|
641
|
+
#
|
|
642
|
+
# @param nodes [Hash]
|
|
643
|
+
# @return [Hash{String => Hash{String => Array<String>}}]
|
|
644
|
+
def table_owners(nodes)
|
|
645
|
+
nodes.keys.sort.each_with_object({}) do |identifier, owners|
|
|
646
|
+
node = nodes[identifier]
|
|
647
|
+
table = node[:table]
|
|
648
|
+
database = node[:database]
|
|
649
|
+
next unless table && database
|
|
650
|
+
|
|
651
|
+
by_database = (owners[table] ||= {})
|
|
652
|
+
(by_database[database] ||= []) << identifier
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# @return [Array<Hash>] association edges from `identifier` that land in another database
|
|
657
|
+
def association_crossings(identifier, from_db, nodes)
|
|
658
|
+
@graph.edge_records(identifier, type: :model).filter_map do |edge|
|
|
659
|
+
via = edge[:via].to_s
|
|
660
|
+
next unless ASSOCIATION_VIAS.include?(via)
|
|
661
|
+
|
|
662
|
+
target = nodes[edge[:target]]
|
|
663
|
+
to_db = target && target[:database]
|
|
664
|
+
through_db = edge[:through] ? edge[:through_db] : nil
|
|
665
|
+
databases = [from_db, to_db, through_db].compact.uniq
|
|
666
|
+
next if databases.size < 2
|
|
667
|
+
|
|
668
|
+
disable_joins = edge[:disable_joins] == true
|
|
669
|
+
kind = edge[:through] && !disable_joins ? 'join_through_across_databases' : 'association_across_databases'
|
|
670
|
+
{
|
|
671
|
+
from: identifier, to: edge[:target], via: via, from_db: from_db, to_db: to_db,
|
|
672
|
+
through: edge[:through], through_db: through_db, disable_joins: disable_joins, kind: kind
|
|
673
|
+
}
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# @return [Array<Hash>] foreign keys from `identifier`'s table into a table owned by another database
|
|
678
|
+
def foreign_key_crossings(identifier, from_db, meta, owners)
|
|
679
|
+
Array(meta[:foreign_key_tables]).filter_map do |table|
|
|
680
|
+
foreign_key_crossing(identifier, from_db, table, owners)
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# A single foreign key's crossing entry, or nil when an owner of `table`
|
|
685
|
+
# lives in `from_db` (the key resolves locally regardless of what else
|
|
686
|
+
# claims the table name) or when no node claims the table at all.
|
|
687
|
+
#
|
|
688
|
+
# @return [Hash, nil]
|
|
689
|
+
def foreign_key_crossing(identifier, from_db, table, owners)
|
|
690
|
+
by_database = owners[table]
|
|
691
|
+
return nil if by_database.nil? || by_database.key?(from_db)
|
|
692
|
+
|
|
693
|
+
base = {
|
|
694
|
+
from: identifier, via: 'foreign_key', from_db: from_db,
|
|
695
|
+
through: nil, through_db: nil, disable_joins: false, kind: 'foreign_key_across_databases'
|
|
696
|
+
}
|
|
697
|
+
databases = by_database.keys.sort
|
|
698
|
+
if databases.size == 1
|
|
699
|
+
owner_db = databases.first
|
|
700
|
+
base.merge(to: by_database[owner_db].first, to_db: owner_db)
|
|
701
|
+
else
|
|
702
|
+
base.merge(to: nil, to_db: nil, ambiguous_owners: by_database.values.flatten.sort)
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
707
|
+
# Volatile dependency helpers
|
|
708
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
# PageRank computed once per analyzer instance.
|
|
711
|
+
#
|
|
712
|
+
# @return [Hash{String => Float}]
|
|
713
|
+
def pagerank_scores
|
|
714
|
+
@pagerank_scores ||= @graph.pagerank
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Every qualifying edge, unranked by {#volatile_dependencies}'s `limit`.
|
|
718
|
+
# {#analyze} needs the full count separately from the persisted top 20.
|
|
414
719
|
#
|
|
415
|
-
# @return [Hash]
|
|
416
|
-
def
|
|
417
|
-
@
|
|
720
|
+
# @return [Array<Hash>] sorted by pagerank, ratio, from, to, via
|
|
721
|
+
def all_volatile_dependencies
|
|
722
|
+
@all_volatile_dependencies ||= compute_volatile_dependencies
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Short-circuits to `[]`, skipping the PageRank computation entirely,
|
|
726
|
+
# when no node carries an Integer `commit_count` (git enrichment never
|
|
727
|
+
# ran): there is nothing to rank.
|
|
728
|
+
#
|
|
729
|
+
# @return [Array<Hash>]
|
|
730
|
+
def compute_volatile_dependencies
|
|
731
|
+
nodes = graph_nodes
|
|
732
|
+
return [] unless nodes.each_value.any? { |meta| meta[:commit_count].is_a?(Integer) }
|
|
733
|
+
|
|
734
|
+
scores = pagerank_scores
|
|
735
|
+
entries = nodes.keys.sort.flat_map do |from|
|
|
736
|
+
from_meta = nodes[from]
|
|
737
|
+
from_commits = from_meta[:commit_count]
|
|
738
|
+
next [] unless from_commits.is_a?(Integer)
|
|
739
|
+
|
|
740
|
+
@graph.edge_records(from, type: from_meta[:type]).filter_map do |edge|
|
|
741
|
+
volatile_entry(from, from_meta, from_commits, edge, nodes, scores)
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
entries.uniq { |e| [e[:from], e[:to], e[:via]] }
|
|
745
|
+
.sort_by { |e| [-e[:pagerank], -e[:ratio], e[:from], e[:to], e[:via]] }
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# @return [Hash, nil] the report entry for one edge, or nil when it is not volatile
|
|
749
|
+
def volatile_entry(from, from_meta, from_commits, edge, nodes, scores)
|
|
750
|
+
to = edge[:target]
|
|
751
|
+
to_meta = nodes[to]
|
|
752
|
+
return nil unless to_meta
|
|
753
|
+
|
|
754
|
+
to_commits = to_meta[:commit_count]
|
|
755
|
+
return nil unless to_commits.is_a?(Integer) && to_commits >= VOLATILE_MIN_COMMITS
|
|
756
|
+
return nil if to_meta[:change_frequency] == 'new'
|
|
757
|
+
|
|
758
|
+
ratio = to_commits.to_f / [from_commits, 1].max
|
|
759
|
+
return nil if ratio < @volatile_ratio
|
|
760
|
+
|
|
761
|
+
{
|
|
762
|
+
from: from, from_type: from_meta[:type], to: to, to_type: to_meta[:type], via: edge[:via].to_s,
|
|
763
|
+
from_commits: from_commits, to_commits: to_commits,
|
|
764
|
+
ratio: ratio.round(2), pagerank: (scores[to] || 0.0).round(4)
|
|
765
|
+
}
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
769
|
+
# Package boundary helpers
|
|
770
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
# Short-circuits to `[]`, skipping declaration lookup entirely, when no
|
|
773
|
+
# node carries a `package` attribute (no package extraction ran).
|
|
774
|
+
#
|
|
775
|
+
# @return [Array<Hash>]
|
|
776
|
+
def compute_undeclared_package_edges
|
|
777
|
+
nodes = graph_nodes
|
|
778
|
+
return [] unless nodes.each_value.any? { |meta| meta.key?(:package) }
|
|
779
|
+
|
|
780
|
+
declared = package_declarations(nodes)
|
|
781
|
+
entries = nodes.keys.sort.flat_map do |from|
|
|
782
|
+
meta = nodes[from]
|
|
783
|
+
from_package = meta[:package]
|
|
784
|
+
next [] if from_package.nil? || meta[:type] == :package
|
|
785
|
+
|
|
786
|
+
@graph.edge_records(from, type: meta[:type]).filter_map do |edge|
|
|
787
|
+
undeclared_entry(from, meta, from_package, edge, nodes, declared)
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
entries.uniq.sort_by { |e| [e[:from], e[:to], e[:via]] }
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# package identifier => Set of package identifiers it declares as
|
|
794
|
+
# dependencies, read from that package unit's own `:package_dependency`
|
|
795
|
+
# edges. A root package (`.`) declaring nothing is just another entry
|
|
796
|
+
# with an empty Set, no special-cased root handling.
|
|
797
|
+
#
|
|
798
|
+
# @param nodes [Hash]
|
|
799
|
+
# @return [Hash{String => Set<String>}]
|
|
800
|
+
def package_declarations(nodes)
|
|
801
|
+
nodes.each_with_object({}) do |(identifier, meta), declared|
|
|
802
|
+
next unless meta[:type] == :package
|
|
803
|
+
|
|
804
|
+
declared[identifier] = @graph.dependencies_of(identifier, via: :package_dependency).to_set
|
|
805
|
+
end
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# @return [Hash, nil] the report entry, or nil when the edge stays inside declared boundaries
|
|
809
|
+
def undeclared_entry(from, from_meta, from_package, edge, nodes, declared)
|
|
810
|
+
to = edge[:target]
|
|
811
|
+
to_meta = nodes[to]
|
|
812
|
+
to_package = to_meta && to_meta[:package]
|
|
813
|
+
return nil if to_package.nil? || to_package == from_package
|
|
814
|
+
|
|
815
|
+
declared_deps = declared[from_package] || Set.new
|
|
816
|
+
return nil if declared_deps.include?(to_package)
|
|
817
|
+
|
|
818
|
+
{
|
|
819
|
+
from: from, from_type: from_meta[:type], to: to, to_type: to_meta[:type], via: edge[:via].to_s,
|
|
820
|
+
from_package: from_package, to_package: to_package
|
|
821
|
+
}
|
|
418
822
|
end
|
|
419
823
|
|
|
420
824
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -433,6 +837,7 @@ module Woods
|
|
|
433
837
|
# @return [Array<Array<String>>] Detected cycles
|
|
434
838
|
def detect_cycles
|
|
435
839
|
nodes = graph_nodes
|
|
840
|
+
@cycle_limit_reached = false
|
|
436
841
|
return [] if nodes.empty?
|
|
437
842
|
|
|
438
843
|
white = 0
|
|
@@ -444,67 +849,103 @@ module Woods
|
|
|
444
849
|
found_cycles = []
|
|
445
850
|
seen_cycle_signatures = Set.new
|
|
446
851
|
|
|
447
|
-
|
|
448
|
-
|
|
852
|
+
catch(:cycle_limit) do
|
|
853
|
+
nodes.keys.sort.each do |start_node|
|
|
854
|
+
next unless color[start_node] == white
|
|
449
855
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
856
|
+
# Iterative DFS using an explicit stack.
|
|
857
|
+
# Each entry is [node, :enter] or [node, :exit].
|
|
858
|
+
stack = [[start_node, :enter]]
|
|
453
859
|
|
|
454
|
-
|
|
455
|
-
|
|
860
|
+
# Track the current DFS path for cycle extraction.
|
|
861
|
+
path = []
|
|
456
862
|
|
|
457
|
-
|
|
458
|
-
|
|
863
|
+
while stack.any?
|
|
864
|
+
node, action = stack.pop
|
|
459
865
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
866
|
+
if action == :exit
|
|
867
|
+
color[node] = black
|
|
868
|
+
path.pop
|
|
869
|
+
next
|
|
870
|
+
end
|
|
465
871
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
seen_cycle_signatures.add(sig)
|
|
486
|
-
found_cycles << cycle
|
|
487
|
-
end
|
|
872
|
+
# :enter action
|
|
873
|
+
next unless color[node] == white
|
|
874
|
+
|
|
875
|
+
color[node] = gray
|
|
876
|
+
path.push(node)
|
|
877
|
+
stack.push([node, :exit])
|
|
878
|
+
|
|
879
|
+
# Not sorted, deliberately: this list is the unit's own declared
|
|
880
|
+
# dependency order, which is identical in a full and an incremental
|
|
881
|
+
# run, so sorting it would change nothing any test can observe.
|
|
882
|
+
neighbors = @graph.dependencies_of(node)
|
|
883
|
+
neighbors.each do |neighbor|
|
|
884
|
+
case color[neighbor]
|
|
885
|
+
when white
|
|
886
|
+
parent[neighbor] = node
|
|
887
|
+
stack.push([neighbor, :enter])
|
|
888
|
+
when gray
|
|
889
|
+
# Found a cycle: extract it from the path
|
|
890
|
+
collect_cycle(path, neighbor, found_cycles, seen_cycle_signatures)
|
|
488
891
|
end
|
|
892
|
+
# black nodes are fully explored, skip them
|
|
489
893
|
end
|
|
490
|
-
# black nodes are fully explored, skip them
|
|
491
894
|
end
|
|
492
895
|
end
|
|
493
896
|
end
|
|
494
897
|
|
|
898
|
+
# Deterministic without a final sort: the DFS starts from a sorted
|
|
899
|
+
# node list, so cycles are discovered in the same order every run.
|
|
495
900
|
found_cycles
|
|
496
901
|
end
|
|
497
902
|
|
|
903
|
+
# Record the cycle closed by a back-edge to +cycle_start+.
|
|
904
|
+
#
|
|
905
|
+
# Skips a cycle longer than the length cap and one already recorded under
|
|
906
|
+
# another rotation. Throws +:cycle_limit+ once the count cap is full,
|
|
907
|
+
# which ends the whole scan in {#detect_cycles}.
|
|
908
|
+
#
|
|
909
|
+
# @param path [Array<String>] Current DFS path
|
|
910
|
+
# @param cycle_start [String] The node that closes the cycle
|
|
911
|
+
# @param found_cycles [Array<Array<String>>] Accumulator
|
|
912
|
+
# @param seen [Set<String>] Signatures already recorded
|
|
913
|
+
# @return [void]
|
|
914
|
+
def collect_cycle(path, cycle_start, found_cycles, seen)
|
|
915
|
+
cycle = extract_cycle_from_path(path, cycle_start, max_length: @cycle_max_length)
|
|
916
|
+
if cycle == :too_long
|
|
917
|
+
@cycle_limit_reached = true
|
|
918
|
+
return
|
|
919
|
+
end
|
|
920
|
+
return unless cycle
|
|
921
|
+
|
|
922
|
+
signature = normalize_cycle_signature(cycle)
|
|
923
|
+
return if seen.include?(signature)
|
|
924
|
+
|
|
925
|
+
seen.add(signature)
|
|
926
|
+
found_cycles << cycle
|
|
927
|
+
return unless @cycle_limit && found_cycles.size >= @cycle_limit
|
|
928
|
+
|
|
929
|
+
@cycle_limit_reached = true
|
|
930
|
+
throw :cycle_limit
|
|
931
|
+
end
|
|
932
|
+
|
|
498
933
|
# Extracts a cycle from the current DFS path when a back-edge to
|
|
499
934
|
# +cycle_start+ is found.
|
|
500
935
|
#
|
|
936
|
+
# The length check runs on indexes, before the slice: an over-long cycle
|
|
937
|
+
# costs nothing beyond the +index+ lookup that found it.
|
|
938
|
+
#
|
|
501
939
|
# @param path [Array<String>] Current DFS path
|
|
502
940
|
# @param cycle_start [String] The node that closes the cycle
|
|
503
|
-
# @
|
|
504
|
-
#
|
|
505
|
-
|
|
941
|
+
# @param max_length [Integer, nil] Longest cycle to build, in distinct nodes
|
|
942
|
+
# @return [Array<String>, Symbol, nil] The cycle path ending with cycle_start
|
|
943
|
+
# repeated; +:too_long+ when it exceeds +max_length+; nil when cycle_start
|
|
944
|
+
# is not in the path
|
|
945
|
+
def extract_cycle_from_path(path, cycle_start, max_length: nil)
|
|
506
946
|
start_index = path.index(cycle_start)
|
|
507
947
|
return nil unless start_index
|
|
948
|
+
return :too_long if max_length && (path.size - start_index) > max_length
|
|
508
949
|
|
|
509
950
|
path[start_index..] + [cycle_start]
|
|
510
951
|
end
|
|
@@ -512,17 +953,20 @@ module Woods
|
|
|
512
953
|
# Normalize a cycle so that duplicate rotations are treated as the same cycle.
|
|
513
954
|
# For example, [A, B, C, A] and [B, C, A, B] are the same cycle.
|
|
514
955
|
#
|
|
956
|
+
# Keyed by digest rather than by the joined path: the set holds one
|
|
957
|
+
# 64-byte key per cycle instead of a string as long as the cycle, so
|
|
958
|
+
# membership stays constant-cost however deep the DFS went.
|
|
959
|
+
#
|
|
515
960
|
# @param cycle [Array<String>] Cycle path with repeated last element
|
|
516
|
-
# @return [String] Canonical
|
|
961
|
+
# @return [String] Canonical hex digest of the rotated loop
|
|
517
962
|
def normalize_cycle_signature(cycle)
|
|
518
963
|
# Remove the trailing repeated element to get the raw loop
|
|
519
964
|
loop_nodes = cycle[0..-2]
|
|
520
|
-
return
|
|
965
|
+
return Digest::SHA256.hexdigest('') if loop_nodes.empty?
|
|
521
966
|
|
|
522
967
|
# Rotate so the lexicographically smallest element is first
|
|
523
968
|
min_index = loop_nodes.each_with_index.min_by { |node, _i| node }.last
|
|
524
|
-
|
|
525
|
-
rotated.join('->')
|
|
969
|
+
Digest::SHA256.hexdigest(loop_nodes.rotate(min_index).join('->'))
|
|
526
970
|
end
|
|
527
971
|
|
|
528
972
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -553,32 +997,65 @@ module Woods
|
|
|
553
997
|
pairs.to_a
|
|
554
998
|
end
|
|
555
999
|
|
|
1000
|
+
# Forward adjacency, resolved once per node per analyzer instance.
|
|
1001
|
+
#
|
|
1002
|
+
# {#bridges} runs `sample_size` whole-graph traversals, and
|
|
1003
|
+
# `DependencyGraph#dependencies_of` sorts and flattens the node's edge
|
|
1004
|
+
# buckets on every call, so an uncached BFS re-derived the same adjacency
|
|
1005
|
+
# list up to 200 times per node. Populated on demand rather than up front:
|
|
1006
|
+
# a traversal that never reaches a node should not pay for it.
|
|
1007
|
+
#
|
|
1008
|
+
# @return [Hash{String => Array<String>}]
|
|
1009
|
+
def adjacency
|
|
1010
|
+
@adjacency ||= Hash.new { |cache, identifier| cache[identifier] = @graph.dependencies_of(identifier) }
|
|
1011
|
+
end
|
|
1012
|
+
|
|
556
1013
|
# BFS shortest path between two nodes, following forward edges.
|
|
557
1014
|
#
|
|
1015
|
+
# Carries parent pointers rather than a path per queue entry. The old form
|
|
1016
|
+
# allocated a copy of the path so far for every node it enqueued, which on
|
|
1017
|
+
# a large graph is a full array per node per traversal; the path is now
|
|
1018
|
+
# built once, for the one node that matched.
|
|
1019
|
+
#
|
|
558
1020
|
# @param source [String] Starting node identifier
|
|
559
1021
|
# @param target [String] Target node identifier
|
|
560
1022
|
# @return [Array<String>, nil] Shortest path or nil if unreachable
|
|
561
1023
|
def bfs_shortest_path(source, target)
|
|
562
1024
|
return [source] if source == target
|
|
563
1025
|
|
|
564
|
-
|
|
565
|
-
queue = [
|
|
1026
|
+
parents = { source => nil }
|
|
1027
|
+
queue = [source]
|
|
1028
|
+
head = 0
|
|
566
1029
|
|
|
567
|
-
while queue.
|
|
568
|
-
current
|
|
1030
|
+
while head < queue.size
|
|
1031
|
+
current = queue[head]
|
|
1032
|
+
head += 1
|
|
569
1033
|
|
|
570
|
-
|
|
571
|
-
next if
|
|
1034
|
+
adjacency[current].each do |neighbor|
|
|
1035
|
+
next if parents.key?(neighbor)
|
|
572
1036
|
|
|
573
|
-
|
|
574
|
-
return
|
|
1037
|
+
parents[neighbor] = current
|
|
1038
|
+
return path_to(parents, neighbor) if neighbor == target
|
|
575
1039
|
|
|
576
|
-
|
|
577
|
-
queue.push([neighbor, new_path])
|
|
1040
|
+
queue.push(neighbor)
|
|
578
1041
|
end
|
|
579
1042
|
end
|
|
580
1043
|
|
|
581
1044
|
nil
|
|
582
1045
|
end
|
|
1046
|
+
|
|
1047
|
+
# Walk parent pointers back to the source and reverse.
|
|
1048
|
+
#
|
|
1049
|
+
# @param parents [Hash{String => String, nil}] node => the node it was reached from
|
|
1050
|
+
# @param node [String] the end of the path
|
|
1051
|
+
# @return [Array<String>] source-first path ending at +node+
|
|
1052
|
+
def path_to(parents, node)
|
|
1053
|
+
path = []
|
|
1054
|
+
while node
|
|
1055
|
+
path << node
|
|
1056
|
+
node = parents[node]
|
|
1057
|
+
end
|
|
1058
|
+
path.reverse
|
|
1059
|
+
end
|
|
583
1060
|
end
|
|
584
1061
|
end
|