woods 2.0.0.beta2 → 2.0.0.beta4

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
@@ -28,12 +28,15 @@ module Woods
28
28
  ASSOCIATION_ORDER = %w[belongs_to has_one has_many has_and_belongs_to_many].freeze
29
29
 
30
30
  # @param name_mapper [NameMapper] resolves ids to wikilinks/paths
31
- # @param nodes [Hash] graph nodes ({ id => { 'type' => , ... } }) for edge-endpoint types
31
+ # @param nodes [Hash] note-key => graph node for edge-endpoint types
32
32
  # @param pagerank [Hash{String=>Float}] persisted pagerank scores
33
33
  # @param analysis [Hash, nil] parsed graph_analysis.json (hubs/cycles/orphans/bridges) or nil
34
34
  # @param include_source [Boolean] embed scrubbed source code
35
35
  # @param scanner [#scan, nil] credential scanner (required when include_source)
36
- def initialize(name_mapper:, nodes:, pagerank: {}, analysis: nil, include_source: false, scanner: nil)
36
+ def initialize(name_mapper:, nodes:, pagerank: {}, analysis: nil, include_source: false, scanner: nil,
37
+ identifiers: {}, ambiguous_identifiers: Set.new)
38
+ @identifiers = identifiers
39
+ @ambiguous_identifiers = ambiguous_identifiers
37
40
  @mapper = name_mapper
38
41
  @nodes = nodes || {}
39
42
  @pagerank = pagerank || {}
@@ -45,10 +48,10 @@ module Woods
45
48
  @cycle_members = cycle_member_set(analysis)
46
49
  end
47
50
 
48
- # @param id [String] bare unit identifier (graph node key)
51
+ # @param id [String, Array<String>] note key: bare id for standalone use, or [identifier, type]
49
52
  # @param unit [Hash] parsed unit JSON (used only for body facts)
50
53
  # @param depends_on [Array<Hash>] [{ target:, via: }] filtered to the emitted set
51
- # @param used_by [Array<String>] unique dependent ids filtered to the emitted set
54
+ # @param used_by [Array<String, Array<String>>] unique dependent note keys filtered to the emitted set
52
55
  # @return [String] the rendered note. A failed source scrub omits the source
53
56
  # section (the note is still written); build never returns nil.
54
57
  def build(id:, unit:, depends_on:, used_by:)
@@ -74,7 +77,7 @@ module Woods
74
77
  def frontmatter(id, unit, depends_on, used_by)
75
78
  fm = {
76
79
  'woods_managed' => true,
77
- 'id' => id,
80
+ 'id' => @identifiers.fetch(id, id),
78
81
  'type' => unit['type'],
79
82
  'file' => unit['file_path'],
80
83
  'source_hash' => unit['source_hash'],
@@ -82,17 +85,23 @@ module Woods
82
85
  'dependency_count' => depends_on.size,
83
86
  'dependent_count' => used_by.size,
84
87
  'tags' => tags_for(id, unit),
85
- 'aliases' => [id]
88
+ 'aliases' => [@identifiers.fetch(id, id)]
86
89
  }.compact
87
90
  "#{fm.to_yaml}---"
88
91
  end
89
92
 
93
+ def analysis_identifier(id)
94
+ identifier = @identifiers.fetch(id, id)
95
+ identifier unless @ambiguous_identifiers.include?(identifier)
96
+ end
97
+
90
98
  def pagerank_for(id)
91
- score = @pagerank[id]
99
+ score = @pagerank[analysis_identifier(id)]
92
100
  score&.round(6)
93
101
  end
94
102
 
95
103
  def tags_for(id, unit)
104
+ id = analysis_identifier(id)
96
105
  tags = ['woods/unit', "woods/#{unit['type']}"]
97
106
  tags << 'woods/hub' if @hubs.include?(id)
98
107
  tags << 'woods/orphan' if @orphans.include?(id)
@@ -124,11 +133,11 @@ module Woods
124
133
  end
125
134
 
