woods 2.0.1 → 2.1.0

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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -7
  3. data/CONTRIBUTING.md +134 -19
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -25
  10. data/docs/CONSOLE_MCP_SETUP.md +82 -30
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +20 -15
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +153 -38
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  33. data/lib/tasks/woods.rake +23 -7
  34. data/lib/tasks/woods_checks.rake +2 -2
  35. data/lib/woods/agent_configuration/cli.rb +1 -1
  36. data/lib/woods/agent_configuration/layout.rb +16 -2
  37. data/lib/woods/agent_configuration/plan.rb +13 -3
  38. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  39. data/lib/woods/agent_configuration/preflight.rb +5 -3
  40. data/lib/woods/builder.rb +17 -57
  41. data/lib/woods/cache/cache_middleware.rb +56 -30
  42. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  43. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  44. data/lib/woods/console/connection_manager.rb +56 -3
  45. data/lib/woods/console/embedded_executor.rb +30 -5
  46. data/lib/woods/console/rack_middleware.rb +29 -1
  47. data/lib/woods/dependency_graph.rb +34 -10
  48. data/lib/woods/embedding/fake.rb +12 -0
  49. data/lib/woods/embedding/indexer.rb +195 -98
  50. data/lib/woods/embedding/input_budget.rb +67 -0
  51. data/lib/woods/embedding/openai.rb +70 -20
  52. data/lib/woods/embedding/provider.rb +37 -25
  53. data/lib/woods/embedding/text_preparer.rb +76 -32
  54. data/lib/woods/embedding/token_counter.rb +18 -81
  55. data/lib/woods/embedding/vector_configuration.rb +48 -0
  56. data/lib/woods/extraction_identities.rb +175 -0
  57. data/lib/woods/extractor.rb +304 -107
  58. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  59. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  60. data/lib/woods/extractors/class_declarations.rb +121 -0
  61. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  62. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  63. data/lib/woods/extractors/event_extractor.rb +8 -0
  64. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  65. data/lib/woods/extractors/job_extractor.rb +5 -1
  66. data/lib/woods/extractors/lib_extractor.rb +132 -15
  67. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  68. data/lib/woods/extractors/manager_extractor.rb +7 -21
  69. data/lib/woods/extractors/migration_declaration.rb +87 -0
  70. data/lib/woods/extractors/migration_extractor.rb +5 -39
  71. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  72. data/lib/woods/extractors/policy_extractor.rb +9 -5
  73. data/lib/woods/extractors/poro_extractor.rb +112 -53
  74. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  75. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  76. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  77. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  78. data/lib/woods/extractors/source_nesting.rb +142 -106
  79. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  80. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  81. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  82. data/lib/woods/flow_assembler.rb +4 -1
  83. data/lib/woods/generation.rb +25 -0
  84. data/lib/woods/hooks/context_hint.rb +7 -2
  85. data/lib/woods/mcp/bootstrapper.rb +33 -7
  86. data/lib/woods/mcp/config_resolver.rb +26 -7
  87. data/lib/woods/mcp/index_reader.rb +125 -24
  88. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  89. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  90. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  91. data/lib/woods/mcp/search_results.rb +7 -1
  92. data/lib/woods/mcp/server.rb +24 -4
  93. data/lib/woods/module_reconciliation.rb +151 -0
  94. data/lib/woods/path_dispatcher.rb +7 -2
  95. data/lib/woods/rake_helpers.rb +43 -11
  96. data/lib/woods/release.rb +1 -1
  97. data/lib/woods/resilience/index_validator.rb +8 -3
  98. data/lib/woods/resilience/retryable_provider.rb +18 -1
  99. data/lib/woods/resolved_config.rb +68 -8
  100. data/lib/woods/retrieval/context_assembler.rb +3 -3
  101. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  102. data/lib/woods/retrieval/scope.rb +18 -2
  103. data/lib/woods/retrieval/source_evidence.rb +14 -2
  104. data/lib/woods/source_contributor_validation.rb +78 -0
  105. data/lib/woods/source_contributors.rb +116 -0
  106. data/lib/woods/source_inputs/handoff.rb +37 -0
  107. data/lib/woods/source_inputs/launcher.rb +53 -13
  108. data/lib/woods/source_inputs/manifest.rb +84 -3
  109. data/lib/woods/source_inputs/private_key.rb +44 -12
  110. data/lib/woods/source_inputs/scanner.rb +98 -27
  111. data/lib/woods/source_inputs/scopes.rb +1 -1
  112. data/lib/woods/source_inputs/session.rb +147 -15
  113. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  114. data/lib/woods/source_inputs/status.rb +40 -8
  115. data/lib/woods/source_inputs/verifier.rb +28 -5
  116. data/lib/woods/source_path_encoding.rb +33 -0
  117. data/lib/woods/source_references/cache.rb +284 -0
  118. data/lib/woods/source_references/collector.rb +120 -0
  119. data/lib/woods/source_references/extraction.rb +185 -0
  120. data/lib/woods/source_references/inputs.rb +134 -0
  121. data/lib/woods/source_references/parser_adapter.rb +134 -0
  122. data/lib/woods/source_references/pass.rb +152 -0
  123. data/lib/woods/source_references/prism_adapter.rb +116 -0
  124. data/lib/woods/source_references/registry.rb +178 -0
  125. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  126. data/lib/woods/source_references/value_class.rb +82 -0
  127. data/lib/woods/storage/metadata_store.rb +4 -1
  128. data/lib/woods/storage/qdrant.rb +2 -2
  129. data/lib/woods/unblocked/client.rb +12 -7
  130. data/lib/woods/unblocked/document_builder.rb +4 -1
  131. data/lib/woods/unblocked/exporter.rb +127 -37
  132. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  133. data/lib/woods/unblocked/uri_migration.rb +105 -0
  134. data/lib/woods/util/host_guard.rb +3 -2
  135. data/lib/woods/version.rb +1 -1
  136. data/lib/woods/watch/catch_up.rb +138 -0
  137. data/lib/woods/watch/claim_lease.rb +150 -0
  138. data/lib/woods/watch/cli.rb +26 -2
  139. data/lib/woods/watch/daemon.rb +80 -59
  140. data/lib/woods/watch/installation/options.rb +1 -1
  141. data/lib/woods/watch/installation/receipt.rb +6 -1
  142. data/lib/woods/watch/managed_child.rb +1 -1
  143. data/lib/woods/watch/supervisor.rb +1 -1
  144. data/lib/woods/watch/tree_scan.rb +14 -2
  145. data/plugin/.claude-plugin/plugin.json +1 -1
  146. data/plugin/hooks/adapters/normalize.rb +3 -2
  147. data/plugin/hooks/woods-input-rules.sh +4 -0
  148. data/plugin/hooks/woods-refresh.sh +15 -7
  149. data/plugin/hooks/woods-session-start.sh +60 -3
  150. data/plugin/skills/woods-diagnose/SKILL.md +334 -11
  151. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  152. data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
  153. data/plugin/skills/woods-setup/SKILL.md +53 -6
  154. metadata +32 -5
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative '../source_references/runtime_lookup'
5
+ require_relative 'concern_extractor'
6
+
7
+ module Woods
8
+ module Extractors
9
+ # Discovers callable modules whose canonical declaration and own methods live
10
+ # in app/models. It does not autoload constants or invoke application methods.
11
+ # Cross-file reopenings deliberately do not become additional source owners.
12
+ class StandaloneModuleDiscovery
13
+ CORE_SINGLETON = Object.instance_method(:singleton_class)
14
+ CORE_SOURCE = Module.instance_method(:const_source_location)
15
+ CORE_METHOD = Module.instance_method(:instance_method)
16
+ CORE_OWNER = UnboundMethod.instance_method(:owner)
17
+ CORE_LOCATION = UnboundMethod.instance_method(:source_location)
18
+ VISIBILITIES = %i[public protected private].freeze
19
+ CORE_LISTS = VISIBILITIES.to_h { |visibility| [visibility, Module.instance_method("#{visibility}_instance_methods")] }
20
+ .freeze
21
+
22
+ def initialize(root: Rails.root, concerns: ConcernExtractor.new)
23
+ @root = File.expand_path(root)
24
+ @lookup = SourceReferences::RuntimeLookup.new
25
+ @concerns = concerns
26
+ end
27
+
28
+ # @param path [String] original application source path
29
+ # @param analysis [Hash] SourceReferences::Collector result for this file
30
+ # @return [Array<Hash>] canonical identifiers and source-verified method metadata
31
+ def call(path, analysis:)
32
+ return [] unless eligible_path?(path)
33
+
34
+ declarations = analysis.fetch('declarations').select do |declaration|
35
+ declaration['kind'] == 'module' && declaration.fetch('singleton_depth', 0).zero?
36
+ end
37
+ declarations.group_by { |declaration| declaration['owner'] }.filter_map do |identifier, candidates|
38
+ next if claimed_identity?(identifier)
39
+
40
+ value = verified_module(identifier, candidates, path)
41
+ next unless value
42
+
43
+ methods = own_methods(value, path, candidates)
44
+ next if methods[:all].empty?
45
+
46
+ { identifier: identifier, public_methods: methods[:public], class_methods: methods[:singleton],
47
+ method_count: methods[:all].size }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def eligible_path?(path)
54
+ absolute = File.expand_path(path, @root)
55
+ return false unless absolute.start_with?("#{@root}/app/models/") && absolute.end_with?('.rb')
56
+ return false if absolute.include?('/concerns/')
57
+
58
+ File.realpath(absolute).start_with?("#{File.realpath(@root)}/app/models/")
59
+ end
60
+
61
+ def claimed_identity?(identifier)
62
+ @claimed ||= @concerns.runtime_model_mixins.values.flatten.to_set do |mod|
63
+ @lookup.reflect(mod, :name)
64
+ end
65
+ @claimed.include?(identifier)
66
+ end
67
+
68
+ def verified_module(identifier, declarations, path)
69
+ declarations.each do |declaration|
70
+ result = @lookup.call(declaration['name'], nesting: declaration.fetch('enclosing_nesting', []),
71
+ allow_private: true)
72
+ value = result[:value]
73
+ next unless result[:status] == :resolved && result[:target] == identifier
74
+ next if @lookup.class_object?(value)
75
+ next unless canonical_path(identifier) == File.realpath(path)
76
+
77
+ return value
78
+ end
79
+ nil
80
+ end
81
+
82
+ def canonical_path(identifier)
83
+ parts = identifier.split('::')
84
+ name = parts.pop
85
+ scope = parts.empty? ? Object : @lookup.call("::#{parts.join('::')}", allow_private: true)[:value]
86
+ return unless @lookup.module_object?(scope)
87
+
88
+ location = CORE_SOURCE.bind(scope).call(name, false)
89
+ File.realpath(location.first) if location && File.file?(location.first)
90
+ end
91
+
92
+ def own_methods(value, path, declarations)
93
+ instance = methods_at(value, path, declarations)
94
+ singleton = methods_at(CORE_SINGLETON.bind(value).call, path, declarations)
95
+ all = (instance.values.flat_map(&:to_a) + singleton.values.flat_map(&:to_a)).uniq
96
+ { public: instance.fetch(:public).keys.sort, singleton: singleton.fetch(:public).keys.sort, all: all }
97
+ end
98
+
99
+ def methods_at(scope, path, declarations)
100
+ VISIBILITIES.to_h do |visibility|
101
+ methods = CORE_LISTS.fetch(visibility).bind(scope).call(false).filter_map do |name|
102
+ method = CORE_METHOD.bind(scope).call(name)
103
+ next unless SourceReferences::RuntimeLookup::CORE_EQUAL.bind(CORE_OWNER.bind(method).call).call(scope)
104
+
105
+ location = CORE_LOCATION.bind(method).call
106
+ next unless local_method?(location, path, declarations)
107
+
108
+ [name.to_s, location]
109
+ end
110
+ [visibility, methods.to_h]
111
+ end
112
+ end
113
+
114
+ def local_method?(location, path, declarations)
115
+ unless location && File.file?(location.first) && File.realpath(location.first) == File.realpath(path)
116
+ return false
117
+ end
118
+
119
+ declarations.any? { |declaration| (declaration['line']..declaration['end_line']).cover?(location.last) }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -5,6 +5,7 @@ require_relative '../source_inputs/consumer_errors'
5
5
  require_relative 'shared_utility_methods'
