woods 2.0.0.beta1 → 2.0.0.beta3

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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +400 -1
  3. data/CONTRIBUTING.md +224 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +233 -13
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +158 -2
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +15 -7
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +71 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/atomic_file.rb +133 -3
  56. data/lib/woods/builder.rb +21 -5
  57. data/lib/woods/cache/cache_middleware.rb +28 -7
  58. data/lib/woods/cache/cache_store.rb +4 -5
  59. data/lib/woods/change_set.rb +5 -4
  60. data/lib/woods/console/credential_index.rb +20 -2
  61. data/lib/woods/console/credential_scanner.rb +14 -14
  62. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  63. data/lib/woods/console/embedded_executor.rb +1 -1
  64. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  65. data/lib/woods/console/rack_middleware.rb +22 -13
  66. data/lib/woods/console/server.rb +18 -16
  67. data/lib/woods/dependency_graph.rb +65 -13
  68. data/lib/woods/embedding/corpus.rb +94 -0
  69. data/lib/woods/embedding/indexer.rb +90 -46
  70. data/lib/woods/embedding/openai.rb +17 -6
  71. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  72. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  73. data/lib/woods/export/typed_reader.rb +56 -0
  74. data/lib/woods/extractor.rb +557 -228
  75. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  76. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  77. data/lib/woods/extractors/caching_extractor.rb +3 -1
  78. data/lib/woods/extractors/concern_extractor.rb +64 -6
  79. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  80. data/lib/woods/extractors/controller_extractor.rb +13 -4
  81. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  82. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  83. data/lib/woods/extractors/engine_extractor.rb +3 -1
  84. data/lib/woods/extractors/event_extractor.rb +4 -2
  85. data/lib/woods/extractors/factory_extractor.rb +3 -1
  86. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  87. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  88. data/lib/woods/extractors/job_extractor.rb +6 -19
  89. data/lib/woods/extractors/lib_extractor.rb +3 -1
  90. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  91. data/lib/woods/extractors/manager_extractor.rb +3 -1
  92. data/lib/woods/extractors/method_parameters.rb +53 -0
  93. data/lib/woods/extractors/middleware_argument.rb +65 -0
  94. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  95. data/lib/woods/extractors/migration_extractor.rb +3 -1
  96. data/lib/woods/extractors/model_extractor.rb +39 -33
  97. data/lib/woods/extractors/package_extractor.rb +24 -4
  98. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  99. data/lib/woods/extractors/policy_extractor.rb +3 -1
  100. data/lib/woods/extractors/poro_extractor.rb +3 -1
  101. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  102. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  103. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  104. data/lib/woods/extractors/route_extractor.rb +3 -1
  105. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  106. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  107. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  108. data/lib/woods/extractors/service_extractor.rb +3 -1
  109. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  110. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  111. data/lib/woods/extractors/source_nesting.rb +1 -1
  112. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  113. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  114. data/lib/woods/extractors/validator_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  116. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  117. data/lib/woods/flow_assembler.rb +87 -8
  118. data/lib/woods/flow_precomputer.rb +44 -7
  119. data/lib/woods/gem_mapper.rb +2 -0
  120. data/lib/woods/git_history.rb +116 -0
  121. data/lib/woods/graph_analyzer.rb +195 -63
  122. data/lib/woods/hooks/context_cli.rb +54 -0
  123. data/lib/woods/hooks/context_event.rb +88 -0
  124. data/lib/woods/hooks/context_hint.rb +73 -0
  125. data/lib/woods/hooks/context_impact.rb +77 -0
  126. data/lib/woods/hooks/context_output.rb +47 -0
  127. data/lib/woods/hooks/context_state.rb +102 -0
  128. data/lib/woods/hooks/refresh.rb +79 -0
  129. data/lib/woods/hooks/rule_projection.rb +78 -0
  130. data/lib/woods/input_rules.rb +19 -0
  131. data/lib/woods/mcp/bearer_auth.rb +20 -12
  132. data/lib/woods/mcp/bootstrapper.rb +62 -0
  133. data/lib/woods/mcp/index_reader.rb +323 -160
  134. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  135. data/lib/woods/mcp/origin_guard.rb +17 -9
  136. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  137. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  138. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  139. data/lib/woods/mcp/search_results.rb +74 -0
  140. data/lib/woods/mcp/server.rb +158 -37
  141. data/lib/woods/mcp/tool_contract.rb +2 -0
  142. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  143. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  144. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  145. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  146. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  147. data/lib/woods/notion/exporter.rb +56 -17
  148. data/lib/woods/obsidian/destination_plan.rb +98 -0
  149. data/lib/woods/obsidian/name_mapper.rb +19 -3
  150. data/lib/woods/obsidian/note_builder.rb +19 -10
  151. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  152. data/lib/woods/operator/pipeline_guard.rb +18 -13
  153. data/lib/woods/path_dispatcher.rb +7 -1
  154. data/lib/woods/payload_store.rb +29 -15
  155. data/lib/woods/railtie.rb +3 -3
  156. data/lib/woods/railtie_support.rb +12 -12
  157. data/lib/woods/rake_helpers.rb +392 -0
  158. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  159. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  160. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  161. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  162. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  163. data/lib/woods/resilience/index_validator.rb +112 -23
  164. data/lib/woods/retrieval/context_assembler.rb +50 -15
  165. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  166. data/lib/woods/retrieval/lexical_index.rb +119 -0
  167. data/lib/woods/retrieval/ranker.rb +4 -2
  168. data/lib/woods/retrieval/scope.rb +108 -0
  169. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  170. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  171. data/lib/woods/retrieval/search_executor.rb +86 -27
  172. data/lib/woods/retrieval/source_evidence.rb +200 -0
  173. data/lib/woods/retriever.rb +98 -22
  174. data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
  175. data/lib/woods/session_tracer/middleware.rb +10 -12
  176. data/lib/woods/session_tracer/redis_store.rb +22 -6
  177. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  178. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  179. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  180. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  181. data/lib/woods/source_inputs/handoff.rb +102 -0
  182. data/lib/woods/source_inputs/launcher.rb +157 -0
  183. data/lib/woods/source_inputs/manifest.rb +124 -0
  184. data/lib/woods/source_inputs/private_key.rb +55 -0
  185. data/lib/woods/source_inputs/scanner.rb +171 -0
  186. data/lib/woods/source_inputs/scopes.rb +71 -0
  187. data/lib/woods/source_inputs/session.rb +214 -0
  188. data/lib/woods/source_inputs/status.rb +84 -0
  189. data/lib/woods/source_inputs/verifier.rb +107 -0
  190. data/lib/woods/storage/metadata_store.rb +25 -25
  191. data/lib/woods/storage/pgvector.rb +29 -8
  192. data/lib/woods/storage/qdrant.rb +17 -7
  193. data/lib/woods/storage/vector_store.rb +18 -6
  194. data/lib/woods/tasks.rb +3 -2
  195. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  196. data/lib/woods/unblocked/exporter.rb +59 -70
  197. data/lib/woods/version.rb +1 -1
  198. data/lib/woods/watch/boot_snapshot.rb +52 -0
  199. data/lib/woods/watch/daemon.rb +136 -28
  200. data/lib/woods/watch/listen_watcher.rb +4 -0
  201. data/lib/woods/watch/polling_watcher.rb +5 -1
  202. data/lib/woods/watch/status.rb +20 -15
  203. data/lib/woods/watch/tree_scan.rb +21 -13
  204. data/lib/woods/watch/watcher.rb +4 -1
  205. data/lib/woods.rb +135 -11
  206. data/plugin/.claude-plugin/plugin.json +1 -1
  207. data/plugin/hooks/adapters/normalize.jq +15 -0
  208. data/plugin/hooks/adapters/normalize.rb +63 -0
  209. data/plugin/hooks/hooks.json +20 -0
  210. data/plugin/hooks/woods-context.sh +50 -0
  211. data/plugin/hooks/woods-input-rules.sh +159 -0
  212. data/plugin/hooks/woods-opencode.mjs +65 -0
  213. data/plugin/hooks/woods-post-edit.sh +2 -225
  214. data/plugin/hooks/woods-refresh.sh +260 -0
  215. data/plugin/hooks/woods-session-start.sh +47 -55
  216. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  217. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  218. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  219. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  220. data/plugin/skills/woods-setup/SKILL.md +107 -6
  221. metadata +84 -5