126
135
  def callout(id, used_by)
127
- if @hubs.include?(id)
136
+ if @hubs.include?(analysis_identifier(id))
128
137
  pr = pagerank_for(id)
129
138
  suffix = pr ? " (PageRank #{format('%.4f', pr)})" : ''
130
139
  "> [!warning] Hub — high blast radius\n> #{used_by.size} units depend on this#{suffix}."
131
- elsif @cycle_members.include?(id)
140
+ elsif @cycle_members.include?(analysis_identifier(id))
132
141
  "> [!warning] Part of a dependency cycle\n> This unit participates in a circular dependency."
133
142
  end
134
143
  end
@@ -181,7 +190,7 @@ module Woods
181
190
  return [] unless items
182
191
 
183
192
  items.filter_map do |assoc|
184
- link = @mapper.wikilink(assoc[:target])
193
+ link = @mapper.wikilink_for_identifier(assoc[:target])
185
194
  next unless link
186
195
 
187
196
  assoc[:dependent] ? "#{link} (dependent: #{assoc[:dependent]})" : link
@@ -10,6 +10,7 @@ require_relative 'errors'
10
10
  require_relative 'name_mapper'
11
11
  require_relative 'note_builder'
12
12
  require_relative 'vault_assets'
13
+ require_relative 'destination_plan'
13
14
 
14
15
  module Woods
15
16
  module Obsidian
@@ -72,7 +73,7 @@ module Woods
72
73
  def export_all
73
74
  with_pinned_index do
74
75
  graph = @reader.raw_graph_data || {}
75
- @nodes = graph['nodes'] || {}
76
+ prepare_graph(graph)
76
77
  @pagerank = graph['pagerank'] || {}
77
78
  analysis = safe_graph_analysis
78
79
  owned = vault_owned_at_start?
@@ -82,16 +83,24 @@ module Woods
82
83
  edge_map = build_edge_map(emitted.to_set, graph)
83
84
  builder = build_note_builder(mapper, analysis)
84
85
 
86
+ @planned_writes = {}
85
87
  written = Set.new
86
88
  errors = []
87
89
  exported = write_notes(emitted, mapper, edge_map, builder, written, errors)
88
90
  indexes = write_indexes(emitted, mapper, written)
89
91
  write_sidecar(emitted, mapper, graph, analysis, written)
90
92
  write_owned_assets(emitted) if owned
93
+ DestinationPlan.new(@vault, @planned_writes, managed_note: method(:managed_marker?)).commit
91
94
 
92
- swept = owned && errors.empty? ? sweep(written) : 0
95
+ swept = owned && errors.empty? && skipped.zero? ? sweep(written) : 0
93
96
  warn_foreign unless owned
94
97
  { exported: exported, indexes: indexes, swept: swept, skipped: skipped, errors: cap_errors(errors) }
98
+ rescue StandardError => e
99
+ log " Export stopped: #{e.class}: #{e.message}"
100
+ { exported: 0, indexes: 0, swept: 0, skipped: skipped || 0,
101
+ errors: cap_errors(Array(errors) + ["#{e.class}: #{e.message}"]) }
102
+ ensure
103
+ @planned_writes = nil
95
104
  end
96
105
  end
97
106
 
@@ -119,19 +128,35 @@ module Woods
119
128
 
120
129
  # ── Selection / mapping ──────────────────────────────────────────
121
130
 
131
+ # Keep typed identity local to the exporter. Public identifiers and graph
132
+ # artifacts retain their original shape; variants have their own outgoing
133
+ # relationships, while persisted edge targets remain bare identifiers.
134
+ def prepare_graph(graph)
135
+ @primary = graph['nodes'] || {}
136
+ @nodes = @primary.to_h { |id, node| [[id, node['type']], node] }
137
+ @forward = @nodes.keys.to_h { |key| [key, Array(graph.dig('edges', key.first))] }
138
+ Array(graph['variants']).grep(Hash).each do |record|
139
+ key = [record['identifier'], record['type']]
140
+ next if key.any?(&:nil?) || @nodes.key?(key)
141
+
142
+ @nodes[key] = record
143
+ @forward[key] = Array(record['edges'])
144
+ end
145
+ @identifiers = @nodes.keys.to_h { |key| [key, key.first] }
146
+ @keys_by_identifier = @nodes.keys.group_by(&:first)
147
+ @ambiguous = @keys_by_identifier.select { |_, keys| keys.size > 1 }.keys.to_set
148
+ end
149
+
122
150
  # The emitted set = graph nodes of an exportable type that have a loadable
