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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -7
- data/CONTRIBUTING.md +134 -19
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -25
- data/docs/CONSOLE_MCP_SETUP.md +82 -30
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +20 -15
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +153 -38
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +56 -30
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/embedded_executor.rb +30 -5
- data/lib/woods/console/rack_middleware.rb +29 -1
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +334 -11
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
- data/plugin/skills/woods-setup/SKILL.md +53 -6
- 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:
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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 =
|
|
207
|
+
initial_state = state_machine_initial_state(declaration)
|
|
203
208
|
callbacks = parse_state_machine_callbacks(block)
|
|
204
209
|
|
|
205
|
-
|
|
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
|
-
#
|
|
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
|
|
227
|
-
# @
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
capturing = true
|
|
241
|
-
depth = 1
|
|
242
|
-
end
|
|
243
|
-
next
|
|
244
|
-
end
|
|
241
|
+
[]
|
|
242
|
+
end
|
|
245
243
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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]
|
|
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
|
data/lib/woods/flow_assembler.rb
CHANGED
|
@@ -390,7 +390,10 @@ module Woods
|
|
|
390
390
|
]
|
|
391
391
|
|
|
392
392
|
filenames.each do |filename|
|
|
393
|
-
Dir
|
|
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
|
data/lib/woods/generation.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
852
|
-
#
|
|
853
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|