@@ -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
@@ -55,11 +56,32 @@ module Woods
55
56
  # can tell whether it was truncated (B-182).
56
57
  DEFAULT_VOLATILE_LIMIT = 20
57
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
71
+
58
72
  # @param dependency_graph [DependencyGraph] The graph to analyze
59
73
  # @param volatile_ratio [Numeric] see {#volatile_dependencies}
60
- def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO)
74
+ # @param volatile_limit_per_target [Integer, nil] maximum edges per typed dependency, before the global limit
75
+ # @param cycle_limit [Integer, nil] see {DEFAULT_CYCLE_LIMIT}
76
+ # @param cycle_max_length [Integer, nil] see {DEFAULT_CYCLE_MAX_LENGTH}
77
+ def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO, volatile_limit_per_target: nil,
78
+ cycle_limit: DEFAULT_CYCLE_LIMIT, cycle_max_length: DEFAULT_CYCLE_MAX_LENGTH)
61
79
  @graph = dependency_graph
62
80
  @volatile_ratio = volatile_ratio.to_f
81
+ @volatile_limit_per_target = volatile_limit_per_target
82
+ @cycle_limit = cycle_limit
83
+ @cycle_max_length = cycle_max_length
84
+ @cycle_limit_reached = false
63
85
  end
64
86
 