123
151
  # unit file. Confirming the file loads (not just that the index lists it)
124
152
  # guarantees every wikilink target is backed by a real note — a node listed
125
153
  # in the index but with no readable unit is dropped (counted as skipped).
126
154
  #
127
- # @return [Array(Array<String>, Integer)] [sorted emitted ids, skipped count]
155
+ # @return [Array(Array<Array<String>>, Integer)] [sorted typed keys, skipped count]
128
156
  def partition_emitted
129
- known = known_ids
130
157
  candidates = @nodes.keys.select do |id|
131
158
  type = @nodes[id]['type']
132
- next false if !@include_framework && FRAMEWORK_TYPES.include?(type)
133
-
134
- known.include?(id)
159
+ @include_framework || !FRAMEWORK_TYPES.include?(type)
135
160
  end.sort
136
161
 
137
162
  # Load each candidate once and keep it: confirms the unit is readable
@@ -151,18 +176,20 @@ module Woods
151
176
  # Load a unit, treating an unreadable/corrupt file as absent rather than
152
177
  # letting one bad JSON parse abort the whole export.
153
178
  def load_unit(id)
154
- @reader.find_unit(id)
179
+ unit = if @ambiguous.include?(id.first)
180
+ @reader.find_unit(id.first, type: id.last)
181
+ else
182
+ @reader.find_unit(id.first)
183
+ end
184
+ unit if unit && unit['identifier'] == id.first && unit['type'] == id.last
155
185
  rescue StandardError => e
156
186
  log " skipped #{id}: #{e.class}: #{e.message}"
157
187
  nil
158
188
  end
159
189
 
160
- def known_ids
161
- @known_ids ||= @reader.list_units.to_set { |entry| entry['identifier'] }
162
- end
163
-
164
190
  def build_mapper(emitted)
165
- NameMapper.new(emitted.to_h { |id| [id, dir_for(@nodes[id]['type'])] })
191
+ NameMapper.new(emitted.to_h { |id| [id, dir_for(@nodes[id]['type'])] },
192
+ identifiers: @identifiers, ambiguous_identifiers: @ambiguous)
166
193
  end
167
194
 
168
195
  def dir_for(type)
@@ -182,25 +209,37 @@ module Woods
182
209
 
183
210
  def build_note_builder(mapper, analysis)
184
211
  NoteBuilder.new(name_mapper: mapper, nodes: @nodes, pagerank: @pagerank,
185
- analysis: analysis, include_source: @include_source, scanner: scanner)
186
- end
187
-
188
- # The single edge source: per-id forward (depends_on) and reverse (used_by)
189
- # edges from the graph, filtered to the emitted set, self-edges dropped.
190
- def build_edge_map(emitted, graph)
191
- edges = graph['edges'] || {}
192
- reverse = graph['reverse'] || {}
193
- emitted.each_with_object({}) do |id, map|
194
- deps = Array(edges[id]).filter_map { |edge| forward_edge(edge, id, emitted) }
195
- .uniq { |edge| [edge[:target], edge[:via]] }
196
- used = Array(reverse[id]).select { |sid| sid != id && emitted.include?(sid) }.uniq
197
- map[id] = { depends_on: deps, used_by: used }
212
+ analysis: analysis, include_source: @include_source, scanner: scanner,
213
+ identifiers: @identifiers, ambiguous_identifiers: @ambiguous)
214
+ end
215
+
216
+ # Reverse links must come from resolved forward edges: the legacy reverse
217
+ # index merges source types, and cannot assign them to a particular note.
218
+ # A bare target naming several graph types remains ambiguous even if only
219
+ # one sibling is exportable. Do not invent a typed relationship.
220
+ def build_edge_map(emitted, _graph)
221
+ @ambiguous_edges = Set.new
222
+ map = emitted.to_h { |id| [id, { depends_on: [], used_by: [] }] }
223
+ emitted.each do |id|
224
+ deps = @forward[id].filter_map { |edge| forward_edge(edge, id, emitted) }
225
+ .uniq { |edge| [edge[:target], edge[:via]] }
226
+ map[id][:depends_on] = deps
227
+ deps.each { |edge| map[edge[:target]][:used_by] << id }
198
228
  end
