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
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'fileutils'
5
5
  require_relative 'filename_utils'
6
6
  require_relative 'flow_assembler'
7
+ require_relative 'atomic_file'
7
8
 
8
9
  module Woods
9
10
  # Orchestrates pre-computation of request flow maps for all controller actions.
@@ -11,10 +12,20 @@ module Woods
11
12
  # After the dependency graph is built, FlowPrecomputer iterates controller units,
12
13
  # runs FlowAssembler for each action, and writes flow documents to disk.
13
14
  #
15
+ # All persisted path values — `flow_index.json` entries and each controller
16
+ # unit's `metadata[:flow_paths]` — are RELATIVE to the output dir
17
+ # (e.g. "flows/OrdersController_create.json"), matching the #166 portability
18
+ # contract: extraction inside a container must not bake `/app/...` prefixes
19
+ # into artifacts a host reads through a volume mount, and identical trees at
20
+ # different roots must produce identical bytes (which is also what keeps
21
+ # {#canonical_json}'s churn-avoidance honest). Readers join the relative
22
+ # value against their own index dir (see the MCP server's
23
+ # `load_precomputed_flow`).
24
+ #
14
25
  # @example
15
26
  # precomputer = FlowPrecomputer.new(units: all_units, graph: dep_graph, output_dir: out)
16
27
  # flow_map = precomputer.precompute
17
- # flow_map["OrdersController#create"] #=> "/tmp/woods/flows/OrdersController_create.json"
28
+ # flow_map["OrdersController#create"] #=> "flows/OrdersController_create.json"
18
29
  #
19
30
  class FlowPrecomputer
20
31
  # Default maximum recursion depth for flow assembly
@@ -34,7 +45,15 @@ module Woods
34
45
 
35
46
  # Pre-compute flow documents for all controller actions.
36
47
  #
37
- # @return [Hash{String => String}] Map of entry_point to flow file path
48
+ # Assembly is fail closed (M3 review round 3): a per-action failure
49
+ # raises instead of skipping the entry, so a partial index — missing
50
+ # entries alongside a freshly written graph — is never written and the
51
+ # caller aborts before publishing. The full extraction path shares this
52
+ # contract with the incremental delta.
53
+ #
54
+ # @return [Hash{String => String}] Map of entry_point to output_dir-relative
55
+ # flow file path (e.g. "flows/OrdersController_create.json")
56
+ # @raise [Woods::ExtractionError] when any action fails to assemble
38
57
  def precompute
39
58
  FileUtils.mkdir_p(@flows_dir)
40
59
 
@@ -42,18 +61,8 @@ module Woods
42
61
  flow_map = {}
43
62
 
44
63
  controller_units.each do |unit|
45
- actions = unit.metadata[:actions] || unit.metadata['actions'] || []
46
- unit_flow_paths = {}
47
-
48
- actions.each do |action|
49
- entry_point = "#{unit.identifier}##{action}"
50
- flow_path = assemble_and_write(assembler, entry_point, unit.identifier, action)
51
- next unless flow_path
52
-
53
- flow_map[entry_point] = flow_path
54
- unit_flow_paths[action] = flow_path
55
- end
56
-
64
+ entries, unit_flow_paths = assemble_controller_unit(assembler, unit)
65
+ flow_map.merge!(entries)
57
66
  unit.metadata[:flow_paths] = unit_flow_paths if unit_flow_paths.any?
58
67
  end
59
68
 
@@ -62,6 +71,64 @@ module Woods
62
71
  flow_map
63
72
  end
64
73
 
