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