65
87
  # ══════════════════════════════════════════════════════════════════════
@@ -147,6 +169,19 @@ module Woods
147
169
  @cycles ||= detect_cycles
148
170
  end
149
171
 
172
+ # Whether {#cycles} is a truncated view of the graph's cycles.
173
+ #
174
+ # True when either cap fired: the count cap stopped enumeration, or at
175
+ # least one cycle was longer than the length cap and was skipped. A reader
176
+ # of the array alone cannot tell, so this is published as
177
+ # `stats[:cycle_limit_reached]` alongside it.
178
+ #
179
+ # @return [Boolean]
180
+ def cycle_limit_reached?
181
+ cycles
182
+ @cycle_limit_reached
183
+ end
184
+
150
185
  # Units that bridge different types in the graph.
151
186
  #
152
187
  # Computes a simplified betweenness centrality metric — for each unit, we
@@ -179,6 +214,7 @@ module Woods
179
214
 
180
215
  pairs.each do |source, target|
181
216
  path = bfs_shortest_path(source, target)
217
+
182
218
  next unless path && path.size > 2
183
219
 
184
220
  # Credit intermediate nodes (exclude source and target)
@@ -262,15 +298,16 @@ module Woods
262
298
  # dependencies with fewer than {VOLATILE_MIN_COMMITS} commits or a
263
299
  # `new` change frequency are skipped. Ranked by the dependency's
264
300
  # PageRank so the most-depended-on volatile unit comes first. `limit`
265
- # caps what this method hands back; {#analyze}'s
301
+ # caps what this method hands back after the optional per-target cap; {#analyze}'s
266
302
  # `stats[:volatile_dependency_count]` reports the full qualifying count
267
303
  # regardless of `limit`, and `stats[:volatile_dependencies_limit]` reports
268
- # the cap, so a reader of the array alone can tell it was truncated.
304
+ # the global cap. An enabled per-target cap also publishes its setting
305
+ # and the final reported count; the full qualifying count is unchanged.
269
306
  #
270
307
  # @param limit [Integer] maximum entries
271
308
  # @return [Array<Hash>] `{ from:, from_type:, to:, to_type:, via:, from_commits:, to_commits:, ratio:, pagerank: }`
272
309
  def volatile_dependencies(limit: DEFAULT_VOLATILE_LIMIT)
273
- all_volatile_dependencies.first(limit)
310
+ capped_volatile_dependencies.first(limit)
274
311
  end
275
312
 
276
313
  # Edges that cross a Packwerk package boundary the source package never
@@ -361,9 +398,9 @@ module Woods
361
398
  dead_end_count: computed_dead_ends.size,
362
399
  hub_count: computed_hubs.size,
363
400
  cycle_count: computed_cycles.size,
401
+ cycle_limit_reached: cycle_limit_reached?,
364
402
  cross_database_edge_count: computed_cross_database.size,
365
- volatile_dependency_count: all_volatile_dependencies.size,
366
- volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT,
403
+ **volatile_dependency_stats(computed_volatile),
367
404
  undeclared_package_edge_count: computed_undeclared.size
368
405
  }
369
406
  }
@@ -687,6 +724,33 @@ module Woods
687
724
  @all_volatile_dependencies ||= compute_volatile_dependencies
688
725
  end
689
726
 
727
+ # Apply the optional hub cap after ranking, before the overall report
728
+ # limit. Keep individual relationship edges; two `via` values consume two
729
+ # slots. Type is part of dependency identity, never just the display name.
730
+ def capped_volatile_dependencies
731
+ return all_volatile_dependencies unless @volatile_limit_per_target
732
+
733
+ counts = Hash.new(0)
734
+ all_volatile_dependencies.select do |entry|
735
+ target = [entry[:to_type], entry[:to]]
736
+ counts[target] += 1
737
+ counts[target] <= @volatile_limit_per_target
738
+ end
739
+ end
740
+
741
+ # Preserve the default artifact exactly; expose extra selection metadata
742
+ # only when the per-target cap was requested. The full count always means
743
+ # all ratio-qualified edges, before either cap.
744
+ def volatile_dependency_stats(reported)
745
+ stats = { volatile_dependency_count: all_volatile_dependencies.size,
746
+ volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT }
747
+ if @volatile_limit_per_target
748
+ stats[:volatile_dependencies_limit_per_target] = @volatile_limit_per_target
749
+ stats[:volatile_dependency_reported_count] = reported.size
750
+ end
751
+ stats
752
+ end
753
+
690
754
  # Short-circuits to `[]`, skipping the PageRank computation entirely,