74
+ # Recompute the flow documents for the controllers an incremental run
75
+ # touched (M3), carrying every untouched controller's entries forward
76
+ # from the previous generation's flow_index.json — payload seeding
77
+ # hardlinks it into this run's payload directory — and rewriting the
78
+ # index with the merged result.
79
+ #
80
+ # Touched controllers replace their previous entries wholesale (a full
81
+ # run would emit exactly their current actions), so an action removed
82
+ # from a re-extracted controller leaves the index even though the file
83
+ # still exists. Controllers named in +removed_identifiers+ lost their
84
+ # unit this run (deleted or renamed) and leave the index entirely;
85
+ # {Woods::Extractor#sweep_orphaned_flow_files} then removes the
86
+ # documents nothing references anymore.
87
+ #
88
+ # @param touched_units [Array<ExtractedUnit>] the run's re-extracted
89
+ # controller units, rehydrated from the payload this run seeded
90
+ # @param removed_identifiers [Array<String>] controller identifiers
91
+ # whose unit the run pruned
92
+ # @param carried_identifiers [Array<String>] controllers the run
93
+ # re-extracted whose flows it established cannot have changed. Their
94
+ # index entries stay as they are and their annotation is read back out
95
+ # of those entries, because a re-extracted unit lost the annotation
96
+ # whether or not its flows moved. Skipping the assembly is the whole
97
+ # saving; skipping the annotation would diverge from a full run.
98
+ # @return [Hash{String => Hash{String => String}}] per-controller
99
+ # annotation (action => relative flow path) to write into the units'
100
+ # metadata; an empty hash means "no flows", which clears any
101
+ # annotation a previous run had written
102
+ def recompute_delta(touched_units:, removed_identifiers: [], carried_identifiers: [])
103
+ FileUtils.mkdir_p(@flows_dir)
104
+
105
+ assembler = FlowAssembler.new(graph: @graph, extracted_dir: @output_dir)
106
+ delta = {}
107
+ annotations = {}
108
+
109
+ touched_units.each do |unit|
110
+ # A per-action skip would publish an index missing that entry, so
111
+ # assembly raises. The incremental path must not advance publication
112
+ # over a broken assembly; the caller aborts before the generation
113
+ # bump.
114
+ entries, unit_flow_paths = assemble_controller_unit(assembler, unit)
115
+ annotations[unit.identifier] = unit_flow_paths
116
+ delta.merge!(entries)
117
+ end
118
+
119
+ previous = previous_flow_index
120
+ Array(carried_identifiers).each do |identifier|
121
+ annotations[identifier] = carried_annotation(previous, identifier)
122
+ end
123
+
124
+ replaced = touched_units.map(&:identifier) + Array(removed_identifiers)
125
+ carried = previous.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
126
+
127
+ write_flow_index(carried.merge(delta))
128
+
129
+ annotations
130
+ end
131
+
65
132
  private
66
133
 
67
134
  # Filter units to only controllers.
@@ -71,33 +138,119 @@ module Woods
71
138
  @units.select { |u| u.type.to_s == 'controller' }
72
139
  end
73
140
 
141
+ # Assemble and write every flow for one controller unit.
142
+ #
143
+ # Both entry points fail closed: a per-action failure aborts the run
144
+ # rather than publishing a flow index missing that entry (EXTB-14 — the
145
+ # old `fail_closed:` switch documented a log-and-skip contract for the
146
+ # full path that no caller ever asked for).
147
+ #
148
+ # @param assembler [FlowAssembler]
149
+ # @param unit [ExtractedUnit]
150
+ # @return [Array(Hash{String => String}, Hash{String => String})] the
151
+ # entry_point => relative-path entries for the flow index, and the
152
+ # action => relative-path annotation for the unit's metadata
153
+ # @raise [Woods::ExtractionError] when an action fails to assemble
154
+ def assemble_controller_unit(assembler, unit)
155
+ entries = {}
156
+ unit_flow_paths = {}
157
+
158
+ actions = unit.metadata[:actions] || unit.metadata['actions'] || []
159
+ actions.each do |action|
160
+ entry_point = "#{unit.identifier}##{action}"
161
+ flow_path = assemble_and_write(assembler, entry_point, unit.identifier, action)
162
+
163
+ entries[entry_point] = flow_path
164
+ unit_flow_paths[action] = flow_path
165
+ end
166
+
167
+ [entries, unit_flow_paths]
168
+ end
169
+
170
+ # One controller's annotation, rebuilt from the index entries it keeps.
171
+ #
172
+ # @param previous [Hash{String => String}] the previous flow index
173
+ # @param identifier [String] controller identifier
174
+ # @return [Hash{String => String}] action to relative flow path
175
+ def carried_annotation(previous, identifier)
176
+ previous.each_with_object({}) do |(entry_point, path), annotation|
177
+ controller, action = entry_point.to_s.split('#', 2)
178
+ annotation[action] = path if action && controller == identifier
179
+ end
180
+ end
181
+
182
+ # The controller part of a flow index entry point.
183
+ #
184
+ # @param entry_point [String]
185
+ # @return [String]
186
+ def controller_of(entry_point)
187
+ entry_point.to_s.split('#', 2).first
188
+ end
189
+
190
+ # The previous generation's flow index, or an empty hash when none was
191
+ # published. A missing index means there is nothing to carry forward
192
+ # (the caller established whether the family is genuinely absent); a
193
+ # corrupt one raises — publication must not advance over unreadable
194
+ # prior flow state.
195
+ #
196
+ # @return [Hash{String => String}]
197
+ # @raise [Woods::ExtractionError] when the index does not parse
198
+ def previous_flow_index
199
+ path = File.join(@flows_dir, 'flow_index.json')
200
+ return {} unless File.exist?(path)
201
+
202
+ JSON.parse(Woods::AtomicFile.read(path))
203
+ rescue JSON::ParserError => e
204
+ raise Woods::ExtractionError, "previous flow_index.json does not parse: #{e.message}"
205
+ end
206
+
74
207
  # Assemble a flow for one entry point and write the JSON file.