229
+ map.each_value { |edges| edges[:used_by].uniq! }
230
+ log " omitted #{@ambiguous_edges.size} ambiguous dependency relationships" unless @ambiguous_edges.empty?
231
+ map
199
232
  end
200
233
 
201
234
  def forward_edge(edge, source_id, emitted)
202
235
  edge = { 'target' => edge } unless edge.is_a?(Hash)
203
- target = edge['target']
236
+ identifier = edge['target']
237
+ if @ambiguous.include?(identifier)
238
+ @ambiguous_edges << [source_id, identifier, edge['via']]
239
+ return nil
240
+ end
241
+
242
+ target = @keys_by_identifier[identifier]&.first
204
243
  return nil if target == source_id || !emitted.include?(target)
205
244
 
206
245
  { target: target, via: edge['via'] }
@@ -263,11 +302,22 @@ module Woods
263
302
  # ── Pass 3b: machine sidecar ─────────────────────────────────────
264
303
 
265
304
  def write_sidecar(emitted, mapper, graph, analysis, written)
266
- notes = emitted.to_h do |id|
267
- [id, { 'path' => mapper.path_for(id), 'type' => @nodes[id]['type'],
268
- 'pagerank' => @pagerank[id]&.round(6) }.compact]
305
+ notes = {}
306
+ variants = []
307
+ emitted.each do |key|
308
+ id, type = key
309
+ info = { 'path' => mapper.path_for(key), 'type' => type }
310
+ info['pagerank'] = @pagerank[id].round(6) if @pagerank[id] && !@ambiguous.include?(id)
311
+ if @primary.dig(id, 'type') == type
312
+ notes[id] = info
313
+ else
314
+ variants << { 'identifier' => id }.merge(info)
315
+ end
269
316
  end
270
- manifest = { 'schema_version' => 1, 'notes' => sort_hash(notes), 'paths' => sort_hash(mapper.paths_to_ids) }
317
+ paths = mapper.paths_to_ids.transform_values(&:first)
318
+ manifest = { 'schema_version' => @ambiguous.empty? ? 1 : 2,
319
+ 'notes' => sort_hash(notes), 'paths' => sort_hash(paths) }
320
+ manifest['variants'] = variants if manifest['schema_version'] == 2
271
321
  write_json(written, "#{SIDECAR_DIR}/manifest.json", manifest)
272
322
  write_json(written, "#{SIDECAR_DIR}/dependency_graph.json", graph)
273
323
  write_json(written, "#{SIDECAR_DIR}/graph_analysis.json", analysis) if analysis
@@ -282,6 +332,8 @@ module Woods
282
332
  safe_write(@vault.join('.obsidian', 'graph.json'), VaultAssets.graph_json(present_types(emitted)))
283
333
  safe_write(@vault.join('Units.base'), VaultAssets.units_base)
284
334
  end
335
+ return if @vault.join(SENTINEL).exist?
336
+
285
337
  safe_write(@vault.join(SENTINEL),
286
338
  "woods-managed vault — regenerated by `rake woods:obsidian`. Safe to delete.\n")
287
339
  end
@@ -410,7 +462,11 @@ module Woods
410
462
  def safe_write(path, content)
