woods 1.6.0 → 2.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1908 -0
  3. data/CONTRIBUTING.md +202 -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 +620 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +415 -0
  19. data/docs/INTERNALS.md +415 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +197 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +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 +40 -1
  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 +2509 -189
  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 +63 -21
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +138 -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 +408 -34
  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 +223 -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.rb +21 -5
  218. data/lib/woods/session_tracer/file_store.rb +138 -19
  219. data/lib/woods/session_tracer/middleware.rb +1 -2
  220. data/lib/woods/session_tracer/redis_store.rb +122 -12
  221. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  222. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  223. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  224. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  225. data/lib/woods/session_tracer/store.rb +14 -1
  226. data/lib/woods/storage/metadata_store.rb +230 -26
  227. data/lib/woods/storage/pgvector.rb +180 -22
  228. data/lib/woods/storage/qdrant.rb +367 -41
  229. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  230. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  231. data/lib/woods/storage/snapshotter.rb +23 -5
  232. data/lib/woods/storage/vector_store.rb +49 -8
  233. data/lib/woods/storage_identity.rb +28 -0
  234. data/lib/woods/tasks.rb +53 -2
  235. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  236. data/lib/woods/temporal/snapshot_store.rb +139 -42
  237. data/lib/woods/unblocked/client.rb +119 -17
  238. data/lib/woods/unblocked/document_builder.rb +34 -2
  239. data/lib/woods/unblocked/exporter.rb +63 -27
  240. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  241. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  242. data/lib/woods/update_check.rb +24 -1
  243. data/lib/woods/util/uuid5.rb +124 -0
  244. data/lib/woods/version.rb +1 -1
  245. data/lib/woods/watch/daemon.rb +1345 -0
  246. data/lib/woods/watch/listen_watcher.rb +81 -0
  247. data/lib/woods/watch/polling_watcher.rb +137 -0
  248. data/lib/woods/watch/status.rb +169 -0
  249. data/lib/woods/watch/tree_scan.rb +163 -0
  250. data/lib/woods/watch/watcher.rb +100 -0
  251. data/lib/woods.rb +53 -9
  252. data/plugin/.claude-plugin/plugin.json +18 -0
  253. data/plugin/hooks/hooks.json +29 -0
  254. data/plugin/hooks/woods-post-edit.sh +226 -0
  255. data/plugin/hooks/woods-session-start.sh +77 -0
  256. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  257. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  258. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  259. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  260. data/plugin/skills/woods-setup/SKILL.md +99 -0
  261. metadata +134 -23
  262. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  263. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  264. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  265. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  266. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  267. data/lib/woods/console/bridge.rb +0 -210
  268. data/lib/woods/formatting/claude_adapter.rb +0 -98
  269. data/lib/woods/formatting/generic_adapter.rb +0 -56
  270. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  271. data/lib/woods/notion/mapper.rb +0 -40
  272. data/lib/woods/observability/health_check.rb +0 -79
  273. 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,53 @@ 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
+ # @return [Hash{String => Hash{String => String}}] per-controller
93
+ # annotation (action => relative flow path) to write into the units'
94
+ # metadata; an empty hash means "no flows", which clears any
95
+ # annotation a previous run had written
96
+ def recompute_delta(touched_units:, removed_identifiers: [])
97
+ FileUtils.mkdir_p(@flows_dir)
98
+
99
+ assembler = FlowAssembler.new(graph: @graph, extracted_dir: @output_dir)
100
+ delta = {}
101
+ annotations = {}
102
+
103
+ touched_units.each do |unit|
104
+ # A per-action skip would publish an index missing that entry, so
105
+ # assembly raises. The incremental path must not advance publication
106
+ # over a broken assembly; the caller aborts before the generation
107
+ # bump.
108
+ entries, unit_flow_paths = assemble_controller_unit(assembler, unit)
109
+ annotations[unit.identifier] = unit_flow_paths
110
+ delta.merge!(entries)
111
+ end
112
+
113
+ replaced = touched_units.map(&:identifier) + Array(removed_identifiers)
114
+ carried = previous_flow_index.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
115
+
116
+ write_flow_index(carried.merge(delta))
117
+
118
+ annotations
119
+ end
120
+
65
121
  private
66
122
 
67
123
  # Filter units to only controllers.
