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.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. 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 => identifier
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 an identifier (incremental extraction registers into a
37
- # graph loaded from disk) first removes the previous registration's
38
- # reverse edges, file-map entry, and type-index entry — otherwise stale
39
- # dependents accumulate across incremental runs and get persisted back
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.identifier)
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] = unit.dependencies.map { |d| { target: d[:target], via: d[:via] } }
55
- @file_map[unit.file_path] = unit.identifier if unit.file_path
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 an identifier's registration side effects: its contribution to
68
- # the reverse indexes (derived from its recorded forward edges), its
69
- # file-map entry, and its type-index entry. Forward node/edge data is
70
- # overwritten by the caller (register), so it is not cleared here.
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 unregister(identifier)
75
- (@edges[identifier] || []).each do |edge|
76
- if (set = @reverse[edge[:target]])
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
- old_node = @nodes[identifier]
89
- return unless old_node
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
- old_path = old_node[:file_path]
92
- @file_map.delete(old_path) if old_path && @file_map[old_path] == identifier
93
- @type_index[old_node[:type]]&.delete(identifier)
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.filter_map { |f| @file_map[f] }
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
- target_suffix = "::#{suffix}"
143
- @nodes.keys.find { |id| id.end_with?(target_suffix) }
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 = @edges[identifier] || []
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
- # Collect rank from dangling nodes (no outgoing edges) and redistribute
200
- dangling_sum = node_ids.sum do |id|
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
- new_scores = {}
610
+ scores
611
+ end
205
612
 
206
- node_ids.each do |id|
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
- new_scores[id] = ((1.0 - damping) / n) + (damping * (rank_sum + (dangling_sum / n)))
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
- scores = new_scores
693
+ [id, ((1.0 - damping) / n) + (damping * (rank_sum + (dangling_sum / n)))]
218
694
  end
695
+ end
219
696
 
220
- scores
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
- @to_h ||= {
229
- nodes: @nodes,
230
- edges: @edges,
231
- reverse: @reverse.transform_values(&:to_a),
232
- file_map: @file_map,
233
- type_index: @type_index.transform_values(&:to_a),
234
- stats: {
235
- node_count: @nodes.size,
236
- edge_count: @edges.values.sum(&:size),
237
- types: @type_index.transform_values(&:size)
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
- @to_h.dup
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
- graph.instance_variable_set(:@nodes, raw_nodes.transform_values { |v| symbolize_node(v) })
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
- graph.instance_variable_set(:@edges, raw_edges.transform_values { |edges| normalize_edges(edges) })
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
- graph.instance_variable_set(:@file_map, data[:file_map] || data['file_map'] || {})
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, edges|
273
- edges.each do |edge|
274
- (reverse_via[[edge[:target], edge[:via]]] ||= Set.new).add(source_id)
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