411
463
  raise PathTraversalError, "refusing to write outside the vault: #{path}" unless safe_inside_vault?(path)
412
464
 
413
- AtomicFile.write(path, content)
465
+ if @planned_writes
466
+ @planned_writes[path] = content
467
+ else
468
+ AtomicFile.write(path, content)
469
+ end
414
470
  end
415
471
 
416
472
  # ── Ownership ────────────────────────────────────────────────────
@@ -85,14 +85,18 @@ module Woods
85
85
 
86
86
  # Reset one supported operation or all supported operations atomically.
87
87
  #
88
+ # An explicit :all reset also repairs corrupt state; scoped resets leave it
89
+ # untouched so unknown cooldowns are not silently discarded.
90
+ #
88
91
  # @param operation [Symbol, String] `:extraction`, `:embedding`, or `:all`
89
- # @return [Boolean] true when at least one cooldown was removed
92
+ # @return [Boolean] true when a cooldown was removed or corrupt state repaired
90
93
  # @raise [ArgumentError] when the operation is unsupported
91
94
  def reset!(operation)
92
95
  operations = reset_operations(operation)
93
- return false unless requested_state_present?(operations)
96
+ repair_corrupt = operation.to_s == 'all'
97
+ return false unless requested_state_present?(operations, repair_corrupt: repair_corrupt)
94
98
 
95
- with_existing_locked_state do |state|
99
+ with_existing_locked_state(repair_corrupt: repair_corrupt) do |state|
96
100
  changed = false
97
101
  operations.each do |key|
98
102
  next unless state.key?(key)
@@ -135,28 +139,29 @@ module Woods
135
139
  end
136
140
  end
137
141
 
138
- def parse_state(content)
139
- state = content.empty? ? {} : JSON.parse(content)
140
- state.is_a?(Hash) ? state : {}
142
+ def parse_state(content, fallback: {})
143
+ state = JSON.parse(content)
144
+ state.is_a?(Hash) ? state : fallback
141
145
  rescue JSON::ParserError
142
- {}
146
+ fallback
143
147
  end
144
148
 
145
- def requested_state_present?(operations)
149
+ def requested_state_present?(operations, repair_corrupt:)
146
150
  File.open(@state_path, File::RDONLY) do |file|
147
151
  file.flock(File::LOCK_SH)
148
- state = parse_state(file.read)
149
- operations.any? { |key| state.key?(key) }
152
+ state = parse_state(file.read, fallback: nil)
153
+ state ? operations.any? { |key| state.key?(key) } : repair_corrupt
150
154
  end
151
155
  rescue Errno::ENOENT
152
156
  false
153
157
  end
154
158
 
155
- def with_existing_locked_state
159
+ def with_existing_locked_state(repair_corrupt:)
156
160
  File.open(@state_path, File::RDWR) do |file|
157
161
  file.flock(File::LOCK_EX)
158
- state = parse_state(file.read)
159
- changed = yield(state)
162
+ parsed = parse_state(file.read, fallback: nil)
163
+ state = parsed || {}
164
+ changed = yield(state) || (repair_corrupt && parsed.nil?)
160
165
  if changed
161
166
  file.rewind
162
167
  file.write(JSON.generate(state))
@@ -16,10 +16,9 @@ module Woods
16
16
  #
17
17
  # * {.file_rules} — file-based extractors, whose per-file method can be
18
18
  # pointed straight at the new path.
19
- # * {.whole_app_rules} — extractors with no per-file entry point (routes,
20
- # middleware, engines, scheduled jobs, state machines, factories,
21
- # events, framework sources). Their trigger paths map to a wholesale
22
- # re-run of that extractor, which is cheap in an already-booted process.
19
+ # * {.whole_app_rules} — extractors needing the complete runtime or source
20
+ # set (routes, middleware, merged Rake tasks, scheduled jobs, etc.). Their
21
+ # trigger paths map to a wholesale re-run of that extractor.
23
22
  #
24
23
  # Class-based extractors (models, controllers, mailers, components,
