woods 1.6.4 → 2.0.0.beta1

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