691
755
  # when no node carries an Integer `commit_count` (git enrichment never
692
756
  # ran): there is nothing to rank.
@@ -802,6 +866,7 @@ module Woods
802
866
  # @return [Array<Array<String>>] Detected cycles
803
867
  def detect_cycles
804
868
  nodes = graph_nodes
869
+ @cycle_limit_reached = false
805
870
  return [] if nodes.empty?
806
871
 
807
872
  white = 0
@@ -813,53 +878,48 @@ module Woods
813
878
  found_cycles = []
814
879
  seen_cycle_signatures = Set.new
815
880
 
816
- nodes.keys.sort.each do |start_node|
817
- next unless color[start_node] == white
881
+ catch(:cycle_limit) do
882
+ nodes.keys.sort.each do |start_node|
883
+ next unless color[start_node] == white
818
884
 
819
- # Iterative DFS using an explicit stack.
820
- # Each entry is [node, :enter] or [node, :exit].
821
- stack = [[start_node, :enter]]
885
+ # Iterative DFS using an explicit stack.
886
+ # Each entry is [node, :enter] or [node, :exit].
887
+ stack = [[start_node, :enter]]
822
888
 
823
- # Track the current DFS path for cycle extraction.
824
- path = []
889
+ # Track the current DFS path for cycle extraction.
890
+ path = []
825
891
 
826
- while stack.any?
827
- node, action = stack.pop
892
+ while stack.any?
893
+ node, action = stack.pop
828
894
 
829
- if action == :exit
830
- color[node] = black
831
- path.pop
832
- next
833
- end
895
+ if action == :exit
896
+ color[node] = black
897
+ path.pop
898
+ next
899
+ end
834
900
 
835
- # :enter action
836
- next unless color[node] == white
837
-
838
- color[node] = gray
839
- path.push(node)
840
- stack.push([node, :exit])
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.
845
- neighbors = @graph.dependencies_of(node)
846
- neighbors.each do |neighbor|
847
- case color[neighbor]
848
- when white
849
- parent[neighbor] = node
850
- stack.push([neighbor, :enter])
851
- when gray
852
- # Found a cycle — extract it from the path
853
- cycle = extract_cycle_from_path(path, neighbor)
854
- if cycle
855
- sig = normalize_cycle_signature(cycle)
856
- unless seen_cycle_signatures.include?(sig)
857
- seen_cycle_signatures.add(sig)
858
- found_cycles << cycle
859
- end
901
+ # :enter action
902
+ next unless color[node] == white
903
+
904
+ color[node] = gray
905
+ path.push(node)
906
+ stack.push([node, :exit])
907
+
908
+ # Not sorted, deliberately: this list is the unit's own declared
909
+ # dependency order, which is identical in a full and an incremental
910
+ # run, so sorting it would change nothing any test can observe.
911
+ neighbors = @graph.dependencies_of(node)
912
+ neighbors.each do |neighbor|
913
+ case color[neighbor]
914
+ when white
915
+ parent[neighbor] = node
916
+ stack.push([neighbor, :enter])
917
+ when gray
918
+ # Found a cycle: extract it from the path
919
+ collect_cycle(path, neighbor, found_cycles, seen_cycle_signatures)
860
920
  end
921
+ # black nodes are fully explored, skip them
861
922
  end
862
- # black nodes are fully explored, skip them
863
923
  end
864
924
  end
865
925
  end
@@ -869,16 +929,52 @@ module Woods
869
929
  found_cycles
870
930
  end
871
931
 