25
24
  # channels) are deliberately *absent* here. They are reconciled against
@@ -94,6 +93,11 @@ module Woods
94
93
  end
95
94
 
96
95
  class << self
96
+ # Runtime-discovered classes have no per-file extractor method.
97
+ def runtime_rules
98
+ @runtime_rules ||= [Rule.new(dirs: %w[app], extensions: %w[.rb])].freeze
99
+ end
100
+
97
101
  # Rules for extractors with a per-file entry point.
98
102
  #
99
103
  # @return [Array<Rule>]
@@ -147,6 +151,7 @@ module Woods
147
151
  # ConcernExtractor globs app/**/concerns, not just the two canonical
148
152
  # directories — match any .rb under app/ inside a concerns/ segment.
149
153
  file_rule(:concerns, :extract_concern_file, %w[app], require_segment: '/concerns/'),
154
+ file_rule(:concerns, :extract_model_mixin_file, %w[app lib], exclude: %w[/concerns/]),
150
155
  # GraphQL types sit outside FILE_BASED (they share one extractor
151
156
  # method across four unit types via GRAPHQL_TYPES), which is exactly
152
157
  # why the FILE_BASED-driven coverage guard never noticed they had no
@@ -155,7 +160,6 @@ module Woods
155
160
  file_rule(:graphql, :extract_graphql_file,
156
161
  [ex::GraphQLExtractor::GRAPHQL_DIRECTORY], extensions: %w[.rb]),
157
162
  file_rule(:i18n, :extract_i18n_file, ex::I18nExtractor::I18N_DIRECTORIES, extensions: %w[.yml]),
158
- file_rule(:rake_tasks, :extract_rake_file, ex::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake]),
159
163
  file_rule(:view_templates, :extract_view_template_file,
160
164
  ex::ViewTemplateExtractor::VIEW_DIRECTORIES, extensions: view_template_extensions),
161
165
  file_rule(:migrations, :extract_migration_file, %w[db/migrate], recursive: false),
@@ -182,6 +186,9 @@ module Woods
182
186
 
183
187
  def build_whole_app_rules
184
188
  [
189
+ # A task may combine definitions from several files; any change or
190
+ # deletion must reconcile the complete task set, not its primary file.
191
+ whole_app_rule(:rake_tasks, Woods::Extractors::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake]),
185
192
  whole_app_rule(:routes, %w[config/routes], exact_paths: %w[config/routes.rb]),
186
193
  whole_app_rule(:engines, %w[config/routes], exact_paths: %w[config/routes.rb Gemfile.lock]),