@@ -71,33 +127,93 @@ module Woods
71
127
  @units.select { |u| u.type.to_s == 'controller' }
72
128
  end
73
129
 
130
+ # Assemble and write every flow for one controller unit.
131
+ #
132
+ # Both entry points fail closed: a per-action failure aborts the run
133
+ # rather than publishing a flow index missing that entry (EXTB-14 — the
134
+ # old `fail_closed:` switch documented a log-and-skip contract for the
135
+ # full path that no caller ever asked for).
136
+ #
137
+ # @param assembler [FlowAssembler]
138
+ # @param unit [ExtractedUnit]
139
+ # @return [Array(Hash{String => String}, Hash{String => String})] the
140
+ # entry_point => relative-path entries for the flow index, and the
141
+ # action => relative-path annotation for the unit's metadata
142
+ # @raise [Woods::ExtractionError] when an action fails to assemble
143
+ def assemble_controller_unit(assembler, unit)
144
+ entries = {}
145
+ unit_flow_paths = {}
146
+
147
+ actions = unit.metadata[:actions] || unit.metadata['actions'] || []
148
+ actions.each do |action|
149
+ entry_point = "#{unit.identifier}##{action}"
150
+ flow_path = assemble_and_write(assembler, entry_point, unit.identifier, action)
151
+
152
+ entries[entry_point] = flow_path
153
+ unit_flow_paths[action] = flow_path
154
+ end
155
+
156
+ [entries, unit_flow_paths]
157
+ end
158
+
159
+ # The controller part of a flow index entry point.
160
+ #
161
+ # @param entry_point [String]
162
+ # @return [String]
163
+ def controller_of(entry_point)
164
+ entry_point.to_s.split('#', 2).first
165
+ end
166
+
167
+ # The previous generation's flow index, or an empty hash when none was
168
+ # published. A missing index means there is nothing to carry forward
169
+ # (the caller established whether the family is genuinely absent); a
170
+ # corrupt one raises — publication must not advance over unreadable
171
+ # prior flow state.
172
+ #
173
+ # @return [Hash{String => String}]
174
+ # @raise [Woods::ExtractionError] when the index does not parse
175
+ def previous_flow_index
176
+ path = File.join(@flows_dir, 'flow_index.json')
177
+ return {} unless File.exist?(path)
178
+
179
+ JSON.parse(Woods::AtomicFile.read(path))
180
+ rescue JSON::ParserError => e
181
+ raise Woods::ExtractionError, "previous flow_index.json does not parse: #{e.message}"
182
+ end
183
+
74
184
  # Assemble a flow for one entry point and write the JSON file.
75
185
  #
186
+ # Written via {Woods::AtomicFile} (temp + fsync + rename) like every
187
+ # other index artifact — a plain +File.write+ interrupted mid-write left
188
+ # a torn partial for the MCP read side to trip over.
189
+ #
76
190
  # @param assembler [FlowAssembler]
77
191
  # @param entry_point [String]
78
192
  # @param controller_id [String]
79
193
  # @param action [String]
80
- # @return [String, nil] The written file path, or nil on failure
194
+ # @return [String] The output_dir-relative path of the written file
195
+ # (e.g. "flows/OrdersController_create.json")
196
+ # @raise [Woods::ExtractionError] when assembly or the write fails
81
197
  def assemble_and_write(assembler, entry_point, controller_id, action)
82
198
  flow = assembler.assemble(entry_point, max_depth: @max_depth)
83
199
 
84
200
  filename = Woods::FilenameUtils.flow_filename(controller_id, action)
85
- flow_path = File.join(@flows_dir, filename)
86
201
 
87
- File.write(flow_path, canonical_json(flow.to_h))
202
+ Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h))
88
203
 
89
- flow_path
204
+ # Relative — this value is persisted (flow_index.json and the unit's
205
+ # metadata[:flow_paths]), so it must not carry this machine's root.
206
+ File.join('flows', filename)
90
207
  rescue StandardError => e
91
- Rails.logger.error("[Woods] Flow precompute failed for #{entry_point}: #{e.message}")
92
- nil
208
+ raise Woods::ExtractionError, "flow precompute failed for #{entry_point}: #{e.message}"
93
209
  end
94
210
 