75
208
  #
209
+ # Written via {Woods::AtomicFile} (temp + rename) like every other index
210
+ # artifact. A plain +File.write+ interrupted mid-write left a torn
211
+ # partial for the MCP read side to trip over. Not durable by default: see
212
+ # {#durable_writes?}.
213
+ #
76
214
  # @param assembler [FlowAssembler]
77
215
  # @param entry_point [String]
78
216
  # @param controller_id [String]
79
217
  # @param action [String]
80
- # @return [String, nil] The written file path, or nil on failure
218
+ # @return [String] The output_dir-relative path of the written file
219
+ # (e.g. "flows/OrdersController_create.json")
220
+ # @raise [Woods::ExtractionError] when assembly or the write fails
81
221
  def assemble_and_write(assembler, entry_point, controller_id, action)
82
222
  flow = assembler.assemble(entry_point, max_depth: @max_depth)
83
223
 
84
224
  filename = Woods::FilenameUtils.flow_filename(controller_id, action)
85
- flow_path = File.join(@flows_dir, filename)
86
225
 
87
- File.write(flow_path, canonical_json(flow.to_h))
226
+ Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h), durable: durable_writes?)
88
227
 
89
- flow_path
228
+ # Relative — this value is persisted (flow_index.json and the unit's
229
+ # metadata[:flow_paths]), so it must not carry this machine's root.
230
+ File.join('flows', filename)
90
231
  rescue StandardError => e
91
- Rails.logger.error("[Woods] Flow precompute failed for #{entry_point}: #{e.message}")
92
- nil
232
+ raise Woods::ExtractionError, "flow precompute failed for #{entry_point}: #{e.message}"
93
233
  end
94
234
 
95
- # Write the flow index mapping entry points to file paths.
235
+ # Write the flow index mapping entry points to output_dir-relative file paths.
96
236
  #
97
237
  # @param flow_map [Hash{String => String}]
98
238
  def write_flow_index(flow_map)
99
239
  index_path = File.join(@flows_dir, 'flow_index.json')
100
- File.write(index_path, canonical_json(flow_map))
240
+ Woods::AtomicFile.write(index_path, canonical_json(flow_map), durable: durable_writes?)
241
+ end
242
+
243
+ # Whether a flow document pays for its own fsync as it is written.
244
+ #
245
+ # False by default. A flow document is a payload file: nothing reads it
246
+ # until +generation.json+ names the payload, and
247
+ # {Woods::AtomicFile.sync_directory_tree} flushes the whole payload before
248
+ # that pointer is written. `durable_payload_writes = true` buys the
249
+ # per-file fsync back for a host that wants it.
250
+ #
251
+ # @return [Boolean]
252
+ def durable_writes?
253
+ Woods.configuration&.durable_payload_writes ? true : false
101
254
  end