187
194
  whole_app_rule(:middleware, %w[config/initializers config/environments],
@@ -258,7 +265,7 @@ module Woods
258
265
  # @param relative_path [String] Rails.root-relative path
259
266
  # @return [Boolean]
260
267
  def relevant?(relative_path)
261
- return true if relative_path.start_with?('app/') && relative_path.end_with?('.rb')
268
+ return true if self.class.runtime_rules.any? { |rule| rule.matches?(relative_path) }
262
269
 
263
270
  file_rules_for(relative_path).any? || whole_app_keys_for(relative_path).any?
264
271
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'pathname'
4
4
  require 'fileutils'
5
- require 'find'
6
5
 
7
6
  module Woods
8
7
  # Writer-side owner of the per-generation payload directories.
@@ -95,19 +94,19 @@ module Woods
95
94
  # @param target [Pathname, String] a directory returned by {#create}
96
95
  # @return [void]
97
96
  def clone(source, target)
98
- from = Pathname.new(source.to_s)
99
- return unless from.directory?
100
-
101
- to = Pathname.new(target.to_s)
102
- # The one directory `find` never offers: it yields the root as `.`, and
103
- # a caller seeding a subtree (Extractor#seed_payload_from_flat_root)
104
- # passes a target that does not exist yet.
105
- FileUtils.mkdir_p(to.to_s)
106
- from.find do |entry|
107
- relative = entry.relative_path_from(from)
108
- next if relative.to_s == '.'
109
-
110
- replicate(entry, to.join(relative))
97
+ from = File.expand_path(source.to_s)
98
+ return unless File.directory?(from)
99
+
100
+ to = target.to_s
101
+ FileUtils.mkdir_p(to)
102
+ pending = [[from, to]]
103
+ until pending.empty?
104
+ source_dir, target_dir = pending.pop
105
+ Dir.each_child(source_dir) do |name|
106
+ entry = File.join(source_dir, name)
107
+ destination = File.join(target_dir, name)
108
+ replicate(entry, destination, pending)
109
+ end
111
110
  end
112
111
  end
113
112
 
@@ -172,7 +171,7 @@ module Woods
172
171
  # @param destination [Pathname, String] where it should land
173
172
  # @return [void]
174
173
  def link_or_copy(source, destination)
175
- FileUtils.ln(source.to_s, destination.to_s)
174
+ File.link(source.to_s, destination.to_s)
176
175
  rescue Errno::EXDEV, Errno::EPERM, Errno::EMLINK, NotImplementedError
177
176
  FileUtils.cp(source.to_s, destination.to_s)
178
177
  end
@@ -215,19 +214,21 @@ module Woods
215
214
  end
216
215
  end
217
216
 
218
- # `Pathname#find` is a pre-order walk: a directory is always visited
219
- # before anything inside it. So the directory branch has already created
220
- # every parent a file could need, and the `mkdir_p` the file branch used
221
- # to run was one stat-heavy syscall chain per unit re-proving what the
222
- # previous entry established. On a payload of 8000+ files that was the
223
- # bulk of the incremental seed.
217
+ # Classify once: Find.find followed by File.directory? queried metadata
218
+ # twice per file, costly on FUSE/virtiofs mounts. Parents are created before
219
+ # their children, so regular files need no extra parent-directory checks.
220
+ # Preserve the old walk's symlink behavior: a directory symlink becomes an
221
+ # empty directory, never a traversal into another tree or generation.
224
222
  #
225
- # @param entry [Pathname] source entry, as `find` yielded it
226
- # @param destination [Pathname] where it belongs in the target
223
+ # @param entry [String] source entry
224
+ # @param destination [String] where it belongs in the target
225
+ # @param pending [Array] directories still to visit
227
226
  # @return [void]
228
- def replicate(entry, destination)
229
- if entry.directory?
230
- FileUtils.mkdir_p(destination.to_s)
227
+ def replicate(entry, destination, pending)
228
+ stat = File.lstat(entry)
229
+ if stat.directory? || (stat.symlink? && File.directory?(entry))
230
+ FileUtils.mkdir_p(destination)
231
+ pending << [entry, destination] if stat.directory?
231
232
  else
232
233
  link_or_copy(entry, destination)
233
234
  end
@@ -21,10 +21,47 @@ module Woods
21
21
  # @return [Hash, nil] string-keyed unit, or nil when this type has no
22
22
  # such identifier
23
23
  def self.call(payload_dir, reader, identifier, type)
24
- dir = Woods::MCP::IndexReader::TYPE_TO_DIR[type]
24
+ dir = directory_for(type)
25
25
  return nil unless dir
26
- return nil unless reader.list_units(type: type).any? { |entry| entry['identifier'] == identifier }
27
26
 
27
+ family = Woods::MCP::IndexReader::DIR_TO_TYPE.fetch(dir)
28
+ return nil unless reader.list_units(type: family).any? { |entry| entry['identifier'] == identifier }
29
+
30
+ unit = read_unit(payload_dir, dir, identifier)
31
+ unit if unit && (unit['type'] == type || type == family)
32
+ end
33
+
34
+ # Resolve actual published types as well as historical directory-family aliases.
35
+ #
36
+ # @param type [String]
37
+ # @return [String, nil]
38
+ def self.directory_for(type)
39
+ Woods::MCP::IndexReader::TYPE_TO_DIR[type] ||
40
+ Woods::MCP::IndexReader::UNIT_TYPES_BY_DIR.find { |_, types| types.include?(type) }&.first
41
+ end
42
+
43
+ # Preserve subtype identity in an index entry from a shared type directory.
44
+ #
45
+ # @param payload_dir [Pathname]
46
+ # @param entry [Hash] published index entry
47
+ # @param dir [String] type directory
48
+ # @param requested_type [String, nil] actual type or family alias
49
+ # @return [Hash, nil] entry with actual type, or nil when filtered out
50
+ def self.entry(payload_dir, entry, dir, requested_type)
51
+ family = Woods::MCP::IndexReader::DIR_TO_TYPE.fetch(dir)
52
+ actual_type = if Woods::MCP::IndexReader::UNIT_TYPES_BY_DIR.fetch(dir).size > 1
53
+ read_unit(payload_dir, dir, entry['identifier'])&.fetch('type')
54
+ else
55
+ family
56
+ end
57
+ return unless actual_type
58
+ return if requested_type && requested_type != family && requested_type != actual_type
59
+
60
+ entry.merge('type' => actual_type)
61
+ end
62
+
63
+ # Read a unit whose directory-index membership has already been established.
64
+ def self.read_unit(payload_dir, dir, identifier)
28
65
  path = payload_dir.join(dir, filename_for(identifier))
29
66
  return nil unless path.file?
30
67
 
@@ -42,7 +79,7 @@ module Woods
42
79
  "#{base}_#{digest}.json"
43
80
  end
44
81
 
45
- private_class_method :filename_for
82
+ private_class_method :filename_for, :read_unit
46
83
  end
47
84
  end
48
85
  end
@@ -167,14 +167,14 @@ module Woods
167
167
  # @return [Array<Hash>]
168
168
  def units(type: nil)
169
169
  dirs = if type
170
- dir = Woods::MCP::IndexReader::TYPE_TO_DIR[type.to_s]
170
+ dir = TypedUnitReader.directory_for(type.to_s)
171
171
  dir ? [dir] : []
172
172
  else
173
173
  Woods::MCP::IndexReader::TYPE_DIRS
174
174
  end
175
175
  dirs.flat_map do |dir|
176
176
  @reader.list_units(type: Woods::MCP::IndexReader::DIR_TO_TYPE[dir])
177
- .map { |entry| entry.merge('type' => Woods::MCP::IndexReader::DIR_TO_TYPE[dir]) }
177
+ .filter_map { |entry| TypedUnitReader.entry(@payload_dir, entry, dir, type&.to_s) }
178
178
  end
179
179
  end
180
180
 
data/lib/woods/railtie.rb CHANGED
@@ -14,8 +14,8 @@ module Woods
14
14
  # flags and tokens set in `config/initializers/woods.rb` (the
15
15
  # generator-created location) do not exist yet when middleware arguments
16
16
  # are captured. Both guards are scoped to `console_mcp_path` — requests
17
- # outside it pass through untouched — and while `console_mcp_enabled` is
18
- # false the whole stack passes through, so an unconfigured host app is
17
+ # outside it pass through untouched — and while `console_mcp_enabled` or
18
+ # `console_mcp_http_enabled` is false the whole stack passes through, so an unconfigured host app is
19
19
  # completely unaffected. Boot-time validation of the settled configuration
20
20
  # happens in `after_initialize` via {Woods::RailtieSupport}.
21
21
  #
@@ -84,7 +84,7 @@ module Woods
84
84
  # BearerAuth next — requires `Authorization: Bearer <token>` at the
85
85
  # console path, failing closed (401) while no usable token is
86
86
  # configured. Both are scoped to the console path and inert while
87
- # console_mcp_enabled is false.
87
+ # either console_mcp_enabled or console_mcp_http_enabled is false.
88
88
  app.middleware.use(
89
89
  Woods::MCP::OriginGuard,
90
90
  allowed_origins: Woods::RailtieSupport.console_allowed_origins_proc,