932
+ # Record the cycle closed by a back-edge to +cycle_start+.
933
+ #
934
+ # Skips a cycle longer than the length cap and one already recorded under
935
+ # another rotation. Throws +:cycle_limit+ once the count cap is full,
936
+ # which ends the whole scan in {#detect_cycles}.
937
+ #
938
+ # @param path [Array<String>] Current DFS path
939
+ # @param cycle_start [String] The node that closes the cycle
940
+ # @param found_cycles [Array<Array<String>>] Accumulator
941
+ # @param seen [Set<String>] Signatures already recorded
942
+ # @return [void]
943
+ def collect_cycle(path, cycle_start, found_cycles, seen)
944
+ cycle = extract_cycle_from_path(path, cycle_start, max_length: @cycle_max_length)
945
+ if cycle == :too_long
946
+ @cycle_limit_reached = true
947
+ return
948
+ end
949
+ return unless cycle
950
+
951
+ signature = normalize_cycle_signature(cycle)
952
+ return if seen.include?(signature)
953
+
954
+ seen.add(signature)
955
+ found_cycles << cycle
956
+ return unless @cycle_limit && found_cycles.size >= @cycle_limit
957
+
958
+ @cycle_limit_reached = true
959
+ throw :cycle_limit
960
+ end
961
+
872
962
  # Extracts a cycle from the current DFS path when a back-edge to
873
963
  # +cycle_start+ is found.
874
964
  #
965
+ # The length check runs on indexes, before the slice: an over-long cycle
966
+ # costs nothing beyond the +index+ lookup that found it.
967
+ #
875
968
  # @param path [Array<String>] Current DFS path
876
969
  # @param cycle_start [String] The node that closes the cycle
877
- # @return [Array<String>, nil] The cycle path ending with cycle_start repeated,
878
- # or nil if cycle_start is not in the path
879
- def extract_cycle_from_path(path, cycle_start)
970
+ # @param max_length [Integer, nil] Longest cycle to build, in distinct nodes
971
+ # @return [Array<String>, Symbol, nil] The cycle path ending with cycle_start
972
+ # repeated; +:too_long+ when it exceeds +max_length+; nil when cycle_start
973
+ # is not in the path
974
+ def extract_cycle_from_path(path, cycle_start, max_length: nil)
880
975
  start_index = path.index(cycle_start)
881
976
  return nil unless start_index
977
+ return :too_long if max_length && (path.size - start_index) > max_length
882
978
 
883
979
  path[start_index..] + [cycle_start]
884
980
  end
@@ -886,17 +982,20 @@ module Woods
886
982
  # Normalize a cycle so that duplicate rotations are treated as the same cycle.
887
983
  # For example, [A, B, C, A] and [B, C, A, B] are the same cycle.
888
984
  #
985
+ # Keyed by digest rather than by the joined path: the set holds one
986
+ # 64-byte key per cycle instead of a string as long as the cycle, so
987
+ # membership stays constant-cost however deep the DFS went.
988
+ #
889
989
  # @param cycle [Array<String>] Cycle path with repeated last element
890
- # @return [String] Canonical string representation
990
+ # @return [String] Canonical hex digest of the rotated loop
891
991
  def normalize_cycle_signature(cycle)
892
992
  # Remove the trailing repeated element to get the raw loop
893
993
  loop_nodes = cycle[0..-2]
894
- return loop_nodes.join('->') if loop_nodes.empty?
994
+ return Digest::SHA256.hexdigest('') if loop_nodes.empty?
895
995
 
896
996
  # Rotate so the lexicographically smallest element is first
897
997
  min_index = loop_nodes.each_with_index.min_by { |node, _i| node }.last
898
- rotated = loop_nodes.rotate(min_index)
899
- rotated.join('->')
998
+ Digest::SHA256.hexdigest(loop_nodes.rotate(min_index).join('->'))
900
999
  end
901
1000
 
902
1001
  # ──────────────────────────────────────────────────────────────────────
@@ -927,32 +1026,65 @@ module Woods
927
1026
  pairs.to_a
928
1027
  end
929
1028
 
1029
+ # Forward adjacency, resolved once per node per analyzer instance.
1030
+ #
1031
+ # {#bridges} runs `sample_size` whole-graph traversals, and
1032
+ # `DependencyGraph#dependencies_of` sorts and flattens the node's edge
1033
+ # buckets on every call, so an uncached BFS re-derived the same adjacency
1034
+ # list up to 200 times per node. Populated on demand rather than up front:
1035
+ # a traversal that never reaches a node should not pay for it.
1036
+ #
1037
+ # @return [Hash{String => Array<String>}]
1038
+ def adjacency
1039
+ @adjacency ||= Hash.new { |cache, identifier| cache[identifier] = @graph.dependencies_of(identifier) }
1040
+ end
1041
+
930
1042
  # BFS shortest path between two nodes, following forward edges.
931
1043
  #
1044
+ # Carries parent pointers rather than a path per queue entry. The old form
1045
+ # allocated a copy of the path so far for every node it enqueued, which on
1046
+ # a large graph is a full array per node per traversal; the path is now
1047
+ # built once, for the one node that matched.
1048
+ #
932
1049
  # @param source [String] Starting node identifier
933
1050
  # @param target [String] Target node identifier