102
255
 
103
256
  # Emit deterministic pretty JSON — keys recursively sorted so two runs
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'pathname'
7
+ require 'time'
8
+
9
+ require_relative 'atomic_file'
10
+ require_relative 'coordination/pipeline_lock'
11
+ require_relative 'dependency_graph'
12
+ require_relative 'extracted_unit'
13
+ require_relative 'filename_utils'
14
+ require_relative 'generation'
15
+ require_relative 'graph_analyzer'
16
+ require_relative 'payload_store'
17
+ require_relative 'ruby_analyzer'
18
+
19
+ module Woods
20
+ # Publishes a static, source-only index of the Woods implementation.
21
+ class GemMapper
22
+ include FilenameUtils
23
+
24
+ SOURCE_GLOBS = %w[lib/woods.rb lib/woods/**/*.rb lib/tasks/**/*.rake exe/*].freeze
25
+ TYPE_DIRECTORIES = {
26
+ ruby_class: 'ruby_classes', ruby_module: 'ruby_modules',
27
+ ruby_method: 'ruby_methods', ruby_file: 'ruby_files'
28
+ }.freeze
29
+ PROVENANCE = {
30
+ mode: 'woods_static_ruby_source', runtime_fidelity: 'none', embeddings: 'absent',
31
+ included: SOURCE_GLOBS, excluded: %w[spec docs .git tmp vendor]
32
+ }.freeze
33
+
34
+ def initialize(root:, output_dir:)
35
+ @root = Pathname.new(root).expand_path
36
+ @output_dir = Pathname.new(output_dir).expand_path
37
+ end
38
+
39
+ # Publishes one complete source snapshot, or skips when it is unchanged.
40
+ def map!
41
+ with_lock do
42
+ snapshot = source_snapshot
43
+ return { status: :skipped, generation: current_generation.number } if current_checksum == snapshot[:checksum]
44
+
45
+ units, graph = GemMapSource.new(root: @root).build(snapshot[:sources])
46
+ payload = create_payload
47
+ GemMapPublisher.new(payload: payload, units: units, graph: graph, checksum: snapshot[:checksum]).write
48
+ ensure_snapshot_unchanged!(snapshot[:checksum])
49
+ marker = publish_generation
50
+ { status: :published, generation: marker.number, units: units.size }
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def with_lock(&block)
57
+ Coordination::PipelineLock.new(lock_dir: @output_dir.to_s, name: 'woods_self_map').with_lock(&block)
58
+ end
59
+
60
+ def source_files
61
+ SOURCE_GLOBS.flat_map { |glob| Dir.glob(@root.join(glob).to_s) }
62
+ .select { |path| File.file?(path) }.uniq.sort
63
+ end
64
+
65
+ def source_snapshot
66
+ sources = source_files.to_h { |path| [path, read_source(path)] }
67
+ { sources: sources, checksum: checksum_for(sources) }
68
+ end
69
+
70
+ def checksum_for(sources)
71
+ digest = Digest::SHA256.new
72
+ sources.sort.each { |path, source| digest << relative_path(path) << "\0" << source << "\0" }
73
+ digest.hexdigest
74
+ end
75
+
76
+ def read_source(path)
77
+ source = File.read(path, encoding: Encoding::UTF_8)
78
+ source.valid_encoding? ? source : source.scrub
79
+ end
80
+
81
+ def ensure_snapshot_unchanged!(checksum)
82
+ return if source_snapshot[:checksum] == checksum
83
+
84
+ raise Woods::ExtractionError, 'Woods source changed during self-map; refusing to publish a mixed snapshot'
85
+ end
86
+
87
+ def current_generation
88
+ Generation.new(output_dir: @output_dir).current
89
+ end
90
+
91
+ def next_generation
92
+ current_generation.number + 1
93
+ end
94
+
95
+ def current_checksum
96
+ payload = Generation.new(output_dir: @output_dir).payload_dir(current_generation)
97
+ manifest = payload.join('manifest.json')
98
+ return nil unless manifest.file?
99
+
100
+ JSON.parse(AtomicFile.read(manifest)).dig('provenance', 'source_checksum')
101
+ rescue JSON::ParserError, SystemCallError
102
+ nil
103
+ end
104
+
105
+ def relative_path(path)
106
+ Pathname.new(path).expand_path.relative_path_from(@root).to_s
107
+ rescue ArgumentError
108
+ raise Woods::ExtractionError, "Source path escapes gem root: #{path}"
109
+ end
110
+
111
+ def create_payload
112
+ PayloadStore.new(@output_dir).create(next_generation)
113
+ end
114
+
115
+ def publish_generation
116
+ marker = Generation.new(output_dir: @output_dir).bump!(
117
+ reason: 'woods_static_source_map', payload: PayloadStore.name_for(next_generation)
118
+ )
119
+ PayloadStore.new(@output_dir).prune(keep: PayloadStore::DEFAULT_RETENTION, protect: marker.number)
120
+ marker
121
+ end
122
+ end
123
+
124
+ # Turns a pre-read Woods source snapshot into typed static analysis units.
125
+ class GemMapSource
126
+ def initialize(root:)
127
+ @root = root
128
+ end
129
+
130
+ def build(sources)
131
+ units = build_units(sources)
132
+ graph = DependencyGraph.new
133
+ units.each { |unit| graph.register(unit) }
134
+ [units, graph]
135
+ end
136
+
137
+ private
138
+
139
+ def build_units(sources)
140
+ analyzable = sources.select { |path, _| path.end_with?('.rb', '.rake') || executable?(path) }
141
+ units = RubyAnalyzer.analyze(sources: analyzable)
142
+ units.each { |unit| unit.file_path = relative_path(unit.file_path) }
143
+ units.concat(file_units(sources))
144
+ disambiguate!(units)
145
+ qualify_dependencies(units)
146
+ resolve_dependents(units)
147
+ units.sort_by { |unit| [unit.identifier, unit.type.to_s, unit.file_path] }
148
+ end
149
+
150
+ def executable?(path)
151
+ path.start_with?(@root.join('exe').to_s + File::SEPARATOR)
152
+ end
153
+
154
+ def file_units(sources)
155
+ sources.map do |path, source|
156
+ relative = relative_path(path)
157
+ unit = ExtractedUnit.new(type: :ruby_file, identifier: "file:#{relative}", file_path: relative)
158
+ unit.source_code = source
159
+ unit.metadata = { static_kind: 'ruby_file', executable: executable?(path) }
160
+ unit
161
+ end
162
+ end
163
+
164
+ def disambiguate!(units)
165
+ units.group_by(&:identifier).each do |canonical, colliding|
166
+ colliding.sort_by! { |unit| [unit.file_path, unit.type.to_s] }
167
+ colliding.each_with_index do |unit, index|
168
+ unit.metadata[:canonical_identifier] = canonical
169
+ next if index.zero?
170
+
171
+ unit.identifier = "#{canonical} [#{unit.file_path}]"
172
+ unit.dependencies << { type: unit.type, target: canonical, via: :definition_of }
173
+ end
174
+ end
175
+ end
176
+
177
+ # Resolve only known lexical definitions, retaining external/dynamic names as written.
178
+ def qualify_dependencies(units)
179
+ definitions = units.select { |unit| %i[ruby_class ruby_module].include?(unit.type) }.to_h do |unit|
180
+ [unit.metadata[:canonical_identifier] || unit.identifier, unit]
181
+ end
182
+ units.each do |unit|
183
+ unit.dependencies.each { |dependency| qualify_dependency(unit, dependency, definitions) }
184
+ end
185
+ end
186
+
187
+ def qualify_dependency(unit, dependency, definitions)
188
+ target = dependency[:target]
189
+ return unless target.is_a?(String)
190
+
191
+ scopes = Array(unit.metadata[:lexical_scopes])
192
+ scopes = scopes.drop(1) if dependency[:via] == :inheritance
193
+ candidates = if target.start_with?('::')
194
+ [target.delete_prefix('::')]
195
+ else
196
+ scopes.map { |scope| "#{scope}::#{target}" } + [target]
197
+ end
198
+ resolved = candidates.find { |candidate| definitions.key?(candidate) }
199
+ return unless resolved
200
+
201
+ dependency[:target] = resolved
202
+ dependency[:type] = definitions[resolved].type
203
+ end
204
+
205
+ def resolve_dependents(units)
206
+ by_identifier = units.group_by(&:identifier)
207
+ units.each do |unit|
208
+ unit.dependencies.each do |dependency|
209
+ Array(by_identifier[dependency[:target]]).each do |target|
210
+ target.dependents << { type: unit.type, identifier: unit.identifier }
211
+ end
212
+ end
213
+ end
214
+ end
215
+
216
+ def relative_path(path)
217
+ Pathname.new(path).expand_path.relative_path_from(@root).to_s
218
+ rescue ArgumentError
219
+ raise Woods::ExtractionError, "Source path escapes gem root: #{path}"
220
+ end
221
+ end
222
+
223
+ # Writes a complete source-map payload before GemMapper atomically publishes it.
224
+ class GemMapPublisher
225
+ include FilenameUtils
226
+
227
+ def initialize(payload:, units:, graph:, checksum:)
228
+ @payload = payload
229
+ @units = units
230
+ @graph = graph
231
+ @checksum = checksum
232
+ end
233
+
234
+ def write
235
+ GemMapper::TYPE_DIRECTORIES.each do |type, directory|
236
+ write_type(@payload.join(directory), @units.select { |unit| unit.type == type })
237
+ end
238
+ write_graph
239
+ write_manifest
240
+ write_summary
241
+ end
242
+
243
+ private
244
+
245
+ def write_type(directory, units)
246
+ FileUtils.mkdir_p(directory)
247
+ units.each do |unit|
248
+ AtomicFile.write(directory.join(collision_safe_filename(unit.identifier)), JSON.pretty_generate(unit.to_h))
249
+ end
250
+ index = units.map do |unit|
251
+ { identifier: unit.identifier, file_path: unit.file_path, namespace: unit.namespace,
252
+ estimated_tokens: unit.estimated_tokens, chunk_count: unit.chunks.size }
253
+ end
254
+ AtomicFile.write(directory.join('_index.json'), JSON.pretty_generate(index))
255
+ end
256
+
257
+ def write_graph
258
+ data = @graph.to_h
259
+ data[:pagerank] = @graph.pagerank
260
+ AtomicFile.write(@payload.join('dependency_graph.json'), JSON.pretty_generate(data))
261
+ AtomicFile.write(@payload.join('graph_analysis.json'), JSON.pretty_generate(GraphAnalyzer.new(@graph).analyze))
262
+ end
263
+
264
+ def write_manifest
265
+ counts = GemMapper::TYPE_DIRECTORIES.keys.to_h do |type|
266
+ [GemMapper::TYPE_DIRECTORIES[type], @units.count { |unit| unit.type == type }]
267
+ end
268
+ manifest = {
269
+ extracted_at: Time.now.utc.iso8601, rails_version: nil, ruby_version: RUBY_VERSION,
270
+ counts: counts, total_units: @units.size, total_chunks: 0, git_sha: nil, git_branch: nil,
271
+ provenance: GemMapper::PROVENANCE.merge(source_checksum: @checksum),
272
+ graph_nodes: @graph.to_h.dig(:stats, :node_count), graph_edges: @graph.to_h.dig(:stats, :edge_count)
273
+ }
274
+ AtomicFile.write(@payload.join('manifest.json'), JSON.pretty_generate(manifest))
275
+ end
276
+
277
+ def write_summary
278
+ stats = @graph.to_h.fetch(:stats)
279
+ AtomicFile.write(@payload.join('SUMMARY.md'), [
280
+ '# Woods static Ruby source map', '', "Units: #{@units.size}", "Graph nodes: #{stats[:node_count]}",
281
+ "Graph edges: #{stats[:edge_count]}", 'Static source analysis only; not a Rails runtime extraction.'
282
+ ].join("\n"))
283
+ end
284
+ end
285
+ end