95
- # Write the flow index mapping entry points to file paths.
211
+ # Write the flow index mapping entry points to output_dir-relative file paths.
96
212
  #
97
213
  # @param flow_map [Hash{String => String}]
98
214
  def write_flow_index(flow_map)
99
215
  index_path = File.join(@flows_dir, 'flow_index.json')
100
- File.write(index_path, canonical_json(flow_map))
216
+ Woods::AtomicFile.write(index_path, canonical_json(flow_map))
101
217
  end
102
218
 
103
219
  # 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
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'pathname'
5
+ require 'digest'
6
+ require 'securerandom'
7
+
8
+ require_relative 'atomic_file'
9
+
10
+ module Woods
11
+ # A monotonic marker for "which version of the index is on disk".
12
+ #
13
+ # The index is a directory of files, so a reader has no cheap way to ask
14
+ # "has this changed since I cached it?" — it would have to stat everything.
15
+ # This is that cheap way: one small file, bumped as the *last* step of any
16
+ # write, holding a counter that only ever increases plus a token that
17
+ # changes whenever the counter does.
18
+ #
19
+ # Two properties matter, and both come from #164's central rule — never
20
+ # advance a cursor over work that didn't land:
21
+ #
22
+ # * **Bumped last.** Callers write the index, then bump. A reader that sees
23
+ # generation N therefore knows the files for N are already there.
24
+ # * **Not bumped on failure.** A run that raised, or that changed nothing,
25
+ # leaves the generation alone. Staleness is then honest: the reader keeps
26
+ # serving N and knows it.
27
+ #
28
+ # The write itself is atomic ({AtomicFile}), so a reader never sees a torn
29
+ # generation file. It can also carry a **payload pointer** — the name of an
30
+ # immutable per-generation directory the payload was published into. When
31
+ # present, the single atomic write of this file is the one commit point for
32
+ # the whole payload: a reader resolves every artifact of a read through the
33
+ # directory this generation names, so it sees one generation whole rather
34
+ # than a mix of a manifest from N+1 next to a unit from N. The pointer is
35
+ # optional — an index written flat (payload files directly under the output
36
+ # directory) carries none, and readers fall back to the output directory.
37
+ #
38
+ # @example Publishing after a successful write
39
+ # Woods::Generation.new(output_dir: "tmp/woods").bump!(reason: "incremental")
40
+ # # => #<Generation::Marker number=42 token="9f2c…" reason="incremental">
41
+ #
42
+ class Generation
43
+ FILENAME = 'generation.json'
44
+
45
+ # An immutable snapshot of the generation file.
46
+ #
47
+ # @!attribute number
48
+ # @return [Integer] monotonically increasing, starting at 1
49
+ # @!attribute token
50
+ # @return [String] opaque; changes on every bump
51
+ # @!attribute updated_at
52
+ # @return [String, nil] ISO8601 stamp of the bump
53
+ # @!attribute reason
54
+ # @return [String, nil] what produced this generation
55
+ # @!attribute payload
56
+ # @return [String, nil] name of the immutable directory this generation's
57
+ # payload lives in, relative to the output directory. nil for an index
58
+ # written flat (payload files directly under the output directory).
59
+ Marker = Struct.new(:number, :token, :updated_at, :reason, :payload, keyword_init: true) do
60
+ # @return [Hash] string-keyed, ready to serialize. The +payload+ key is
61
+ # emitted only when set, so a flat index's generation file is byte-for-
62
+ # byte what it always was.
63
+ def to_h
64
+ base = { 'number' => number, 'token' => token, 'updated_at' => updated_at, 'reason' => reason }
65
+ base['payload'] = payload if payload
66
+ base
67
+ end
68
+ end
69
+
70
+ # Marker returned when no generation file exists yet — an index written
71
+ # before generations existed, or a directory nothing has published to.
72
+ UNPUBLISHED = Marker.new(number: 0, token: nil, updated_at: nil, reason: nil).freeze
73
+
74
+ # @param output_dir [String, Pathname] index directory
75
+ # @param clock [#call] returns the ISO8601 stamp for a bump; injectable
76
+ # so specs don't depend on wall-clock time
77
+ def initialize(output_dir:, clock: -> { Time.now.utc.iso8601 })
78
+ @path = File.join(output_dir.to_s, FILENAME)
79
+ @clock = clock
80
+ end
81
+
82
+ # @return [String] absolute path to the generation file
83
+ attr_reader :path
84
+
85
+ # The generation currently published.
86
+ #
87
+ # A missing or unreadable file reads as {UNPUBLISHED} rather than raising:
88
+ # a reader asking "what generation is this?" of a directory that has none
89
+ # wants an answer, not an exception.
90
+ #
91
+ # @return [Marker]
92
+ def current
93
+ return UNPUBLISHED unless File.exist?(@path)
94
+
95
+ data = JSON.parse(AtomicFile.read(@path))
96
+ Marker.new(
97
+ number: data['number'].to_i,
98
+ token: data['token'],
99
+ updated_at: data['updated_at'],
100
+ reason: data['reason'],
101
+ payload: data['payload']
102
+ )
103
+ rescue JSON::ParserError, SystemCallError
104
+ UNPUBLISHED
105
+ end
106
+
107
+ # Advance to the next generation.
108
+ #
109
+ # Call this *after* the index files are written, never before, and never
110
+ # after a run that failed or wrote nothing.
111
+ #
112
+ # @param reason [String, nil] what produced this generation, for humans
113
+ # reading `woods_status`
114
+ # @param payload [String, nil] name of the immutable directory this
115
+ # generation's payload was published into, relative to the output
116
+ # directory. Omit for a flat index; when given, this bump becomes the
117
+ # single commit point for that whole payload (see the class docs).
118
+ # @return [Marker] the newly published generation
119
+ def bump!(reason: nil, payload: nil)
120
+ marker = Marker.new(
121
+ number: current.number + 1,
122
+ token: SecureRandom.hex(8),
123
+ updated_at: @clock.call,
124
+ reason: reason,
125
+ payload: payload
126
+ )
127
+ AtomicFile.write(@path, JSON.generate(marker.to_h))
128
+ marker
129
+ end
130
+
131
+ # The directory a generation's payload artifacts live in.
132
+ #
133
+ # Returns the index root when there is no pointer (an index written flat,
134
+ # which is every index written before payloads existed), when the named
135
+ # directory is gone (a stale pointer — a payload retention pruned), or
136
+ # when the name would escape the index root. A torn or tampered pointer
137
+ # must degrade to the flat layout, never read outside the index; the name
138
+ # is written by Woods and is relative by contract.
139
+ #
140
+ # Every reader of a payload artifact resolves through this, so the pointer
141
+ # is followed identically wherever it is read.
142
+ #
143
+ # @param marker [Marker] the generation to resolve; defaults to the
144
+ # published one. Pass an already-loaded marker to avoid re-reading, and
145
+ # to resolve a generation a caller has pinned rather than the current one.
146
+ # @return [Pathname]
147
+ def payload_dir(marker = current)
148
+ name = marker.payload
149
+ return root if name.nil? || name.empty?
150
+
151
+ candidate = root.join(name)
152
+ return root unless candidate.directory?
153
+ return root unless within_root?(candidate)
154
+
155
+ candidate
156
+ end
157
+
158
+ # @return [Pathname] the index directory this generation lives in
159
+ def root
160
+ @root ||= Pathname.new(File.dirname(@path))
161
+ end
162
+
163
+ private
164
+
165
+ # Does +candidate+ resolve inside the index root?
166
+ #
167
+ # Compared through +realpath+, mirroring
168
+ # {Woods::IndexArtifact#validate_dump_dir!} (B-134). +expand_path+ is
169
+ # textual, so a symlink planted *inside* +payloads/+ and pointing outside
170
+ # the index passed the check while +Pathname#directory?+ happily followed
171
+ # it — aiming every payload reader at an arbitrary directory (CORE-6). An
172
+ # index reached through a symlinked root still resolves, because both
173
+ # sides go through realpath.
174
+ #
175
+ # @param candidate [Pathname] an existing directory
176
+ # @return [Boolean] false when it cannot be resolved at all
177
+ def within_root?(candidate)
178
+ candidate_real = candidate.realpath.to_s
179
+ root_real = root.realpath.to_s
180
+ candidate_real.start_with?("#{root_real}#{File::SEPARATOR}")
181
+ rescue SystemCallError
182
+ false
183
+ end
184
+ end
185
+ end