934
1051
  # @return [Array<String>, nil] Shortest path or nil if unreachable
935
1052
  def bfs_shortest_path(source, target)
936
1053
  return [source] if source == target
937
1054
 
938
- visited = Set.new([source])
939
- queue = [[source, [source]]]
1055
+ parents = { source => nil }
1056
+ queue = [source]
1057
+ head = 0
940
1058
 
941
- while queue.any?
942
- current, path = queue.shift
1059
+ while head < queue.size
1060
+ current = queue[head]
1061
+ head += 1
943
1062
 
944
- @graph.dependencies_of(current).each do |neighbor|
945
- next if visited.include?(neighbor)
1063
+ adjacency[current].each do |neighbor|
1064
+ next if parents.key?(neighbor)
946
1065
 
947
- new_path = path + [neighbor]
948
- return new_path if neighbor == target
1066
+ parents[neighbor] = current
1067
+ return path_to(parents, neighbor) if neighbor == target
949
1068
 
950
- visited.add(neighbor)
951
- queue.push([neighbor, new_path])
1069
+ queue.push(neighbor)
952
1070
  end
953
1071
  end
954
1072
 
955
1073
  nil
956
1074
  end
1075
+
1076
+ # Walk parent pointers back to the source and reverse.
1077
+ #
1078
+ # @param parents [Hash{String => String, nil}] node => the node it was reached from
1079
+ # @param node [String] the end of the path
1080
+ # @return [Array<String>] source-first path ending at +node+
1081
+ def path_to(parents, node)
1082
+ path = []
1083
+ while node
1084
+ path << node
1085
+ node = parents[node]
1086
+ end
1087
+ path.reverse
1088
+ end
957
1089
  end
