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
|
@@ -21,38 +21,67 @@ module Woods
|
|
|
21
21
|
# affected = graph.affected_by(["app/models/user.rb"])
|
|
22
22
|
#
|
|
23
23
|
class DependencyGraph
|
|
24
|
+
# Optional node keys beyond type, file_path, and namespace. Each is
|
|
25
|
+
# omitted when the unit carries no value, so a graph with no such facts
|
|
26
|
+
# serializes byte-for-byte as it always has. Values are normalized to
|
|
27
|
+
# strings (or Integer/Array/Boolean where noted) so a full and an
|
|
28
|
+
# incremental run write identical JSON.
|
|
29
|
+
#
|
|
30
|
+
# database, table, foreign_key_tables: model units (Task 3)
|
|
31
|
+
# package: any app-owned unit under a Packwerk package (Task 8)
|
|
32
|
+
# enforce_dependencies: package units (Task 7)
|
|
33
|
+
# commit_count, change_frequency: git enrichment (Task 5)
|
|
34
|
+
NODE_ATTRIBUTE_KEYS = %i[
|
|
35
|
+
database table foreign_key_tables package enforce_dependencies commit_count change_frequency
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Optional edge keys beyond target and via. `through` is the through
|
|
39
|
+
# association name; `through_db` is the through model's resolved
|
|
40
|
+
# database, emitted only when present; `disable_joins` is emitted only
|
|
41
|
+
# when true.
|
|
42
|
+
EDGE_ATTRIBUTE_KEYS = %i[through through_db disable_joins].freeze
|
|
43
|
+
|
|
24
44
|
def initialize
|
|
25
|
-
@nodes = {} # identifier => { type:, file_path: }
|
|
26
|
-
@edges = {} # identifier => [{ target:, via: }]
|
|
45
|
+
@nodes = {} # identifier => { type => { type:, file_path:, namespace: } }
|
|
46
|
+
@edges = {} # identifier => { type => [{ target:, via: }] }
|
|
27
47
|
@reverse = {} # identifier => Set of dependent identifiers
|
|
28
48
|
@reverse_via = {} # [target, via] => Set of dependent identifiers
|
|
29
|
-
@file_map = {} # file_path =>
|
|
49
|
+
@file_map = {} # file_path => Set of identifiers (one file can define many units)
|
|
30
50
|
@type_index = {} # type => Set of identifiers
|
|
31
51
|
@to_h = nil
|
|
52
|
+
@suffix_groups = nil
|
|
32
53
|
end
|
|
33
54
|
|
|
34
55
|
# Register a unit in the graph.
|
|
35
56
|
#
|
|
36
|
-
# Re-registering
|
|
37
|
-
# graph loaded from disk
|
|
38
|
-
# reverse edges, file-map entry, and type-index entry
|
|
39
|
-
# dependents accumulate across incremental runs and get
|
|
40
|
-
# to dependency_graph.json.
|
|
57
|
+
# Re-registering the same (identifier, type) — incremental extraction
|
|
58
|
+
# registers into a graph loaded from disk — first removes that
|
|
59
|
+
# registration's reverse edges, file-map entry, and type-index entry;
|
|
60
|
+
# otherwise stale dependents accumulate across incremental runs and get
|
|
61
|
+
# persisted back to dependency_graph.json.
|
|
62
|
+
#
|
|
63
|
+
# Re-registering the same identifier under a *different* type does not
|
|
64
|
+
# displace the first unit (#225). A Scenic view `reports` and a factory
|
|
65
|
+
# `reports` are two units, the index writes them to two files
|
|
66
|
+
# (`database_view/reports.json`, `factory/reports.json`), and the graph
|
|
67
|
+
# holds them as two nodes under one identifier.
|
|
41
68
|
#
|
|
42
69
|
# @param unit [ExtractedUnit] The unit to register
|
|
43
70
|
def register(unit)
|
|
44
71
|
@to_h = nil
|
|
72
|
+
@suffix_groups = nil
|
|
45
73
|
|
|
46
|
-
unregister(unit.identifier) if @nodes.key?(unit.
|
|
74
|
+
unregister(unit.identifier, type: unit.type) if @nodes[unit.identifier]&.key?(unit.type)
|
|
47
75
|
|
|
48
|
-
@nodes[unit.identifier] = {
|
|
76
|
+
(@nodes[unit.identifier] ||= {})[unit.type] = {
|
|
49
77
|
type: unit.type,
|
|
50
78
|
file_path: unit.file_path,
|
|
51
79
|
namespace: unit.namespace
|
|
52
|
-
}
|
|
80
|
+
}.merge(node_attributes_from(unit))
|
|
53
81
|
|
|
54
|
-
@edges[unit.identifier]
|
|
55
|
-
|
|
82
|
+
(@edges[unit.identifier] ||= {})[unit.type] =
|
|
83
|
+
unit.dependencies.map { |d| { target: d[:target], via: d[:via] }.merge(self.class.edge_attributes(d)) }
|
|
84
|
+
(@file_map[unit.file_path] ||= Set.new).add(unit.identifier) if unit.file_path
|
|
56
85
|
|
|
57
86
|
# Type index for filtering (Set-based for O(1) insert)
|
|
58
87
|
(@type_index[unit.type] ||= Set.new).add(unit.identifier)
|
|
@@ -64,33 +93,243 @@ module Woods
|
|
|
64
93
|
end
|
|
65
94
|
end
|
|
66
95
|
|
|
67
|
-
# Remove
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
96
|
+
# Remove a registration's side effects: its contribution to the reverse
|
|
97
|
+
# indexes (derived from its recorded forward edges), its file-map entry,
|
|
98
|
+
# and its type-index entry. Forward node/edge data is overwritten by the
|
|
99
|
+
# caller (register), so it is not cleared here.
|
|
100
|
+
#
|
|
101
|
+
# Scoped to one type when `type:` is given, so re-registering a Scenic
|
|
102
|
+
# view `reports` leaves a factory `reports` untouched. Without it, every
|
|
103
|
+
# type registered under the identifier is stripped — which is what a
|
|
104
|
+
# caller deleting the identifier outright wants.
|
|
105
|
+
#
|
|
106
|
+
# The reverse indexes are keyed on the *target* identifier and hold source
|
|
107
|
+
# identifiers, not (identifier, type) pairs: a dependency names a target
|
|
108
|
+
# by identifier alone, so there is no type to key on. A source's
|
|
109
|
+
# contribution is therefore only withdrawn once its **last** remaining
|
|
110
|
+
# type stops pointing at that target.
|
|
71
111
|
#
|
|
72
112
|
# @param identifier [String] Previously-registered unit identifier
|
|
113
|
+
# @param type [Symbol, nil] Restrict to one registered type
|
|
114
|
+
# @return [void]
|
|
115
|
+
def unregister(identifier, type: nil)
|
|
116
|
+
withdrawing = registered_types(identifier, type).to_set
|
|
117
|
+
|
|
118
|
+
withdrawing.each do |t|
|
|
119
|
+
withdraw_reverse_edges(identifier, t, withdrawing)
|
|
120
|
+
|
|
121
|
+
old_node = @nodes[identifier]&.[](t)
|
|
122
|
+
next unless old_node
|
|
123
|
+
|
|
124
|
+
drop_from_file_map(identifier, old_node[:file_path], withdrawing)
|
|
125
|
+
drop_from_type_index(identifier, old_node[:type])
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Retract one registration's forward edges from the reverse indexes,
|
|
130
|
+
# keeping any contribution the identifier's *other* types still make to
|
|
131
|
+
# the same target.
|
|
132
|
+
#
|
|
133
|
+
# @param identifier [String]
|
|
134
|
+
# @param type [Symbol, nil]
|
|
135
|
+
# @param withdrawing [Set<Symbol>] every type being withdrawn in this call
|
|
73
136
|
# @return [void]
|
|
74
|
-
def
|
|
75
|
-
(
|
|
76
|
-
|
|
137
|
+
def withdraw_reverse_edges(identifier, type, withdrawing)
|
|
138
|
+
surviving = surviving_edges(identifier, withdrawing)
|
|
139
|
+
surviving_targets = surviving.to_set(&:first)
|
|
140
|
+
|
|
141
|
+
Array(@edges[identifier]&.[](type)).each do |edge|
|
|
142
|
+
if !surviving_targets.include?(edge[:target]) && (set = @reverse[edge[:target]])
|
|
77
143
|
set.delete(identifier)
|
|
78
144
|
@reverse.delete(edge[:target]) if set.empty?
|
|
79
145
|
end
|
|
80
146
|
|
|
81
147
|
via_key = [edge[:target], edge[:via]]
|
|
148
|
+
next if surviving.include?(via_key)
|
|
82
149
|
next unless (set = @reverse_via[via_key])
|
|
83
150
|
|
|
84
151
|
set.delete(identifier)
|
|
85
152
|
@reverse_via.delete(via_key) if set.empty?
|
|
86
153
|
end
|
|
154
|
+
end
|
|
155
|
+
private :withdraw_reverse_edges
|
|
156
|
+
|
|
157
|
+
# `[target, via]` pairs the identifier's *other* types still record, which
|
|
158
|
+
# must keep their reverse entries when one type is withdrawn.
|
|
159
|
+
#
|
|
160
|
+
# @param identifier [String]
|
|
161
|
+
# @param withdrawing [Set<Symbol>] the types being withdrawn
|
|
162
|
+
# @return [Set<Array>] surviving `[target, via]` pairs
|
|
163
|
+
def surviving_edges(identifier, withdrawing)
|
|
164
|
+
(@edges[identifier] || {}).each_with_object(Set.new) do |(other_type, edges), set|
|
|
165
|
+
next if withdrawing.include?(other_type)
|
|
166
|
+
|
|
167
|
+
edges.each { |edge| set.add([edge[:target], edge[:via]]) }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
private :surviving_edges
|
|
171
|
+
|
|
172
|
+
# Drop an identifier from a path's file-map entry, unless another of its
|
|
173
|
+
# types still claims that path.
|
|
174
|
+
#
|
|
175
|
+
# @param identifier [String]
|
|
176
|
+
# @param path [String, nil]
|
|
177
|
+
# @param withdrawing [Set<Symbol>] types being withdrawn, which do not count
|
|
178
|
+
# as claimants — the node is still in `@nodes` at this point, since
|
|
179
|
+
# `register` overwrites it only after `unregister` returns
|
|
180
|
+
# @return [void]
|
|
181
|
+
def drop_from_file_map(identifier, path, withdrawing)
|
|
182
|
+
return unless path
|
|
183
|
+
|
|
184
|
+
return if (@nodes[identifier] || {}).any? do |type, node|
|
|
185
|
+
!withdrawing.include?(type) && node[:file_path] == path
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
return unless (ids = @file_map[path])
|
|
189
|
+
|
|
190
|
+
ids.delete(identifier)
|
|
191
|
+
@file_map.delete(path) if ids.empty?
|
|
192
|
+
end
|
|
193
|
+
private :drop_from_file_map
|
|
194
|
+
|
|
195
|
+
# Drop an identifier from a type bucket.
|
|
196
|
+
#
|
|
197
|
+
# @param identifier [String]
|
|
198
|
+
# @param type [Symbol, nil]
|
|
199
|
+
# @return [void]
|
|
200
|
+
def drop_from_type_index(identifier, type)
|
|
201
|
+
return unless (type_ids = @type_index[type])
|
|
202
|
+
|
|
203
|
+
type_ids.delete(identifier)
|
|
204
|
+
# Drop the key when the last unit of a type goes away — a full
|
|
205
|
+
# extraction never emits an empty type bucket, and a stale empty one
|
|
206
|
+
# would show up in to_h[:type_index] and stats[:types] as a phantom.
|
|
207
|
+
@type_index.delete(type) if type_ids.empty?
|
|
208
|
+
end
|
|
209
|
+
private :drop_from_type_index
|
|
210
|
+
|
|
211
|
+
# Types registered under an identifier, or the one requested.
|
|
212
|
+
#
|
|
213
|
+
# Reads from `@edges` as well as `@nodes`: a graph restored by {.from_h}
|
|
214
|
+
# from a hash carrying edges for an identifier it has no node for still
|
|
215
|
+
# has to be able to withdraw them.
|
|
216
|
+
#
|
|
217
|
+
# @param identifier [String]
|
|
218
|
+
# @param type [Symbol, nil] restrict to this one when given
|
|
219
|
+
# @return [Array<Symbol, nil>]
|
|
220
|
+
def registered_types(identifier, type = nil)
|
|
221
|
+
return [type] if type
|
|
222
|
+
|
|
223
|
+
((@nodes[identifier]&.keys || []) | (@edges[identifier]&.keys || []))
|
|
224
|
+
end
|
|
225
|
+
private :registered_types
|
|
226
|
+
|
|
227
|
+
# Fully remove a unit from the graph — node, forward edges, reverse-edge
|
|
228
|
+
# contributions, file-map entry, and type-index entry.
|
|
229
|
+
#
|
|
230
|
+
# Distinct from {#unregister}, which strips only the *side effects* of a
|
|
231
|
+
# registration so the same identifier can be re-registered on top; this
|
|
232
|
+
# is the deletion path used when a source file disappears. Reverse
|
|
233
|
+
# entries keyed *on* the removed identifier are intentionally left alone:
|
|
234
|
+
# they are derived from other units' forward edges, which are unchanged,
|
|
235
|
+
# and a full extraction records them the same way (a dependency target
|
|
236
|
+
# need not be a registered node).
|
|
237
|
+
#
|
|
238
|
+
# @param identifier [String] Unit identifier to remove
|
|
239
|
+
# @param type [Symbol, nil] Remove only this type's node; without it every
|
|
240
|
+
# node registered under the identifier goes. A caller pruning a deleted
|
|
241
|
+
# source file must pass the type, or it deletes the same-named unit of
|
|
242
|
+
# another type along with it (#225).
|
|
243
|
+
# @return [Hash, nil] the removed node, or nil if it was not registered
|
|
244
|
+
def remove(identifier, type: nil)
|
|
245
|
+
nodes = @nodes[identifier]
|
|
246
|
+
return nil unless nodes&.any?
|
|
247
|
+
return nil if type && !nodes.key?(type)
|
|
248
|
+
|
|
249
|
+
@to_h = nil
|
|
250
|
+
@suffix_groups = nil
|
|
251
|
+
unregister(identifier, type: type)
|
|
252
|
+
|
|
253
|
+
return remove_all(identifier, nodes) unless type
|
|
254
|
+
|
|
255
|
+
remaining_edges = @edges[identifier]
|
|
256
|
+
remaining_edges&.delete(type)
|
|
257
|
+
@edges.delete(identifier) if remaining_edges && remaining_edges.empty?
|
|
258
|
+
removed = nodes.delete(type)
|
|
259
|
+
@nodes.delete(identifier) if nodes.empty?
|
|
260
|
+
removed
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @param identifier [String]
|
|
264
|
+
# @param nodes [Hash{Symbol => Hash}] every node under the identifier
|
|
265
|
+
# @return [Hash, nil] the primary node, matching the pre-#225 return value
|
|
266
|
+
def remove_all(identifier, nodes)
|
|
267
|
+
primary = primary_of(nodes)
|
|
268
|
+
@edges.delete(identifier)
|
|
269
|
+
@nodes.delete(identifier)
|
|
270
|
+
primary
|
|
271
|
+
end
|
|
272
|
+
private :remove_all
|
|
273
|
+
|
|
274
|
+
# Set or clear optional attributes on an existing node.
|
|
275
|
+
#
|
|
276
|
+
# Used after registration for facts that arrive later than the unit's
|
|
277
|
+
# metadata: git enrichment runs after every unit is registered, and an
|
|
278
|
+
# incremental run patches git data into unit JSON well after
|
|
279
|
+
# {#register}. A nil value removes the key, so a node whose fact went
|
|
280
|
+
# away serializes without it, exactly as a fresh registration would.
|
|
281
|
+
#
|
|
282
|
+
# @param identifier [String]
|
|
283
|
+
# @param type [Symbol] the registered type to annotate
|
|
284
|
+
# @param attributes [Hash{Symbol => Object}] keys from {NODE_ATTRIBUTE_KEYS}
|
|
285
|
+
# @return [Hash, nil] the node, or nil when (identifier, type) is unknown
|
|
286
|
+
# @raise [ArgumentError] on a key outside {NODE_ATTRIBUTE_KEYS}
|
|
287
|
+
def annotate(identifier, type:, **attributes)
|
|
288
|
+
node = @nodes.dig(identifier, type)
|
|
289
|
+
return nil unless node
|
|
290
|
+
|
|
291
|
+
@to_h = nil
|
|
292
|
+
attributes.each do |key, value|
|
|
293
|
+
raise ArgumentError, "Unknown node attribute #{key.inspect}" unless NODE_ATTRIBUTE_KEYS.include?(key)
|
|
294
|
+
|
|
295
|
+
if value.nil?
|
|
296
|
+
node.delete(key)
|
|
297
|
+
else
|
|
298
|
+
node[key] = self.class.normalize_node_attribute(key, value)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
node
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Forward edge records with their attributes, as copies.
|
|
305
|
+
#
|
|
306
|
+
# {#dependencies_of} answers with targets only; the analyzer reports that
|
|
307
|
+
# read `through` and `disable_joins` need the whole record.
|
|
308
|
+
#
|
|
309
|
+
# @param identifier [String]
|
|
310
|
+
# @param type [Symbol, nil] one registered type, or every one when nil
|
|
311
|
+
# @return [Array<Hash>] `{ target:, via:, through?, disable_joins? }`
|
|
312
|
+
def edge_records(identifier, type: nil)
|
|
313
|
+
edges_for(identifier, type).map(&:dup)
|
|
314
|
+
end
|
|
87
315
|
|
|
88
|
-
|
|
89
|
-
|
|
316
|
+
# Identifiers defined by a given file path.
|
|
317
|
+
#
|
|
318
|
+
# A single file can define several units (a `.rake` file with multiple
|
|
319
|
+
# tasks, an i18n YAML file, a lib file holding several classes), so this
|
|
320
|
+
# returns a list rather than a single identifier.
|
|
321
|
+
#
|
|
322
|
+
# @param file_path [String] Absolute file path as registered
|
|
323
|
+
# @return [Array<String>] Identifiers defined by that file (possibly empty)
|
|
324
|
+
def identifiers_for_path(file_path)
|
|
325
|
+
(@file_map[file_path] || []).to_a
|
|
326
|
+
end
|
|
90
327
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
328
|
+
# Every file path currently registered in the graph.
|
|
329
|
+
#
|
|
330
|
+
# @return [Array<String>]
|
|
331
|
+
def registered_paths
|
|
332
|
+
@file_map.keys
|
|
94
333
|
end
|
|
95
334
|
|
|
96
335
|
# Find all units affected by changes to given files
|
|
@@ -100,7 +339,7 @@ module Woods
|
|
|
100
339
|
# @param max_depth [Integer] Maximum traversal depth (nil for unlimited)
|
|
101
340
|
# @return [Array<String>] List of affected unit identifiers
|
|
102
341
|
def affected_by(changed_files, max_depth: nil)
|
|
103
|
-
directly_changed = changed_files.
|
|
342
|
+
directly_changed = changed_files.flat_map { |f| (@file_map[f] || []).to_a }.uniq
|
|
104
343
|
|
|
105
344
|
affected = Set.new(directly_changed)
|
|
106
345
|
queue = directly_changed.map { |id| [id, 0] } # [identifier, depth]
|
|
@@ -122,6 +361,86 @@ module Woods
|
|
|
122
361
|
affected.to_a
|
|
123
362
|
end
|
|
124
363
|
|
|
364
|
+
# Fetch a node's metadata without materializing the whole graph.
|
|
365
|
+
#
|
|
366
|
+
# {#to_h} is memoized but rebuilds on every register/remove, so reaching
|
|
367
|
+
# for `to_h[:nodes][id]` inside a loop is quadratic. Use this instead.
|
|
368
|
+
#
|
|
369
|
+
# Without `type:` this answers with the **primary** node — the one whose
|
|
370
|
+
# type sorts first. It is deterministic on purpose: picking by
|
|
371
|
+
# registration order would let a full and an incremental extraction of one
|
|
372
|
+
# tree disagree about which of two colliding units is "the" node, and the
|
|
373
|
+
# graph's published artifacts have to be a pure function of its content.
|
|
374
|
+
# A caller that must not guess should use {#nodes_for} or {#node_types}.
|
|
375
|
+
#
|
|
376
|
+
# @param identifier [String] Unit identifier
|
|
377
|
+
# @param type [Symbol, nil] Exact type to look up
|
|
378
|
+
# @return [Hash, nil] `{ type:, file_path:, namespace: }` or nil
|
|
379
|
+
def node(identifier, type: nil)
|
|
380
|
+
nodes = @nodes[identifier]
|
|
381
|
+
return nil unless nodes
|
|
382
|
+
|
|
383
|
+
type ? nodes[type] : primary_of(nodes)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Every node registered under an identifier, sorted by type.
|
|
387
|
+
#
|
|
388
|
+
# One entry for all but the handful of identifiers a codebase reuses
|
|
389
|
+
# across unit types (a Scenic view and a factory both called `reports`).
|
|
390
|
+
#
|
|
391
|
+
# @param identifier [String] Unit identifier
|
|
392
|
+
# @return [Array<Hash>] `{ type:, file_path:, namespace: }` entries
|
|
393
|
+
def nodes_for(identifier)
|
|
394
|
+
sorted_nodes(@nodes[identifier] || {}).map(&:last)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Types registered under an identifier, sorted.
|
|
398
|
+
#
|
|
399
|
+
# @param identifier [String] Unit identifier
|
|
400
|
+
# @return [Array<Symbol>]
|
|
401
|
+
def node_types(identifier)
|
|
402
|
+
sorted_nodes(@nodes[identifier] || {}).map(&:first)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# The (identifier, type) pairs a file defines.
|
|
406
|
+
#
|
|
407
|
+
# {#identifiers_for_path} answers with identifiers alone, which is
|
|
408
|
+
# ambiguous for a colliding identifier — a caller deleting the units of a
|
|
409
|
+
# vanished file needs to know *which* node that path registered, or it
|
|
410
|
+
# deletes the same-named unit of another type too.
|
|
411
|
+
#
|
|
412
|
+
# An identifier whose nodes name no matching `file_path` still yields every
|
|
413
|
+
# one of its types. The file map is the authority on which identifiers a
|
|
414
|
+
# path defines — a persisted graph can carry a file-map key that does not
|
|
415
|
+
# match its node's recorded path, and dropping the identifier there would
|
|
416
|
+
# make this see *less* than {#identifiers_for_path} does.
|
|
417
|
+
#
|
|
418
|
+
# @param file_path [String] Absolute file path as registered
|
|
419
|
+
# @return [Array<Array(String, Symbol)>] `[identifier, type]` pairs
|
|
420
|
+
def units_for_path(file_path)
|
|
421
|
+
(@file_map[file_path] || []).flat_map do |identifier|
|
|
422
|
+
nodes = sorted_nodes(@nodes[identifier] || {})
|
|
423
|
+
at_path = nodes.select { |_, node| node[:file_path] == file_path }
|
|
424
|
+
(at_path.empty? ? nodes : at_path).map { |type, _| [identifier, type] }
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# @param nodes [Hash{Symbol => Hash}]
|
|
429
|
+
# @return [Array<Array(Symbol, Hash)>] sorted by type name; a nil type
|
|
430
|
+
# (an edges-only entry restored from a hash with no matching node) sorts
|
|
431
|
+
# first, since it cannot be compared against a Symbol
|
|
432
|
+
def sorted_nodes(nodes)
|
|
433
|
+
nodes.sort_by { |type, _| [type ? 1 : 0, type.to_s] }
|
|
434
|
+
end
|
|
435
|
+
private :sorted_nodes
|
|
436
|
+
|
|
437
|
+
# @param nodes [Hash{Symbol => Hash}]
|
|
438
|
+
# @return [Hash, nil]
|
|
439
|
+
def primary_of(nodes)
|
|
440
|
+
sorted_nodes(nodes).first&.last
|
|
441
|
+
end
|
|
442
|
+
private :primary_of
|
|
443
|
+
|
|
125
444
|
# Check if a node exists in the graph by exact identifier.
|
|
126
445
|
#
|
|
127
446
|
# @param identifier [String] Unit identifier to check
|
|
@@ -130,6 +449,14 @@ module Woods
|
|
|
130
449
|
@nodes.key?(identifier)
|
|
131
450
|
end
|
|
132
451
|
|
|
452
|
+
# Whether the graph holds no nodes at all — an incremental run's
|
|
453
|
+
# "do I have a baseline?" check ({Woods::Extractor}).
|
|
454
|
+
#
|
|
455
|
+
# @return [Boolean]
|
|
456
|
+
def empty?
|
|
457
|
+
@nodes.empty?
|
|
458
|
+
end
|
|
459
|
+
|
|
133
460
|
# Find a node by suffix matching (e.g., "Update" matches "Order::Update").
|
|
134
461
|
#
|
|
135
462
|
# When multiple nodes share the same suffix, the first match wins.
|
|
@@ -138,18 +465,56 @@ module Woods
|
|
|
138
465
|
#
|
|
139
466
|
# @param suffix [String] The suffix to match against
|
|
140
467
|
# @return [String, nil] The first matching identifier, or nil
|
|
468
|
+
# Find a namespaced node by its unqualified short name.
|
|
469
|
+
#
|
|
470
|
+
# Backed by a memoized index rather than a scan. This is called once per
|
|
471
|
+
# operation while assembling an execution flow, so on a host with tens of
|
|
472
|
+
# thousands of nodes a single flow paid a linear pass over all of them for
|
|
473
|
+
# every call target it encountered.
|
|
474
|
+
#
|
|
475
|
+
# The index also makes the answer deterministic where several namespaced
|
|
476
|
+
# identifiers share a short name: the first in sorted order wins, whereas
|
|
477
|
+
# the scan returned whichever was registered first — so the same flow could
|
|
478
|
+
# resolve differently after an incremental run than after a full one.
|
|
479
|
+
#
|
|
480
|
+
# @param suffix [String] unqualified name, e.g. "Payment"
|
|
481
|
+
# @return [String, nil] the full identifier, e.g. "Billing::Payment"
|
|
141
482
|
def find_node_by_suffix(suffix)
|
|
142
|
-
|
|
143
|
-
|
|
483
|
+
suffix_groups[suffix]&.first
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Every namespaced identifier sharing +suffix+ as its unqualified short
|
|
487
|
+
# name — the full multiplicity {#find_node_by_suffix} collapses to its
|
|
488
|
+
# first (sorted) match.
|
|
489
|
+
#
|
|
490
|
+
# {#find_node_by_suffix}'s single-result contract stays exactly as it
|
|
491
|
+
# was for existing callers; this is additive, for a caller (the flow
|
|
492
|
+
# assembler) that needs to know whether a suffix resolution was
|
|
493
|
+
# ambiguous — several namespaces sharing one short name — instead of
|
|
494
|
+
# trusting whichever candidate the single-result method happened to
|
|
495
|
+
# pick.
|
|
496
|
+
#
|
|
497
|
+
# @param suffix [String] unqualified name, e.g. "Update"
|
|
498
|
+
# @return [Array<String>] every matching full identifier, sorted; empty
|
|
499
|
+
# when nothing matches
|
|
500
|
+
def find_all_by_suffix(suffix)
|
|
501
|
+
suffix_groups.fetch(suffix, []).dup
|
|
144
502
|
end
|
|
145
503
|
|
|
146
504
|
# Get direct dependencies of a unit
|
|
147
505
|
#
|
|
506
|
+
# Without `type:` this is the **union** across every type registered under
|
|
507
|
+
# the identifier. The caller is blast-radius computation, where a superset
|
|
508
|
+
# is safe and a subset silently under-extracts; for the identifiers that
|
|
509
|
+
# are not shared across types — all but a handful in any real index — the
|
|
510
|
+
# union is the same single list it always was.
|
|
511
|
+
#
|
|
148
512
|
# @param identifier [String] Unit identifier
|
|
149
513
|
# @param via [Symbol, Array<Symbol>, nil] Filter by relationship type(s)
|
|
514
|
+
# @param type [Symbol, nil] Restrict to one registered unit type
|
|
150
515
|
# @return [Array<String>] List of dependency identifiers
|
|
151
|
-
def dependencies_of(identifier, via: nil)
|
|
152
|
-
edges =
|
|
516
|
+
def dependencies_of(identifier, via: nil, type: nil)
|
|
517
|
+
edges = edges_for(identifier, type)
|
|
153
518
|
if via
|
|
154
519
|
via_set = Array(via)
|
|
155
520
|
edges = edges.select { |e| via_set.include?(e[:via]) }
|
|
@@ -157,6 +522,17 @@ module Woods
|
|
|
157
522
|
edges.map { |e| e[:target] }
|
|
158
523
|
end
|
|
159
524
|
|
|
525
|
+
# @param identifier [String]
|
|
526
|
+
# @param type [Symbol, nil] one registered type, or every one when nil
|
|
527
|
+
# @return [Array<Hash>] `{ target:, via: }` edges, in type-sorted order
|
|
528
|
+
def edges_for(identifier, type = nil)
|
|
529
|
+
by_type = @edges[identifier] || {}
|
|
530
|
+
return Array(by_type[type]) if type
|
|
531
|
+
|
|
532
|
+
by_type.sort_by { |t, _| [t ? 1 : 0, t.to_s] }.flat_map { |_, edges| edges }
|
|
533
|
+
end
|
|
534
|
+
private :edges_for
|
|
535
|
+
|
|
160
536
|
# Get direct dependents of a unit (what depends on it)
|
|
161
537
|
#
|
|
162
538
|
# @param identifier [String] Unit identifier
|
|
@@ -170,6 +546,28 @@ module Woods
|
|
|
170
546
|
end.to_a
|
|
171
547
|
end
|
|
172
548
|
|
|
549
|
+
# Dependents of a unit in the shape {Extractor#resolve_dependents} writes
|
|
550
|
+
# into unit JSON: `{ type:, identifier: }` per *edge*, not per source.
|
|
551
|
+
#
|
|
552
|
+
# Multiplicity is preserved deliberately — a source that references the
|
|
553
|
+
# same target twice (say once as `:belongs_to` and once as
|
|
554
|
+
# `:code_reference`) contributes two entries in a full extraction, so
|
|
555
|
+
# reconstructing this list incrementally has to do the same or the two
|
|
556
|
+
# paths produce different unit JSON. Order is registration order and is
|
|
557
|
+
# not guaranteed to match a full extraction's; callers comparing the two
|
|
558
|
+
# should compare as multisets.
|
|
559
|
+
#
|
|
560
|
+
# @param identifier [String] Unit identifier
|
|
561
|
+
# @return [Array<Hash>] `{ type: Symbol, identifier: String }` entries
|
|
562
|
+
def dependents_detail(identifier)
|
|
563
|
+
(@reverse[identifier] || []).flat_map do |source|
|
|
564
|
+
sorted_nodes(@nodes[source] || {}).flat_map do |type, node|
|
|
565
|
+
edge_count = Array(@edges[source]&.[](type)).count { |e| e[:target] == identifier }
|
|
566
|
+
Array.new(edge_count) { { type: node[:type], identifier: source } }
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
173
571
|
# Get all units of a specific type
|
|
174
572
|
#
|
|
175
573
|
# @param type [Symbol] Unit type (:model, :controller, etc.)
|
|
@@ -184,6 +582,15 @@ module Woods
|
|
|
184
582
|
# with many dependents gets a higher score. This matches Aider's insight
|
|
185
583
|
# that structural importance correlates with retrieval relevance.
|
|
186
584
|
#
|
|
585
|
+
# Mass is conserved (#205): a source's out-degree counts only edges whose
|
|
586
|
+
# target is a registered node, **with multiplicity**. Two edges to one
|
|
587
|
+
# target (`:belongs_to` + `:code_reference` is documented normal — see
|
|
588
|
+
# {#dependents_detail}) deliver twice the share, and edges to unregistered
|
|
589
|
+
# targets (gem or framework constants) neither dilute the distribution nor
|
|
590
|
+
# swallow the source's mass. A node whose every edge is unresolvable is
|
|
591
|
+
# dangling, exactly like a node with no edges at all — its rank
|
|
592
|
+
# redistributes uniformly instead of vanishing.
|
|
593
|
+
#
|
|
187
594
|
# @param damping [Float] Damping factor (default: 0.85)
|
|
188
595
|
# @param iterations [Integer] Number of iterations (default: 20)
|
|
189
596
|
# @return [Hash<String, Float>] Identifier => PageRank score
|
|
@@ -192,52 +599,400 @@ module Woods
|
|
|
192
599
|
return {} if n.zero?
|
|
193
600
|
|
|
194
601
|
node_ids = @nodes.keys
|
|
602
|
+
weights = resolvable_edge_weights
|
|
195
603
|
base_score = 1.0 / n
|
|
196
604
|
scores = node_ids.to_h { |id| [id, base_score] }
|
|
197
605
|
|
|
198
606
|
iterations.times do
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
@edges[id].nil? || @edges[id].empty? ? scores[id] : 0.0
|
|
202
|
-
end
|
|
607
|
+
scores = pagerank_step(node_ids, scores, weights, damping)
|
|
608
|
+
end
|
|
203
609
|
|
|
204
|
-
|
|
610
|
+
scores
|
|
611
|
+
end
|
|
205
612
|
|
|
206
|
-
|
|
207
|
-
# Sum contributions from nodes that depend on this one
|
|
208
|
-
incoming = @reverse[id] || []
|
|
209
|
-
rank_sum = incoming.sum do |src|
|
|
210
|
-
out_degree = (@edges[src] || []).size
|
|
211
|
-
out_degree.positive? ? scores[src] / out_degree : 0.0
|
|
212
|
-
end
|
|
613
|
+
private
|
|
213
614
|
|
|
214
|
-
|
|
615
|
+
# short name => every full identifier sharing it, for
|
|
616
|
+
# {#find_node_by_suffix} and {#find_all_by_suffix}.
|
|
617
|
+
#
|
|
618
|
+
# Only namespaced identifiers contribute — an unnamespaced `User` is found
|
|
619
|
+
# by {#node_exists?} before the suffix tier is reached. Each bucket is
|
|
620
|
+
# sorted so a short name claimed by several namespaces resolves to the
|
|
621
|
+
# same first candidate every time. Invalidated wherever `@to_h` is, since
|
|
622
|
+
# both derive from `@nodes`.
|
|
623
|
+
#
|
|
624
|
+
# @return [Hash{String => Array<String>}]
|
|
625
|
+
def suffix_groups
|
|
626
|
+
return @suffix_groups if @suffix_groups
|
|
627
|
+
|
|
628
|
+
groups = @nodes.keys.each_with_object({}) do |identifier, index|
|
|
629
|
+
short = identifier.split('::').last
|
|
630
|
+
next if short == identifier
|
|
631
|
+
|
|
632
|
+
(index[short] ||= []) << identifier
|
|
633
|
+
end
|
|
634
|
+
groups.each_value(&:sort!)
|
|
635
|
+
@suffix_groups = groups
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Node attributes a unit's metadata supplies at registration time.
|
|
639
|
+
#
|
|
640
|
+
# `table` is taken only from model units and `enforce_dependencies` only
|
|
641
|
+
# from package units, so an unrelated extractor that happens to use one
|
|
642
|
+
# of those metadata keys cannot leak into the graph.
|
|
643
|
+
#
|
|
644
|
+
# @param unit [ExtractedUnit]
|
|
645
|
+
# @return [Hash{Symbol => Object}] normalized, nil-free
|
|
646
|
+
def node_attributes_from(unit)
|
|
647
|
+
metadata = unit.respond_to?(:metadata) && unit.metadata.is_a?(Hash) ? unit.metadata : {}
|
|
648
|
+
attrs = {}
|
|
649
|
+
attrs[:database] = metadata[:database] unless metadata[:database].nil?
|
|
650
|
+
attrs[:table] = metadata[:table_name] if unit.type == :model && !metadata[:table_name].nil?
|
|
651
|
+
tables = Array(metadata[:foreign_keys]).filter_map { |fk| fk[:to_table] || fk['to_table'] if fk.is_a?(Hash) }
|
|
652
|
+
attrs[:foreign_key_tables] = tables if tables.any?
|
|
653
|
+
attrs[:package] = metadata[:package] unless metadata[:package].nil?
|
|
654
|
+
if unit.type == :package && !metadata[:enforce_dependencies].nil?
|
|
655
|
+
attrs[:enforce_dependencies] = metadata[:enforce_dependencies]
|
|
656
|
+
end
|
|
657
|
+
git = metadata[:git]
|
|
658
|
+
if git.is_a?(Hash)
|
|
659
|
+
attrs[:commit_count] = git[:commit_count] unless git[:commit_count].nil?
|
|
660
|
+
attrs[:change_frequency] = git[:change_frequency] unless git[:change_frequency].nil?
|
|
661
|
+
end
|
|
662
|
+
attrs.to_h { |key, value| [key, self.class.normalize_node_attribute(key, value)] }
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# One PageRank power iteration over precomputed resolvable-edge weights.
|
|
666
|
+
#
|
|
667
|
+
# Iteration order (node insertion order, reverse-Set insertion order) is
|
|
668
|
+
# the same stable order the pre-#205 implementation used, so two runs
|
|
669
|
+
# over the same graph produce identical floats.
|
|
670
|
+
#
|
|
671
|
+
# @param node_ids [Array<String>] All registered identifiers
|
|
672
|
+
# @param scores [Hash{String => Float}] Scores from the previous iteration
|
|
673
|
+
# @param weights [Hash{String => Array(Hash, Integer)}] See {#resolvable_edge_weights}
|
|
674
|
+
# @param damping [Float] Damping factor
|
|
675
|
+
# @return [Hash{String => Float}] Next iteration's scores
|
|
676
|
+
def pagerank_step(node_ids, scores, weights, damping)
|
|
677
|
+
n = node_ids.size
|
|
678
|
+
# Collect rank from dangling nodes (no *resolvable* outgoing edges) and redistribute
|
|
679
|
+
dangling_sum = node_ids.sum { |id| weights.key?(id) ? 0.0 : scores[id] }
|
|
680
|
+
|
|
681
|
+
node_ids.to_h do |id|
|
|
682
|
+
# Sum contributions from nodes that depend on this one
|
|
683
|
+
rank_sum = (@reverse[id] || []).sum do |src|
|
|
684
|
+
counts, out_degree = weights[src]
|
|
685
|
+
multiplicity = counts ? counts[id] : 0
|
|
686
|
+
# `fetch(src, 0.0)`: {.from_h} accepts (and {#registered_types}
|
|
687
|
+
# relies on) a hash carrying edges for an identifier it has no node
|
|
688
|
+
# for. Such a source is absent from +node_ids+, so it holds no rank
|
|
689
|
+
# to give — `scores[src]` was nil and crashed the refresh (EXTB-10).
|
|
690
|
+
multiplicity.positive? ? scores.fetch(src, 0.0) * multiplicity / out_degree : 0.0
|
|
215
691
|
end
|
|
216
692
|
|
|
217
|
-
|
|
693
|
+
[id, ((1.0 - damping) / n) + (damping * (rank_sum + (dangling_sum / n)))]
|
|
218
694
|
end
|
|
695
|
+
end
|
|
219
696
|
|
|
220
|
-
|
|
697
|
+
# Per-source PageRank link weights over **resolvable** edges only — edges
|
|
698
|
+
# whose target is a registered node. Built once per {#pagerank} run in
|
|
699
|
+
# O(edges), so the per-iteration loops stay O(nodes + reverse edges).
|
|
700
|
+
#
|
|
701
|
+
# Each entry is `[target => multiplicity, resolvable out-degree]`; the
|
|
702
|
+
# out-degree is the multiplicity sum, so a duplicated edge weighs double
|
|
703
|
+
# rather than leaking. Sources with zero resolvable edges are absent,
|
|
704
|
+
# which is what marks them dangling in {#pagerank_step}.
|
|
705
|
+
#
|
|
706
|
+
# @return [Hash{String => Array(Hash{String => Integer}, Integer)}]
|
|
707
|
+
def resolvable_edge_weights
|
|
708
|
+
@edges.each_with_object({}) do |(src, by_type), weights|
|
|
709
|
+
counts = nil
|
|
710
|
+
by_type.each_value do |edges|
|
|
711
|
+
edges.each do |edge|
|
|
712
|
+
next unless @nodes.key?(edge[:target])
|
|
713
|
+
|
|
714
|
+
(counts ||= Hash.new(0))[edge[:target]] += 1
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
weights[src] = [counts, counts.each_value.sum] if counts
|
|
719
|
+
end
|
|
221
720
|
end
|
|
222
721
|
|
|
722
|
+
public
|
|
723
|
+
|
|
223
724
|
# Serialize graph for persistence. Memoized — cache is invalidated on register.
|
|
224
725
|
# Returns a dup so callers can't pollute the cached hash.
|
|
225
726
|
#
|
|
727
|
+
# ## Wire format and the `variants` key (#225)
|
|
728
|
+
#
|
|
729
|
+
# `nodes` and `edges` stay keyed on the bare identifier, holding the
|
|
730
|
+
# **primary** node — the type that sorts first. Identifiers registered
|
|
731
|
+
# under more than one type put their remaining nodes in `variants`, a flat
|
|
732
|
+
# array that is **omitted entirely when empty**. So the serialized form of
|
|
733
|
+
# a graph with no collisions is byte-for-byte what it has always been, and
|
|
734
|
+
# a `dependency_graph.json` written before this change loads through
|
|
735
|
+
# {.from_h} unmodified: it simply carries no `variants`.
|
|
736
|
+
#
|
|
737
|
+
# `reverse`, `file_map` and `type_index` need no new shape. They are
|
|
738
|
+
# already identifier-valued sets, so a Scenic view `reports` and a factory
|
|
739
|
+
# `reports` each contribute to their own type bucket and their own path.
|
|
740
|
+
#
|
|
226
741
|
# @return [Hash] Complete graph data
|
|
227
742
|
def to_h
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
743
|
+
root = self.class.graph_root
|
|
744
|
+
@to_h ||= begin
|
|
745
|
+
variants = self.class.relativize_variants(variant_records, root)
|
|
746
|
+
base = {
|
|
747
|
+
nodes: key_sorted(self.class.relativize_nodes(primary_nodes, root)),
|
|
748
|
+
edges: key_sorted(primary_edges),
|
|
749
|
+
reverse: key_sorted(@reverse.transform_values { |ids| ids.to_a.sort }),
|
|
750
|
+
file_map: key_sorted(self.class.relativize_file_map(@file_map, root)),
|
|
751
|
+
type_index: key_sorted(@type_index.transform_values { |ids| ids.to_a.sort }),
|
|
752
|
+
stats: {
|
|
753
|
+
node_count: @nodes.each_value.sum(&:size),
|
|
754
|
+
edge_count: @edges.each_value.sum { |by_type| by_type.each_value.sum(&:size) },
|
|
755
|
+
types: key_sorted(@type_index.transform_values(&:size))
|
|
756
|
+
}
|
|
238
757
|
}
|
|
239
|
-
|
|
240
|
-
|
|
758
|
+
variants.empty? ? base : base.merge(variants: variants)
|
|
759
|
+
end
|
|
760
|
+
detached_snapshot(@to_h)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# A hash rebuilt in key order, so serialization is a function of the
|
|
764
|
+
# graph's content and not of the order its units happened to register.
|
|
765
|
+
#
|
|
766
|
+
# @param hash [Hash] keyed by identifier, path, or unit type
|
|
767
|
+
# @return [Hash] the same pairs, key-sorted
|
|
768
|
+
def key_sorted(hash)
|
|
769
|
+
hash.sort_by { |key, _| key.to_s }.to_h
|
|
770
|
+
end
|
|
771
|
+
private :key_sorted
|
|
772
|
+
|
|
773
|
+
# A copy whose edge containers are the caller's alone.
|
|
774
|
+
#
|
|
775
|
+
# `dup` copies only the top level, so `to_h[:edges][id]` used to be the
|
|
776
|
+
# very Array `@edges` holds: appending to it polluted the live graph and
|
|
777
|
+
# the memo, both silently (EXTB-11). {#primary_edges} and
|
|
778
|
+
# {#variant_records} already detach from the graph; this detaches each
|
|
779
|
+
# handed-out snapshot from the memo as well.
|
|
780
|
+
#
|
|
781
|
+
# @param memo [Hash] the memoized serialization
|
|
782
|
+
# @return [Hash]
|
|
783
|
+
def detached_snapshot(memo)
|
|
784
|
+
snapshot = memo.dup
|
|
785
|
+
snapshot[:edges] = memo[:edges].transform_values { |list| list.map(&:dup) }
|
|
786
|
+
if memo.key?(:variants)
|
|
787
|
+
snapshot[:variants] = memo[:variants].map do |record|
|
|
788
|
+
record.merge(edges: record[:edges].map(&:dup))
|
|
789
|
+
end
|
|
790
|
+
end
|
|
791
|
+
snapshot
|
|
792
|
+
end
|
|
793
|
+
private :detached_snapshot
|
|
794
|
+
|
|
795
|
+
# @return [Hash{String => Hash}] identifier => primary node
|
|
796
|
+
def primary_nodes
|
|
797
|
+
@nodes.transform_values { |nodes| primary_of(nodes) }
|
|
798
|
+
end
|
|
799
|
+
private :primary_nodes
|
|
800
|
+
|
|
801
|
+
# Edge arrays are copied element-wise: {#to_h}'s `dup` is shallow, so
|
|
802
|
+
# handing back the live Arrays let a caller append straight into `@edges`
|
|
803
|
+
# and change what {#dependencies_of} answers (EXTB-11).
|
|
804
|
+
#
|
|
805
|
+
# @return [Hash{String => Array<Hash>}] identifier => primary node's edges
|
|
806
|
+
def primary_edges
|
|
807
|
+
@edges.each_with_object({}) do |(identifier, by_type), edges|
|
|
808
|
+
primary_type = primary_type_for(identifier, by_type)
|
|
809
|
+
edges[identifier] = by_type.fetch(primary_type, []).map(&:dup)
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
private :primary_edges
|
|
813
|
+
|
|
814
|
+
# Every non-primary node, flattened for serialization. Each record carries
|
|
815
|
+
# its own edges, so a variant round-trips whole.
|
|
816
|
+
#
|
|
817
|
+
# @return [Array<Hash>] sorted by identifier then type, so two extractions
|
|
818
|
+
# of one tree serialize identically
|
|
819
|
+
def variant_records
|
|
820
|
+
records = @nodes.flat_map do |identifier, nodes|
|
|
821
|
+
sorted_nodes(nodes).drop(1).map do |type, node|
|
|
822
|
+
{
|
|
823
|
+
identifier: identifier,
|
|
824
|
+
type: type,
|
|
825
|
+
file_path: node[:file_path],
|
|
826
|
+
namespace: node[:namespace]
|
|
827
|
+
}.merge(node.slice(*NODE_ATTRIBUTE_KEYS)).merge(
|
|
828
|
+
# Copied like {#primary_edges} — see EXTB-11.
|
|
829
|
+
edges: (@edges[identifier]&.[](type) || []).map(&:dup)
|
|
830
|
+
)
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
records.sort_by { |record| [record[:identifier], record[:type].to_s] }
|
|
834
|
+
end
|
|
835
|
+
private :variant_records
|
|
836
|
+
|
|
837
|
+
# The type whose node is primary for an identifier. Falls back to the
|
|
838
|
+
# edges' own first type when there is no node — {.from_h} accepts a hash
|
|
839
|
+
# carrying edges for an identifier it has no node for.
|
|
840
|
+
#
|
|
841
|
+
# @param identifier [String]
|
|
842
|
+
# @param by_type [Hash{Symbol => Array<Hash>}] that identifier's edges
|
|
843
|
+
# @return [Symbol, nil]
|
|
844
|
+
def primary_type_for(identifier, by_type)
|
|
845
|
+
nodes = @nodes[identifier]
|
|
846
|
+
return sorted_nodes(nodes).first&.first if nodes&.any?
|
|
847
|
+
|
|
848
|
+
by_type.keys.first
|
|
849
|
+
end
|
|
850
|
+
private :primary_type_for
|
|
851
|
+
|
|
852
|
+
# ── Path portability (#166) ────────────────────────────────────────────
|
|
853
|
+
#
|
|
854
|
+
# The graph holds **absolute** paths in memory, because every consumer of
|
|
855
|
+
# them resolves against the filesystem in the extracting process:
|
|
856
|
+
# `re_extract_unit` and `incremental_git_data` both gate on `File.exist?`,
|
|
857
|
+
# and `affected_by` / `identifiers_for_path` are handed absolute paths by
|
|
858
|
+
# `ChangeSet`. Persisting them absolute made the artifact non-portable —
|
|
859
|
+
# extraction in a container writes `/app/...` while a host reading the
|
|
860
|
+
# volume mount sees `/Users/.../woods/...`, so a host-run `woods:incremental`
|
|
861
|
+
# computed an empty blast radius and silently re-extracted nothing.
|
|
862
|
+
#
|
|
863
|
+
# So paths are relativized on the way out and absolutized on the way back.
|
|
864
|
+
# Nothing on the read side is affected: no MCP tool, exporter or formatter
|
|
865
|
+
# reads a graph node's `file_path` — they all read the per-unit JSON, which
|
|
866
|
+
# has always been relative.
|
|
867
|
+
#
|
|
868
|
+
# Absolutizing is idempotent for a path that is already absolute, which is
|
|
869
|
+
# what lets a graph persisted before this load without a re-index — the same
|
|
870
|
+
# approach {normalize_edges} and {normalize_file_map} take for their own
|
|
871
|
+
# legacy formats.
|
|
872
|
+
|
|
873
|
+
# @return [String, nil] the extraction root, or nil outside a Rails process
|
|
874
|
+
# (a host-side reader), in which case paths pass through untouched
|
|
875
|
+
def self.graph_root
|
|
876
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
877
|
+
|
|
878
|
+
Rails.root.to_s
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# @return [String, nil] path relative to root, or unchanged when it is nil,
|
|
882
|
+
# root is unknown, or it lives outside the tree (a gem path)
|
|
883
|
+
def self.relativize(path, root)
|
|
884
|
+
return path if path.nil? || root.nil?
|
|
885
|
+
|
|
886
|
+
prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
|
|
887
|
+
path.start_with?(prefix) ? path.delete_prefix(prefix) : path
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
# @return [String, nil] absolute path, or unchanged when it is nil, root is
|
|
891
|
+
# unknown, or it is already absolute (a pre-#166 graph, or a gem path)
|
|
892
|
+
def self.absolutize(path, root)
|
|
893
|
+
return path if path.nil? || root.nil?
|
|
894
|
+
return path if path.start_with?(File::SEPARATOR)
|
|
895
|
+
|
|
896
|
+
File.join(root, path)
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def self.relativize_nodes(nodes, root)
|
|
900
|
+
return nodes if root.nil?
|
|
901
|
+
|
|
902
|
+
nodes.transform_values do |node|
|
|
903
|
+
node[:file_path] ? node.merge(file_path: relativize(node[:file_path], root)) : node
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def self.absolutize_nodes(nodes, root)
|
|
908
|
+
return nodes if root.nil?
|
|
909
|
+
|
|
910
|
+
nodes.transform_values do |node|
|
|
911
|
+
node[:file_path] ? node.merge(file_path: absolutize(node[:file_path], root)) : node
|
|
912
|
+
end
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Variant records carry a `file_path` like any other node, so they move
|
|
916
|
+
# with the same rules — relative on the way out, absolute on the way back.
|
|
917
|
+
def self.relativize_variants(variants, root)
|
|
918
|
+
return variants if root.nil?
|
|
919
|
+
|
|
920
|
+
variants.map do |record|
|
|
921
|
+
record[:file_path] ? record.merge(file_path: relativize(record[:file_path], root)) : record
|
|
922
|
+
end
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# Serialization form: **sorted arrays**, matching what `to_h` has always
|
|
926
|
+
# emitted, minus the insertion order.
|
|
927
|
+
def self.relativize_file_map(file_map, root)
|
|
928
|
+
relocate_file_map(file_map) { |path| relativize(path, root) }
|
|
929
|
+
.transform_values { |ids| ids.to_a.sort }
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# In-memory form: **Sets**, which is the `@file_map` contract every path
|
|
933
|
+
# lookup depends on. Returning arrays here would break `#unregister`, which
|
|
934
|
+
# calls `Set#delete` on the value.
|
|
935
|
+
def self.absolutize_file_map(file_map, root)
|
|
936
|
+
relocate_file_map(file_map) { |path| absolutize(path, root) }
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
# Rekey a file map, merging rather than overwriting: two keys can collapse
|
|
940
|
+
# onto one after transformation — a path inside the root and its
|
|
941
|
+
# already-relative twin from a pre-#166 graph — and the identifiers of both
|
|
942
|
+
# belong to the survivor. Values come back as Sets; callers that need the
|
|
943
|
+
# serialized shape convert.
|
|
944
|
+
def self.relocate_file_map(file_map)
|
|
945
|
+
file_map.each_with_object({}) do |(path, ids), moved|
|
|
946
|
+
key = yield(path)
|
|
947
|
+
(moved[key] ||= Set.new).merge(ids.is_a?(Set) ? ids : Array(ids))
|
|
948
|
+
end
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# Canonical value for a node attribute, applied on registration, on
|
|
952
|
+
# {#annotate}, and on load, so both extraction paths and a JSON round
|
|
953
|
+
# trip agree.
|
|
954
|
+
#
|
|
955
|
+
# @param key [Symbol] one of {NODE_ATTRIBUTE_KEYS}
|
|
956
|
+
# @param value [Object]
|
|
957
|
+
# @return [Object]
|
|
958
|
+
def self.normalize_node_attribute(key, value)
|
|
959
|
+
case key
|
|
960
|
+
when :foreign_key_tables then Array(value).map(&:to_s).uniq.sort
|
|
961
|
+
when :commit_count then value.to_i
|
|
962
|
+
when :enforce_dependencies then [true, false].include?(value) ? value : value.to_s
|
|
963
|
+
else value.to_s
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# Node attributes present in a persisted node hash (string or symbol keys).
|
|
968
|
+
#
|
|
969
|
+
# @param node [Hash]
|
|
970
|
+
# @return [Hash{Symbol => Object}]
|
|
971
|
+
def self.persisted_node_attributes(node)
|
|
972
|
+
NODE_ATTRIBUTE_KEYS.each_with_object({}) do |key, attrs|
|
|
973
|
+
value = node.key?(key) ? node[key] : node[key.to_s]
|
|
974
|
+
attrs[key] = normalize_node_attribute(key, value) unless value.nil?
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
# Edge attributes present in a dependency or persisted edge hash.
|
|
979
|
+
#
|
|
980
|
+
# Driven off {EDGE_ATTRIBUTE_KEYS} so a new edge attribute needs adding to
|
|
981
|
+
# only that list, not a second hand-written branch here.
|
|
982
|
+
#
|
|
983
|
+
# @param dep [Hash] string or symbol keys
|
|
984
|
+
# @return [Hash{Symbol => Object}] empty when the edge carries none
|
|
985
|
+
def self.edge_attributes(dep)
|
|
986
|
+
EDGE_ATTRIBUTE_KEYS.each_with_object({}) do |key, attrs|
|
|
987
|
+
value = dep.key?(key) ? dep[key] : dep[key.to_s]
|
|
988
|
+
next if value.nil?
|
|
989
|
+
|
|
990
|
+
if key == :disable_joins
|
|
991
|
+
attrs[key] = true if value == true
|
|
992
|
+
else
|
|
993
|
+
attrs[key] = value.to_s unless value.to_s.empty?
|
|
994
|
+
end
|
|
995
|
+
end
|
|
241
996
|
end
|
|
242
997
|
|
|
243
998
|
# Load graph from persisted data
|
|
@@ -246,21 +1001,41 @@ module Woods
|
|
|
246
1001
|
# them back to the expected types: node values use symbol keys (:type,
|
|
247
1002
|
# :file_path, :namespace), and type_index uses symbol keys for types.
|
|
248
1003
|
#
|
|
1004
|
+
# The root is resolved ambiently rather than accepted as a keyword. Adding a
|
|
1005
|
+
# `root:` kwarg here silently breaks every caller that passes a bare hash
|
|
1006
|
+
# literal — `from_h('nodes' => ..., 'file_map' => ...)` binds as *keywords*
|
|
1007
|
+
# once the method accepts any, so `data` arrives empty and the call raises
|
|
1008
|
+
# ArgumentError. The specs in this file call it exactly that way.
|
|
1009
|
+
#
|
|
249
1010
|
# @param data [Hash] Previously serialized graph data
|
|
250
1011
|
# @return [DependencyGraph] Restored graph
|
|
251
1012
|
def self.from_h(data)
|
|
252
1013
|
graph = new
|
|
253
1014
|
|
|
1015
|
+
root = graph_root
|
|
1016
|
+
|
|
254
1017
|
raw_nodes = data[:nodes] || data['nodes'] || {}
|
|
255
|
-
|
|
1018
|
+
flat_nodes = absolutize_nodes(raw_nodes.transform_values { |v| symbolize_node(v) }, root)
|
|
1019
|
+
nodes = flat_nodes.transform_values { |node| { node[:type] => node } }
|
|
256
1020
|
|
|
257
1021
|
raw_edges = data[:edges] || data['edges'] || {}
|
|
258
|
-
|
|
1022
|
+
edges = raw_edges.each_with_object({}) do |(identifier, list), by_identifier|
|
|
1023
|
+
# A hash carrying edges for an identifier it has no node for keys them
|
|
1024
|
+
# under nil — the same type `symbolize_node` would have produced.
|
|
1025
|
+
type = nodes[identifier]&.keys&.first
|
|
1026
|
+
by_identifier[identifier] = { type => normalize_edges(list) }
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
merge_variants(data[:variants] || data['variants'], nodes, edges, root)
|
|
1030
|
+
|
|
1031
|
+
graph.instance_variable_set(:@nodes, nodes)
|
|
1032
|
+
graph.instance_variable_set(:@edges, edges)
|
|
259
1033
|
|
|
260
1034
|
raw_reverse = data[:reverse] || data['reverse'] || {}
|
|
261
1035
|
graph.instance_variable_set(:@reverse, raw_reverse.transform_values { |v| v.is_a?(Set) ? v : Set.new(v) })
|
|
262
1036
|
|
|
263
|
-
|
|
1037
|
+
raw_file_map = data[:file_map] || data['file_map'] || {}
|
|
1038
|
+
graph.instance_variable_set(:@file_map, absolutize_file_map(normalize_file_map(raw_file_map), root))
|
|
264
1039
|
|
|
265
1040
|
raw_type_index = data[:type_index] || data['type_index'] || {}
|
|
266
1041
|
graph.instance_variable_set(:@type_index, raw_type_index.transform_keys(&:to_sym).transform_values do |v|
|
|
@@ -269,9 +1044,11 @@ module Woods
|
|
|
269
1044
|
|
|
270
1045
|
# Rebuild reverse_via index from edges
|
|
271
1046
|
reverse_via = {}
|
|
272
|
-
graph.instance_variable_get(:@edges).each do |source_id,
|
|
273
|
-
|
|
274
|
-
|
|
1047
|
+
graph.instance_variable_get(:@edges).each do |source_id, by_type|
|
|
1048
|
+
by_type.each_value do |edge_list|
|
|
1049
|
+
edge_list.each do |edge|
|
|
1050
|
+
(reverse_via[[edge[:target], edge[:via]]] ||= Set.new).add(source_id)
|
|
1051
|
+
end
|
|
275
1052
|
end
|
|
276
1053
|
end
|
|
277
1054
|
graph.instance_variable_set(:@reverse_via, reverse_via)
|
|
@@ -279,6 +1056,62 @@ module Woods
|
|
|
279
1056
|
graph
|
|
280
1057
|
end
|
|
281
1058
|
|
|
1059
|
+
# Fold the `variants` section back into the nested node/edge hashes.
|
|
1060
|
+
#
|
|
1061
|
+
# Absent (every graph written before #225, and every graph with no
|
|
1062
|
+
# identifier shared across types) this is a no-op, which is what makes the
|
|
1063
|
+
# format backward compatible without a migration.
|
|
1064
|
+
#
|
|
1065
|
+
# @param raw [Array<Hash>, nil] the persisted `variants` array
|
|
1066
|
+
# @param nodes [Hash] identifier => { type => node }, mutated in place
|
|
1067
|
+
# @param edges [Hash] identifier => { type => edges }, mutated in place
|
|
1068
|
+
# @param root [String, nil] extraction root for path absolutizing
|
|
1069
|
+
# @return [void]
|
|
1070
|
+
def self.merge_variants(raw, nodes, edges, root)
|
|
1071
|
+
return unless raw.is_a?(Array)
|
|
1072
|
+
|
|
1073
|
+
raw.each do |record|
|
|
1074
|
+
next unless record.is_a?(Hash)
|
|
1075
|
+
|
|
1076
|
+
identifier = record[:identifier] || record['identifier']
|
|
1077
|
+
type = (record[:type] || record['type'])&.to_sym
|
|
1078
|
+
next if identifier.nil? || type.nil?
|
|
1079
|
+
|
|
1080
|
+
(nodes[identifier] ||= {})[type] = {
|
|
1081
|
+
type: type,
|
|
1082
|
+
file_path: absolutize(record[:file_path] || record['file_path'], root),
|
|
1083
|
+
namespace: record[:namespace] || record['namespace']
|
|
1084
|
+
}.merge(persisted_node_attributes(record))
|
|
1085
|
+
(edges[identifier] ||= {})[type] = normalize_edges(record[:edges] || record['edges'])
|
|
1086
|
+
end
|
|
1087
|
+
end
|
|
1088
|
+
private_class_method :merge_variants
|
|
1089
|
+
|
|
1090
|
+
# Normalize a persisted file map to `path => Set<identifier>`.
|
|
1091
|
+
#
|
|
1092
|
+
# Graphs written before the multi-valued migration stored a single
|
|
1093
|
+
# identifier per path (`{"app/models/user.rb" => "User"}`), which silently
|
|
1094
|
+
# lost every unit but the last for files defining several units (a `.rake`
|
|
1095
|
+
# file with multiple tasks, an i18n YAML, a lib file with several classes).
|
|
1096
|
+
# Old graphs load without conversion — the bare string is wrapped — so the
|
|
1097
|
+
# first incremental run after upgrading re-widens the map as it
|
|
1098
|
+
# re-registers units.
|
|
1099
|
+
#
|
|
1100
|
+
# @param raw [Hash] Persisted file_map (values are String, Array, or Set)
|
|
1101
|
+
# @return [Hash{String => Set<String>}]
|
|
1102
|
+
def self.normalize_file_map(raw)
|
|
1103
|
+
return {} unless raw.is_a?(Hash)
|
|
1104
|
+
|
|
1105
|
+
raw.each_with_object({}) do |(path, ids), map|
|
|
1106
|
+
map[path] = case ids
|
|
1107
|
+
when Set then ids
|
|
1108
|
+
when Array then Set.new(ids)
|
|
1109
|
+
when nil then Set.new
|
|
1110
|
+
else Set.new([ids])
|
|
1111
|
+
end
|
|
1112
|
+
end
|
|
1113
|
+
end
|
|
1114
|
+
|
|
282
1115
|
# Normalize a node hash to use symbol keys
|
|
283
1116
|
#
|
|
284
1117
|
# @param node [Hash] Node data with string or symbol keys
|
|
@@ -290,7 +1123,7 @@ module Woods
|
|
|
290
1123
|
type: (node[:type] || node['type'])&.to_sym,
|
|
291
1124
|
file_path: node[:file_path] || node['file_path'],
|
|
292
1125
|
namespace: node[:namespace] || node['namespace']
|
|
293
|
-
}
|
|
1126
|
+
}.merge(persisted_node_attributes(node))
|
|
294
1127
|
end
|
|
295
1128
|
|
|
296
1129
|
# Normalize edge data from either old format (bare strings) or new format (hashes).
|
|
@@ -323,6 +1156,7 @@ module Woods
|
|
|
323
1156
|
{ target: edge, via: nil }
|
|
324
1157
|
elsif edge.is_a?(Hash)
|
|
325
1158
|
{ target: edge[:target] || edge['target'], via: (edge[:via] || edge['via'])&.to_sym }
|
|
1159
|
+
.merge(edge_attributes(edge))
|
|
326
1160
|
else
|
|
327
1161
|
{ target: edge.to_s, via: nil }
|
|
328
1162
|
end
|