6
6
  require_relative 'shared_dependency_scanner'
7
7
  require_relative 'source_nesting'
8
+ require_relative 'class_declarations'
8
9
 
9
10
  module Woods
10
11
  module Extractors
@@ -13,7 +14,7 @@ module Woods
13
14
  # Supports three state machine gems:
14
15
  # - AASM: files that include AASM with +aasm do...end+ blocks
15
16
  # - Statesman: files that include Statesman::Machine with state/transition calls
16
- # - state_machines: files using the +state_machine :attr do...end+ DSL
17
+ # - state_machines: literal +state_machine+ declarations, with default or named attributes
17
18
  #
18
19
  # Produces one ExtractedUnit per state machine definition found.
19
20
  # A single model file can produce multiple units (e.g., two state_machine blocks).
@@ -184,7 +185,8 @@ module Woods
184
185
 
185
186
  # Extract state_machines gem state machine units from source.
186
187
  #
187
- # Handles multiple state_machine blocks for different attributes.
188
+ # Handles default/named attributes and parenthesized declarations, with
189
+ # each machine's metadata restricted to its own block.
188
190
  #
189
191
  # @param source [String] Ruby source code
190
192
  # @param class_name [String] Model class name
@@ -193,16 +195,19 @@ module Woods
193
195
  def extract_state_machines_units(source, class_name, file_path)