958
1090
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'timeout'
5
+
6
+ module Woods
7
+ module Hooks
8
+ # The plugin's independent process-group deadline also includes bundle/Ruby
9
+ # startup. This inner deadline keeps ordinary direct calls short as well.
10
+ class ContextCLI
11
+ MAX_INPUT_BYTES = 1_048_576
12
+ HELP = 'Usage: woods-hook-context SessionStart|PostToolUse < claude-event.json; ' \
13
+ 'opt in with WOODS_HOOK_CONTEXT_ENABLED=1. Prefer the bounded plugin hook entry point.'
14
+
15
+ def self.run(kind, input: $stdin, output: $stdout)
16
+ return output.puts(HELP) || 0 if kind == '--help'
17
+
18
+ return 0 if ENV['WOODS_HOOKS_DISABLED'] == '1' || ENV['WOODS_HOOK_CONTEXT_ENABLED'] != '1'
19
+ return 0 unless %w[SessionStart PostToolUse].include?(kind)
20
+
21
+ Timeout.timeout(0.65) do
22
+ require 'woods'
23
+ require 'woods/hooks/context_hint'
24
+ require 'woods/hooks/context_state'
25
+ emit(kind, input, output)
26
+ end
27
+ 0
28
+ rescue Timeout::Error, JSON::ParserError, ArgumentError, KeyError, TypeError, SystemCallError, IOError
29
+ # Missing/degraded/slow inputs are optional context, not permission to
30
+ # run extraction, read application data, or acknowledge queued edits.
31
+ 0
32
+ end
33
+
34
+ def self.emit(kind, input, output)
35
+ bytes = input.read(MAX_INPUT_BYTES + 1)
36
+ return if bytes.bytesize > MAX_INPUT_BYTES
37
+
38
+ event = JSON.parse(bytes)
39
+ return unless event.is_a?(Hash) && event['hook_event_name'] == kind
40
+
41
+ root = ENV['WOODS_HOOK_CONTEXT_ROOT'] || event.fetch('cwd')
42
+ directory = File.expand_path(ENV.fetch('WOODS_OUTPUT', 'tmp/woods'), root)
43
+ result = ContextHint.new(event: event, output_dir: directory, root: root).call
44
+ return unless result
45
+
46
+ ContextState.new(directory).emit(result) do
47
+ output.write(ContextOutput.new(kind).encode(result.fetch(:context)))
48
+ output.flush
49
+ end
50
+ end
51
+ private_class_method :emit
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'woods/input_rules'
5
+
6
+ module Woods
7
+ module Hooks
8
+ # Claude context events are deliberately separate from refresh queue records.
9
+ class ContextEvent
10
+ MAX_FILE_BYTES = 1_048_576
11
+ attr_reader :root, :path, :kind, :session
12
+
13
+ def initialize(input, root: nil)
14
+ validate_root!(input)
15
+
16
+ @logical_root = File.expand_path(input['cwd'])
17
+ @root = File.realpath(root || @logical_root)
18
+ @kind = input['hook_event_name']
19
+ @session = input['session_id'] if valid_string?(input['session_id'], 256)
20
+ return if kind == 'SessionStart'
21
+
22
+ raise ArgumentError unless kind == 'PostToolUse' && %w[Edit Write MultiEdit].include?(input['tool_name'])
23
+
24
+ @path = relative_path(input.fetch('tool_input').fetch('file_path'))
25
+ end
26
+
27
+ def eligible?
28
+ return true if kind == 'SessionStart'
29
+
30
+ require 'woods/extractor' unless defined?(Woods::Extractor::EXTRACTORS)
31
+ InputRules.new.action(path) != :ignore
32
+ end
33
+
34
+ def fingerprint
35
+ return 'orientation' if kind == 'SessionStart'
36
+
37
+ flags = File::RDONLY | File::NONBLOCK
38
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
39
+ File.open(File.join(root, path), flags) do |file|
40
+ return nil unless file.stat.file? && file.stat.size <= MAX_FILE_BYTES
41
+
42
+ bytes = file.read(MAX_FILE_BYTES + 1)
43
+ return nil if bytes.bytesize > MAX_FILE_BYTES
44
+
45
+ Digest::SHA256.hexdigest(bytes)
46
+ end
47
+ rescue Errno::ENOENT
48
+ 'missing'
49
+ rescue SystemCallError, IOError
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def validate_root!(input)
56
+ raise ArgumentError unless input.is_a?(Hash) && valid_string?(input['cwd']) && input['cwd'].start_with?('/')
57
+ end
58
+
59
+ def valid_string?(value, limit = 4096)
60
+ value.is_a?(String) && !value.empty? && value.bytesize <= limit &&
61
+ value.valid_encoding? && !value.include?("\0")
62
+ end
63
+
64
+ def relative_path(value)
65
+ raise ArgumentError unless valid_string?(value)
66
+
67
+ prefix = [@logical_root, root].find { |base| value.start_with?("#{base}/") }
68
+ relative = prefix ? value.delete_prefix("#{prefix}/") : value
69
+ validate_parts!(relative)
70
+ validate_containment!(relative)
71
+ relative
72
+ end
73
+
74
+ def validate_parts!(relative)
75
+ parts = relative.split('/', -1)
76
+ raise ArgumentError if relative.start_with?('/') || parts.size > 64
77
+ raise ArgumentError if parts.any? { |part| ['', '.', '..'].include?(part) }
78
+ end
79
+
80
+ def validate_containment!(relative)
81
+ candidate = File.join(root, relative)
82
+ candidate = File.dirname(candidate) until File.exist?(candidate) || File.symlink?(candidate)
83
+ resolved = File.realpath(candidate)
84
+ raise ArgumentError unless resolved == root || resolved.start_with?("#{root}/")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods/mcp/index_reader'
4
+ require_relative 'context_event'
5
+ require_relative 'context_output'
6
+ require_relative 'context_impact'
7
+
8
+ module Woods
9
+ module Hooks
10
+ # A bounded snapshot hint. The outer hook process owns the end-to-end deadline.
11
+ class ContextHint
12
+ MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
13
+
14
+ def initialize(event:, output_dir:, root: nil)
15
+ @event = ContextEvent.new(event, root: root)
16
+ @output = File.expand_path(output_dir, @event.root)
17
+ @renderer = ContextOutput.new(@event.kind)
18
+ rescue ArgumentError, KeyError, TypeError, SystemCallError
19
+ @event = nil
20
+ end
21
+
22
+ def call
23
+ return nil unless @event&.eligible?
24
+
25
+ @reader = MCP::IndexReader.new(@output)
26
+ @reader.with_pinned_generation do
27
+ @generation = @reader.generation_identity
28
+ raise ArgumentError if @reader.payload_dir.to_s == @output
29
+
30
+ check_artifacts!
31
+ freshness = source_freshness
32
+ text = if @event.kind == 'SessionStart'
33
+ orientation(freshness)
34
+ else
35
+ ContextImpact.new(@reader, @renderer).call(@event.path, @generation.first, freshness)
36
+ end
37
+ result(text)
38
+ end
39
+ rescue ArgumentError, KeyError, TypeError, NoMethodError, JSON::ParserError, SystemCallError, IOError
40
+ { context: ContextOutput::UNAVAILABLE, identity: nil, session: nil }
41
+ end
42
+
43
+ private
44
+
45
+ def check_artifacts!
46
+ %w[manifest.json dependency_graph.json source_inputs.json].each do |name|
47
+ file = @reader.payload_dir.join(name)
48
+ next if name == 'source_inputs.json' && !file.exist?
49
+
50
+ raise ArgumentError unless file.file? && file.size <= MAX_ARTIFACT_BYTES
51
+ end
52
+ end
53
+
54
+ def source_freshness
55
+ SourceInputs::Status.new(output_dir: @output, root: @event.root, payload_dir: @reader.payload_dir,
56
+ generation: @reader.loaded_generation).call.fetch('state')
57
+ end
58
+
59
+ def orientation(freshness)
60
+ @renderer.context("Woods index generation #{@generation.first}; source freshness: #{freshness}. " \
61
+ 'Use woods_status, search and typed lookup; dependents explain:true suggests checks.')
62
+ end
63
+
64
+ def result(text)
65
+ fingerprint = @event.fingerprint
66
+ identity = if fingerprint && @event.session
67
+ Digest::SHA256.hexdigest(JSON.generate([@event.root, @event.path, @generation, fingerprint, text]))
68
+ end
69
+ { context: text, identity: identity, session: @event.session, root: @event.root }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Hooks
5
+ # Resolve one unambiguous edited identity, then reuse the bounded graph walk.
6
+ class ContextImpact
7
+ MAX_NODES = 50_000
8
+
9
+ def initialize(reader, renderer)
10
+ @reader = reader
11
+ @renderer = renderer
12
+ end
13
+
14
+ def call(path, generation, freshness)
15
+ graph = @reader.raw_graph_data
16
+ roots = roots_for(graph, path)
17
+ header = "Woods candidates from generation #{generation} (pre-refresh snapshot; " \
18
+ "source freshness: #{freshness}). Changed path: #{JSON.generate(path)}."
19
+ return unresolved(header, roots) unless roots.size == 1
20
+
21
+ root = roots.first
22
+ traversal = @reader.traverse_dependents(root.fetch('identifier'), depth: 2, explain: true, max_nodes: 10,
23
+ max_edges: 100)
24
+ @renderer.context(header, rows_for(traversal), partial: !!traversal[:partial])
25
+ end
26
+
27
+ private
28
+
29
+ def rows_for(traversal)
30
+ rows = traversal.fetch(:explanation).fetch(:witnesses).filter_map do |_identifier, witness|
31
+ next if witness[:impact] == 'root'
32
+
33
+ edge = traversal[:explanation][:edges].fetch(witness.fetch(:edge_id))
34
+ evidence_row(edge, witness)
35
+ end
36
+ rows << 'No candidate found within this bounded snapshot; verify manually with dependents.' if rows.empty?
37
+ rows
38
+ end
39
+
40
+ def roots_for(graph, path)
41
+ records = records_for(graph)
42
+ matching = records.select { |node| node['file_path'] == path }
43
+ identities = matching.map { |node| node.slice('identifier', 'type') }.uniq
44
+ return identities if identities.size != 1
45
+
46
+ # A recorded edge target does not identify which same-named type it means.
47
+ records.select { |node| node['identifier'] == identities.first['identifier'] }
48
+ .map { |node| node.slice('identifier', 'type') }.uniq
49
+ end
50
+
51
+ def unresolved(header, roots)
52
+ reason = roots.empty? ? 'unresolved changed path' : 'ambiguous changed identity'
53
+ rows = roots.first(10).map { |root| "#{root['identifier']} (#{root['type']})" }
54
+ @renderer.context("#{header} #{reason}; verify with search and typed lookup.", rows, partial: true)
55
+ end
56
+
57
+ def evidence_row(edge, witness)
58
+ source = edge.fetch(:source)
59
+ target = edge.fetch(:target)
60
+ target_type = target[:type] || "ambiguous/unknown #{Array(target[:candidate_types]).join(',')}"
61
+ test_hint = source[:type] == 'test_mapping' ? '; test suggestion' : ''
62
+ uncertain = witness[:typed_path_complete] ? '' : '; typed path incomplete'
63
+ "#{witness[:impact]} candidate: #{source[:identifier]} (#{source[:type]}) " \
64
+ "--via=#{edge[:via] || 'unknown'}--> #{target[:identifier]} (#{target_type})#{test_hint}#{uncertain}"
65
+ end
66
+
67
+ def records_for(graph)
68
+ nodes = graph.fetch('nodes')
69
+ variants = graph.fetch('variants', [])
70
+ raise ArgumentError unless nodes.is_a?(Hash) && variants.is_a?(Array)
71
+ raise ArgumentError if nodes.size + variants.size > MAX_NODES
72
+
73
+ nodes.map { |identifier, node| node.merge('identifier' => identifier) } + variants
74
+ end
75
+ end
76
+ end
77
+ end