194
196
  return [] unless source.match?(/\bstate_machine\b/)
195
197
 
196
- units = []
197
- source.scan(/state_machine\s+:(\w+)/) do |match|
198
- attr_name = match[0]
199
- block = extract_block_for_state_machine(source, attr_name)
198
+ owners = ClassDeclarations.read(source).select { |record| record[:identifier] == class_name }
199
+ declarations = owners.flat_map { |record| state_machine_declarations(record[:node].body) }
200
+ declarations.filter_map do |declaration|
201
+ attr_name = state_machine_attribute(declaration)
202
+ next unless attr_name
203
+
204
+ block = declaration.block&.location&.slice.to_s
200
205
  states = block.scan(/^\s*state\s+:(\w+)/).flatten
201
206
  events = parse_events_from_source(block, /\Aevent\s+:(\w+)/)
202
- initial_state = source.match(/state_machine\s+:#{Regexp.escape(attr_name)}[^#\n]*initial:\s*:(\w+)/)&.[](1)
207
+ initial_state = state_machine_initial_state(declaration)
203
208
  callbacks = parse_state_machine_callbacks(block)
204
209
 
205
- units << build_unit(
210
+ build_unit(
206
211
  identifier: "#{class_name}::state_machine_#{attr_name}",
207
212
  class_name: class_name,
208
213
  file_path: file_path,
@@ -215,42 +220,47 @@ module Woods
215
220
  callbacks: callbacks
216
221
  )
217
222
  end
218
-
219
- units
220
223
  end
221
224
 
222
- # Extract the block body for a specific state_machine attribute.
223
- #
224
- # Uses depth tracking (do/end balance) to find the block boundaries.
225
+ # Read only direct calls in the selected class body. Nested classes,
226
+ # singleton classes and deferred/receiver blocks do not belong to it.
225
227
  #
226
- # @param source [String] Ruby source code
227
- # @param attr_name [String] Attribute name (e.g., "status")
228
- # @return [String] Block body source
229
- def extract_block_for_state_machine(source, attr_name)
230
- lines = source.lines
231
- result = []
232
- depth = 0
233
- capturing = false
228
+ # @param node [Prism::Node, nil] selected class body or direct statement
229
+ # @return [Array<Prism::CallNode>] state machine declarations in source order
230
+ def state_machine_declarations(node)
231
+ if node.is_a?(Prism::StatementsNode)
232
+ return node.body.flat_map { |statement| state_machine_declarations(statement) }
233
+ end
234
234
 
235
- lines.each do |line|
236
- stripped = line.strip
235
+ if node.is_a?(Prism::CallNode) && node.name == :state_machine &&
236
+ (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) &&
237
+ (node.block.nil? || node.block.is_a?(Prism::BlockNode))
238
+ return [node]
239
+ end
237
240
 
238
- unless capturing
239
- if stripped.match?(/\Astate_machine\s+:#{Regexp.escape(attr_name)}.*\bdo\b/)
240
- capturing = true
241
- depth = 1
242
- end
243
- next
244
- end
241
+ []
242
+ end
245
243
 
246
- depth += 1 if block_opener?(stripped)
247
- depth -= 1 if stripped == 'end'
248
- break if depth <= 0
244
+ # @param declaration [Prism::CallNode] state_machine DSL call
245
+ # @return [String, nil] literal attribute, defaulting to state for options-only calls
246
+ def state_machine_attribute(declaration)
247
+ argument = declaration.arguments&.arguments&.first
248
+ return 'state' if argument.nil? || argument.is_a?(Prism::KeywordHashNode) || argument.is_a?(Prism::HashNode)
249
+ return argument.unescaped if argument.is_a?(Prism::SymbolNode) && argument.unescaped.match?(/\A\w+\z/)
249
250
 
250
- result << line
251
- end
251
+ nil
252
+ end
253
+
254
+ # @param declaration [Prism::CallNode] state_machine DSL call
255
+ # @return [String, nil] literal initial state; dynamic initializers are never called
256
+ def state_machine_initial_state(declaration)
257
+ options = declaration.arguments&.arguments&.last
258
+ return unless options.is_a?(Prism::KeywordHashNode) || options.is_a?(Prism::HashNode)
252
259
 
253
- result.join
260
+ initial = options.elements.find do |entry|
261
+ entry.is_a?(Prism::AssocNode) && entry.key.is_a?(Prism::SymbolNode) && entry.key.unescaped == 'initial'
262
+ end
263
+ initial.value.unescaped if initial&.value.is_a?(Prism::SymbolNode)
254
264
  end
255
265
 
256
266
  # ──────────────────────────────────────────────────────────────────────
@@ -347,10 +357,6 @@ module Woods
347
357
  nil
348
358
  end
349
359
 
350
- # NOTE: block_opener? (the RakeTaskExtractor-style depth-tracking
351
- # discipline used by extract_block_for_state_machine above) is provided
352
- # by the included SourceNesting module.
353
-
354
360
  # ──────────────────────────────────────────────────────────────────────
355
361
  # Unit Construction
356
362
  # ──────────────────────────────────────────────────────────────────────
@@ -67,9 +67,7 @@ module Woods
67
67
  # "addition", extract it, get nil, and dirty the dependents pass for
68
68
  # nothing. Anonymous classes go too: `extract_component` rejects a nil
69
69
  # name for the same reason.
70
- @component_base.descendants.reject do |component|
71
- component.name.nil? || preview_class?(component)
72
- end
70
+ @component_base.descendants.select { |component| app_component?(component) }
73
71
  end
74
72
 
75
73
  # Extract a single ViewComponent component
@@ -77,8 +75,7 @@ module Woods
77
75
  # @param component [Class] The component class
78
76
  # @return [ExtractedUnit, nil] The extracted unit, or nil on failure
79
77
  def extract_component(component)
80
- return nil if component.name.nil?
81
- return nil if preview_class?(component)
78
+ return nil unless app_component?(component)
82
79
 
83
80
  unit = ExtractedUnit.new(
84
81
  type: :view_component,
@@ -103,6 +100,11 @@ module Woods
103
100
 
104
101
  private
105
102
 
103
+ def app_component?(component)
104
+ @component_base && component.name && component < @component_base &&
105
+ !preview_class?(component) && app_source_file?(source_file_for(component))
106
+ end
107
+
106
108
  # Find the ViewComponent::Base class if the gem is loaded
107
109
  #
108
110
  # @return [Class, nil]
@@ -222,7 +224,7 @@ module Woods
222
224
  # Detect sidecar template file (.html.erb next to the .rb file)
223
225
  #
224
226
  # @param component [Class]
225
- # @return [String, nil] Path to sidecar template if found
227
+ # @return [String, nil] App-relative path to sidecar template if found
226
228
  def detect_sidecar_template(component)
227
229
  base_path = Rails.root.join("app/components/#{component.name.underscore}")
228
230
 
@@ -234,7 +236,7 @@ module Woods
234
236
  "#{base_path}/#{component.name.demodulize.underscore}.html.erb"
235
237
  ]
236
238
 
237
- candidates.find { |path| File.exist?(path) }
239
+ candidates.find { |path| File.exist?(path) }&.delete_prefix(File.join(Rails.root.to_s, ''))
238
240
  rescue StandardError
239
241
  nil
240
242
  end
@@ -390,7 +390,10 @@ module Woods
390
390
  ]
391
391
 
392
392
  filenames.each do |filename|
393
- Dir[File.join(@extracted_dir, '*', filename)].each do |path|
393
+ Dir.children(@extracted_dir).sort.each do |directory|
394
+ path = File.join(@extracted_dir, directory, filename)
395
+ next unless File.file?(path)
396
+
394
397
  # Force UTF-8: the extractor writes the routes-comment header in
395
398
  # source_code using Unicode box-drawing characters; reading under
396
399
  # the platform default (US-ASCII on some CIs) raises
@@ -42,6 +42,9 @@ module Woods
42
42
  class Generation
43
43
  FILENAME = 'generation.json'
44
44
 
45
+ # Raised by strict readers instead of treating corruption as a legacy index.
46
+ class InvalidMarker < IOError; end
47
+
45
48
  # An immutable snapshot of the generation file.
46
49
  #
47
50
  # @!attribute number
@@ -104,6 +107,23 @@ module Woods
104
107
  UNPUBLISHED
105
108
  end
106
109
 
110
+ # Read and validate an existing marker for a long-lived reader. Missing
111
+ # markers still describe legacy flat indexes; malformed existing files do not.
112
+ # @return [Marker]
113
+ # @raise [InvalidMarker] if the existing marker cannot identify a generation
114
+ def current!
115
+ data = JSON.parse(AtomicFile.read(@path))
116
+ raise InvalidMarker, 'Published generation marker has an invalid shape' unless valid_marker_data?(data)
117
+ raise InvalidMarker, 'Published generation payload path is malformed' if data['payload']&.include?("\0")
118
+
119
+ Marker.new(number: data['number'], token: data['token'], updated_at: data['updated_at'],
120
+ reason: data['reason'], payload: data['payload'])
121
+ rescue Errno::ENOENT
122
+ UNPUBLISHED
123
+ rescue JSON::ParserError, EncodingError, SystemCallError
124
+ raise InvalidMarker, 'Published generation marker is unreadable or malformed'
125
+ end
126
+
107
127
  # Advance to the next generation.
108
128
  #
109
129
  # Call this *after* the index files are written, never before, and never
@@ -162,6 +182,11 @@ module Woods
162
182
 
163
183
  private
164
184
 
185
+ def valid_marker_data?(data)
186
+ data.is_a?(Hash) && data['number'].is_a?(Integer) && data['number'] >= 0 &&
187
+ %w[token updated_at reason payload].all? { |key| data[key].nil? || data[key].is_a?(String) }
188
+ end
189
+
165
190
  # Does +candidate+ resolve inside the index root?
166
191
  #
167
192
  # Compared through +realpath+, mirroring
@@ -52,8 +52,13 @@ module Woods
52
52
  end
53
53
 
54
54
  def source_freshness
55
- SourceInputs::Status.new(output_dir: @output, root: @event.root, payload_dir: @reader.payload_dir,
56
- generation: @reader.loaded_generation).call.fetch('state')
55
+ status = SourceInputs::Status.new(output_dir: @output, root: @event.root, payload_dir: @reader.payload_dir,
56
+ generation: @reader.loaded_generation).call
57
+ return status.fetch('state') unless status['state'] == 'unavailable'
58
+
59
+ evidence = status.fetch('unavailable')
60
+ "unavailable (#{evidence.fetch('reason')}; #{evidence.fetch('size_bytes')} bytes; " \
61
+ "limit #{evidence.fetch('limit_bytes')})"
57
62
  end
58
63
 
59
64
  def orientation(freshness)
@@ -11,6 +11,7 @@ require_relative '../index_artifact'
11
11
  require_relative '../builder'
12
12
  require_relative '../resolved_config'
13
13
  require_relative '../generation'
14
+ require_relative '../chunking/contributor_chunks'
14
15
  require_relative '../storage/snapshotter'
15
16
  require_relative '../storage/inapplicable_backend'
16
17
 
@@ -327,7 +328,10 @@ module Woods
327
328
  end
328
329
  resolved = build_resolved_config(config)
329
330
 
330
- return refresh_reader_only(reader, zero_counts) unless refreshable_stores?(target)
331
+ unless refreshable_stores?(target)
332
+ assert_no_snapshot_vector_reload!(target, served)
333
+ return refresh_reader_only(reader, zero_counts)
334
+ end
331
335
 
332
336
  # A captured dump whose embedded config names store types the
333
337
  # refreshable live target cannot refresh cannot be turned into
@@ -449,6 +453,20 @@ module Woods
449
453
  end
450
454
  private_class_method :refreshable_stores?
451
455
 
456
+ # The local preset combines snapshot vectors with live SQLite metadata.
457
+ # A reader-only reload leaves those vectors stale; report that limitation
458
+ # explicitly until both stores can participate in an atomic replacement.
459
+ def self.assert_no_snapshot_vector_reload!(target, served)
460
+ return unless target.vector_store.respond_to?(:clear!) && target.vector_store.respond_to?(:bulk_load)
461
+
462
+ raise ReloadDegraded.new(
463
+ 'reload cannot atomically refresh snapshot vectors with this metadata store; restart woods-mcp ' \
464
+ 'to load the latest vectors after woods:embed',
465
+ generation: served.number, stores: %w[vector metadata]
466
+ )
467
+ end
468
+ private_class_method :assert_no_snapshot_vector_reload!
469
+
452
470
  # The captured dump's embedded config and the refreshable live target
453
471
  # must agree on store types (M2). Each is valid alone: the live target
454
472
  # is refreshable (in-memory), and the dump is complete and valid. But
@@ -736,20 +754,24 @@ module Woods
736
754
  private_class_method :build_artifact
737
755
 
738
756
  def self.build_retriever_from_config(config, resolved, artifact, state = nil)
757
+ builder = Woods::Builder.new(config)
739
758
  vector_store = hydrated_vector_store(config, resolved, artifact, state)
740
759
  metadata_store = hydrated_metadata_store(config, resolved, artifact, state)
760
+ metadata_store ||= builder.build_metadata_store if config.metadata_store == :sqlite
741
761
  graph_store = hydrated_graph_store(config, artifact, state)
742
762
 
743
763
  # Cross-populate the vector store's per-entry metadata cache from
744
764
  # the metadata store. The WVF1 binary format stores only id + float
745
765
  # blob (no per-vector hash) — it's numeric-only to keep dumps
746
- # mmap-friendly. The metadata lives in metadata.msgpack, and
766
+ # mmap-friendly. The metadata lives in metadata.msgpack or the configured
767
+ # SQLite store, which must be opened before this back-fill. The same
768
+ # metadata instance is then handed to the retriever below, and
747
769
  # InMemory::VectorStore#search uses its per-entry metadata for
748
770
  # filter predicates. Without this back-fill, a type-filtered
749
771
  # search returns zero results after a dump/reload.
750
772
  populate_vector_metadata(vector_store, metadata_store) if vector_store && metadata_store
751
773
 
752
- Woods::Builder.new(config).build_retriever(
774
+ builder.build_retriever(
753
775
  vector_store: vector_store, metadata_store: metadata_store,
754
776
  graph_store: graph_store
755
777
  )
@@ -848,9 +870,10 @@ module Woods
848
870
  private_constant :CHUNK_SUFFIX_PATTERN
849
871
 
850
872
  # Back-fill the vector store's per-entry metadata hashes from the
851
- # metadata store. Only makes sense when both are in-memory — durable
852
- # backends return nil from the hydration helpers and never reach
853
- # this path. Both callers hand over stores they built themselves —
873
+ # metadata store. Only applies to in-memory vectors; metadata may be an
874
+ # in-memory snapshot or the configured SQLite store. Durable vector
875
+ # backends return nil from hydration and never reach this path.
876
+ # Both callers hand over stores they built themselves —
854
877
  # boot's freshly hydrated pair and {.build_reload_candidates}'
855
878
  # off-side candidates (the M7 transaction retired the old in-place
856
879
  # variant that reached a LIVE store) — but the guard below still has
@@ -868,7 +891,10 @@ module Woods
868
891
  meta = metadata_store.find(id.to_s.sub(CHUNK_SUFFIX_PATTERN, ''))
869
892
  next if meta.nil? || (meta.respond_to?(:empty?) && meta.empty?)
870
893
 
871
- vector_store.store(id, vec, vector_filter_metadata(meta))
894
+ chunk_index = id.to_s[/#chunk_(\d+)\z/, 1].to_i
895
+ chunk = Array(meta['embedding_chunks'])[chunk_index]
896
+ facts = vector_filter_metadata(meta).merge(Chunking::ContributorChunks.vector_metadata(meta, chunk))
897
+ vector_store.store(id, vec, facts)
872
898
  end
873
899
  end
874
900
  private_class_method :populate_vector_metadata
@@ -164,6 +164,13 @@ module Woods
164
164
  # @param artifact [Woods::IndexArtifact]
165
165
  # @return [Woods::Configuration]
166
166
  def self.populate_from_stored(config, stored, artifact:, env: ENV)
167
+ if stored.requires_host_provider?
168
+ raise ConfigMismatch,
169
+ 'This embedding snapshot requires an explicit host provider configuration. ' \
170
+ 'Configure the original provider in the host initializer, or use lexical retrieval; ' \
171
+ 'Woods cannot safely reconstruct these settings from woods.json.'
172
+ end
173
+
167
174
  config.output_dir = artifact.output_dir.to_s
168
175
  config.vector_store = stored.stores[:vector_store] || :in_memory
169
176
  config.metadata_store = stored.stores[:metadata_store] || :in_memory
@@ -177,7 +184,11 @@ module Woods
177
184
  opts[:host] = stored.embedding_provider[:host] if stored.embedding_provider[:host]
178
185
  opts[:num_ctx] = stored.embedding_provider[:num_ctx] if stored.embedding_provider[:num_ctx]
179
186
  opts[:read_timeout] = stored.embedding_provider[:read_timeout] if stored.embedding_provider[:read_timeout]
180
- opts[:dimension] = stored.embedding_provider[:dimension] if stored.embedding_provider[:dimension]&.positive?
187
+ if stored.dimension.positive?
188
+ opts[provider_sym == :fake ? :dimension : :expected_dimensions] = stored.dimension
189
+ end
190
+ requested = stored.embedding_provider[:requested_dimensions] || legacy_requested_dimensions(stored)
191
+ opts[:dimensions] = requested if requested
181
192
 
182
193
  # OpenAI needs an api_key to construct. `woods.json` deliberately
183
194
  # never stores credentials; pull from env here so the standalone
@@ -207,6 +218,19 @@ module Woods
207
218
  end
208
219
  private_class_method :populate_from_stored
209
220
 
221
+ # Old snapshots stored only the resulting width. A reduced width for
222
+ # either known OpenAI v3 model can only be reproduced by requesting it.
223
+ # Do not infer this capability for ada, Ollama, or arbitrary model names.
224
+ def self.legacy_requested_dimensions(stored)
225
+ return if stored.embedding_provider.key?(:requested_dimensions)
226
+ return unless provider_symbol(stored.embedding_provider[:class]) == :openai
227
+
228
+ native = { 'text-embedding-3-small' => 1536, 'text-embedding-3-large' => 3072 }
229
+ width = native[stored.embedding_provider[:model]]
230
+ stored.dimension if width && stored.dimension.positive? && stored.dimension < width
231
+ end
232
+ private_class_method :legacy_requested_dimensions
233
+
210
234
  def self.restore_store_options(config, stored, artifact:, env:)
211
235
  if config.metadata_store == :sqlite
212
236
  config.metadata_store_options = { database: artifact.output_dir.join('metadata.sqlite3').to_s }
@@ -250,12 +274,7 @@ module Woods
250
274
  # @param class_name [String]
251
275
  # @return [Symbol, String] symbol for known providers, raw string otherwise
252
276
  def self.provider_symbol(class_name)
253
- case class_name.to_s
254
- when /Ollama/ then :ollama
255
- when /OpenAI/ then :openai
256
- when /Fake/ then :fake
257
- else class_name.to_s
258
- end
277
+ ResolvedConfig::BUILTIN_PROVIDERS.fetch(class_name.to_s, class_name.to_s)
259
278
  end
260
279
  private_class_method :provider_symbol
261
280