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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'chunk'
|
|
4
|
+
require_relative 'contributor_chunks'
|
|
4
5
|
|
|
5
6
|
module Woods
|
|
6
7
|
module Chunking
|
|
@@ -181,11 +182,8 @@ module Woods
|
|
|
181
182
|
# Default token threshold below which units stay whole.
|
|
182
183
|
DEFAULT_THRESHOLD = 200
|
|
183
184
|
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
# (e.g., a single 2000-char regex line that tokenizes into 6000
|
|
187
|
-
# tokens because BERT WordPiece fragments every `\w+` boundary).
|
|
188
|
-
MIN_SLICE_CHARS = 256
|
|
185
|
+
# Stop at one Unicode character and refuse if even that cannot fit.
|
|
186
|
+
MIN_SLICE_CHARS = 1
|
|
189
187
|
private_constant :MIN_SLICE_CHARS
|
|
190
188
|
|
|
191
189
|
# @param threshold [Integer] Token count threshold for chunking
|
|
@@ -222,7 +220,8 @@ module Woods
|
|
|
222
220
|
# @return [Array<Chunk>] Ordered list of chunks
|
|
223
221
|
def chunk(unit)
|
|
224
222
|
return [] if unit.source_code.nil? || unit.source_code.strip.empty?
|
|
225
|
-
return
|
|
223
|
+
return enforce_char_limit(ContributorChunks.chunks(unit), unit) if SourceContributors.multiple?(unit)
|
|
224
|
+
return enforce_char_limit([build_whole_chunk(unit)], unit) if unit.estimated_tokens <= @threshold
|
|
226
225
|
|
|
227
226
|
enforce_char_limit(chunks_for(unit), unit)
|
|
228
227
|
end
|
|
@@ -247,6 +246,11 @@ module Woods
|
|
|
247
246
|
unit.chunks = unit.chunks.flat_map { |chunk| split_oversize_hash_chunk(chunk) }
|
|
248
247
|
end
|
|
249
248
|
|
|
249
|
+
def preparation_identity
|
|
250
|
+
{ 'class' => self.class.name, 'contributors_version' => 1, 'threshold' => @threshold, 'max_chars' => @max_chars,
|
|
251
|
+
'max_tokens' => @max_tokens, 'counter' => @token_counter&.class&.name }
|
|
252
|
+
end
|
|
253
|
+
|
|
250
254
|
private
|
|
251
255
|
|
|
252
256
|
# True when either the char ceiling or the token-based verifier is
|
|
@@ -270,12 +274,20 @@ module Woods
|
|
|
270
274
|
# @param chunk [Hash] a unit-chunk hash (symbol or string keys)
|
|
271
275
|
# @return [Array<Hash>]
|
|
272
276
|
def split_oversize_hash_chunk(chunk)
|
|
273
|
-
|
|
277
|
+
chunk = chunk.transform_keys(&:to_sym)
|
|
278
|
+
content = chunk[:content]
|
|
274
279
|
return [chunk] if content.nil? || !oversize?(content)
|
|
275
280
|
|
|
276
|
-
chunk_type = chunk[:chunk_type] ||
|
|
281
|
+
chunk_type = chunk[:chunk_type] || :whole
|
|
282
|
+
offset = SourceContributors.field(chunk[:embedding_slice], :start_byte) || 0
|
|
283
|
+
initial_offset = offset
|
|
277
284
|
verified_slices(content).each_with_index.map do |slice, idx|
|
|
278
|
-
|
|
285
|
+
first = offset
|
|
286
|
+
offset += slice.bytesize
|
|
287
|
+
metadata = ContributorChunks.slice_metadata(chunk[:metadata], content, first - initial_offset,
|
|
288
|
+
offset - initial_offset)
|
|
289
|
+
chunk.merge(content: slice, chunk_type: :"#{chunk_type}_part_#{idx}", metadata: metadata,
|
|
290
|
+
embedding_slice: { start_byte: first, end_byte: offset })
|
|
279
291
|
end
|
|
280
292
|
end
|
|
281
293
|
|
|
@@ -332,12 +344,16 @@ module Woods
|
|
|
332
344
|
def split_oversize_chunk(chunk, unit)
|
|
333
345
|
return [chunk] unless oversize?(chunk.content)
|
|
334
346
|
|
|
347
|
+
offset = 0
|
|
335
348
|
verified_slices(chunk.content).each_with_index.map do |slice, idx|
|
|
349
|
+
first = offset
|
|
350
|
+
offset += slice.bytesize
|
|
336
351
|
Chunk.new(
|
|
337
352
|
content: slice,
|
|
338
353
|
chunk_type: :"#{chunk.chunk_type}_part_#{idx}",
|
|
339
354
|
parent_identifier: unit.identifier,
|
|
340
|
-
parent_type: unit.type
|
|
355
|
+
parent_type: unit.type,
|
|
356
|
+
metadata: ContributorChunks.slice_metadata(chunk.metadata, chunk.content, first, offset)
|
|
341
357
|
)
|
|
342
358
|
end
|
|
343
359
|
end
|
|
@@ -359,9 +375,8 @@ module Woods
|
|
|
359
375
|
end
|
|
360
376
|
|
|
361
377
|
# Ensure a single post-line-split slice fits the token budget.
|
|
362
|
-
# Halves the
|
|
363
|
-
#
|
|
364
|
-
# cannot be split line-wise (minified output, huge regex literals).
|
|
378
|
+
# Halves the character limit and reslices if necessary. A minimum-sized
|
|
379
|
+
# slice that still exceeds the counter's limit is refused, never emitted.
|
|
365
380
|
#
|
|
366
381
|
# @param slice [String]
|
|
367
382
|
# @param char_limit [Integer]
|
|
@@ -370,7 +385,9 @@ module Woods
|
|
|
370
385
|
return [slice] unless @token_counter.count(slice) > @max_tokens
|
|
371
386
|
|
|
372
387
|
smaller = char_limit / 2
|
|
373
|
-
|
|
388
|
+
if smaller < MIN_SLICE_CHARS || slice.length <= 1
|
|
389
|
+
raise ArgumentError, 'Chunk input limit cannot fit one source character'
|
|
390
|
+
end
|
|
374
391
|
|
|
375
392
|
slice_by_lines(slice, smaller).flat_map { |sub| verify_slice(sub, smaller) }
|
|
376
393
|
end
|
|
@@ -385,13 +402,15 @@ module Woods
|
|
|
385
402
|
end
|
|
386
403
|
|
|
387
404
|
# Greedy line-based slicing that respects a supplied `limit`.
|
|
388
|
-
# Lines longer than `limit` are
|
|
389
|
-
#
|
|
405
|
+
# Lines longer than `limit` are cut at Unicode character boundaries;
|
|
406
|
+
# concatenating the slices preserves every source character.
|
|
390
407
|
#
|
|
391
408
|
# @param content [String]
|
|
392
409
|
# @param limit [Integer]
|
|
393
410
|
# @return [Array<String>]
|
|
394
411
|
def slice_by_lines(content, limit = @max_chars)
|
|
412
|
+
raise ArgumentError, 'Chunk size must be positive' unless limit.positive?
|
|
413
|
+
|
|
395
414
|
slices = []
|
|
396
415
|
current = String.new
|
|
397
416
|
content.each_line do |line|
|
|
@@ -658,6 +677,8 @@ module Woods
|
|
|
658
677
|
include ChunkBuilder
|
|
659
678
|
include LineDepthTracking
|
|
660
679
|
|
|
680
|
+
METHOD_NAME = /\A\s*def\s+((?:self\s*\.\s*)?(?:#{OPERATOR_METHOD_NAMES}|[[:word:]]+[?!=]?))/
|
|
681
|
+
|
|
661
682
|
# @param unit [ExtractedUnit]
|
|
662
683
|
def initialize(unit)
|
|
663
684
|
@unit = unit
|
|
@@ -677,7 +698,7 @@ module Woods
|
|
|
677
698
|
# @return [Hash]
|
|
678
699
|
def parse_lines(lines)
|
|
679
700
|
state = {
|
|
680
|
-
summary: [], methods: {}, private_methods: [],
|
|
701
|
+
summary: [], methods: {}, method_occurrences: Hash.new(0), private_methods: [],
|
|
681
702
|
current_method: nil, depth: 0, in_private: false
|
|
682
703
|
}
|
|
683
704
|
lines.each do |line|
|
|
@@ -725,14 +746,16 @@ module Woods
|
|
|
725
746
|
end
|
|
726
747
|
|
|
727
748
|
def start_method(state, line)
|
|
728
|
-
method_name = line[
|
|
749
|
+
method_name = line[METHOD_NAME, 1]&.delete(" \t")
|
|
729
750
|
|
|
730
751
|
if state[:in_private]
|
|
731
752
|
state[:private_methods] << line
|
|
732
753
|
else
|
|
733
|
-
#
|
|
734
|
-
#
|
|
735
|
-
# methods in source order.
|
|
754
|
+
# Repeated definitions (nested owners, singleton bodies, or inlined
|
|
755
|
+
# overrides) must never overwrite earlier source. Preserve existing
|
|
756
|
+
# keys for unique methods and number later occurrences in source order.
|
|
757
|
+
occurrence = state[:method_occurrences][method_name] += 1
|
|
758
|
+
method_name = "#{method_name}@#{occurrence}" if occurrence > 1
|
|
736
759
|
state[:methods][method_name] = [line]
|
|
737
760
|
end
|
|
738
761
|
|
|
@@ -17,6 +17,12 @@ module Woods
|
|
|
17
17
|
# process with a direct, Docker, or SSH command.
|
|
18
18
|
class ConnectionManager
|
|
19
19
|
DEFAULT_COMMAND = 'bundle exec rake woods:console'
|
|
20
|
+
MODE_OPTIONS = {
|
|
21
|
+
'direct' => %w[mode command directory],
|
|
22
|
+
'docker' => %w[mode command container],
|
|
23
|
+
'ssh' => %w[mode command host user]
|
|
24
|
+
}.freeze
|
|
25
|
+
CONFIG_KEYS = MODE_OPTIONS.values.flatten.uniq.freeze
|
|
20
26
|
|
|
21
27
|
# @param config [Hash] Process-launch configuration
|
|
22
28
|
# @option config [String] 'mode' direct, docker, or ssh (default: direct)
|
|
@@ -27,8 +33,9 @@ module Woods
|
|
|
27
33
|
# @option config [String] 'user' Optional SSH user
|
|
28
34
|
def initialize(config:)
|
|
29
35
|
@config = config
|
|
36
|
+
validate_config!
|
|
30
37
|
@mode = config.fetch('mode', 'direct')
|
|
31
|
-
@embedded_command = config
|
|
38
|
+
@embedded_command = config['command']
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
# Return the argv used to launch the embedded MCP server.
|
|
@@ -36,6 +43,7 @@ module Woods
|
|
|
36
43
|
# @return [Array<String>]
|
|
37
44
|
# @raise [ConnectionError] when the mode is invalid or incomplete
|
|
38
45
|
def command
|
|
46
|
+
validate_mode_options!
|
|
39
47
|
case @mode
|
|
40
48
|
when 'direct' then embedded_argv
|
|
41
49
|
when 'docker' then docker_command
|
|
@@ -53,10 +61,11 @@ module Woods
|
|
|
53
61
|
# @return [void]
|
|
54
62
|
# @raise [ConnectionError] when the process cannot be launched
|
|
55
63
|
def replace_process!
|
|
64
|
+
argv = command
|
|
56
65
|
if @mode == 'direct' && @config['directory']
|
|
57
|
-
Dir.chdir(@config['directory']) { exec(*
|
|
66
|
+
Dir.chdir(@config['directory']) { exec(*argv) }
|
|
58
67
|
else
|
|
59
|
-
exec(*
|
|
68
|
+
exec(*argv)
|
|
60
69
|
end
|
|
61
70
|
rescue SystemCallError, ArgumentError => e
|
|
62
71
|
raise ConnectionError, "Failed to launch embedded Console MCP (#{@mode}): #{e.message}"
|
|
@@ -64,7 +73,43 @@ module Woods
|
|
|
64
73
|
|
|
65
74
|
private
|
|
66
75
|
|
|
76
|
+
def validate_config!
|
|
77
|
+
raise ConnectionError, 'Console configuration must be a YAML mapping' unless @config.is_a?(Hash)
|
|
78
|
+
raise ConnectionError, 'Console configuration keys must be strings' unless @config.keys.all?(String)
|
|
79
|
+
|
|
80
|
+
unknown = @config.keys - CONFIG_KEYS
|
|
81
|
+
unless unknown.empty?
|
|
82
|
+
raise ConnectionError,
|
|
83
|
+
"Unsupported console.yml keys: #{unknown.map(&:inspect).join(', ')}. " \
|
|
84
|
+
"Use top-level #{CONFIG_KEYS.join(', ')} launch options; " \
|
|
85
|
+
'configure access controls and redaction in the Rails initializer.'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
@config.each do |key, value|
|
|
89
|
+
next if valid_launch_string?(value)
|
|
90
|
+
|
|
91
|
+
raise ConnectionError, "Console #{key} must be a non-empty string without NUL bytes"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def valid_launch_string?(value)
|
|
96
|
+
value.is_a?(String) && !value.strip.empty? && !value.include?("\0")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_mode_options!
|
|
100
|
+
accepted = MODE_OPTIONS[@mode]
|
|
101
|
+
return unless accepted
|
|
102
|
+
|
|
103
|
+
unused = @config.keys - accepted
|
|
104
|
+
return if unused.empty?
|
|
105
|
+
|
|
106
|
+
raise ConnectionError,
|
|
107
|
+
"Console options #{unused.join(', ')} are not used in #{@mode} mode; select the intended mode"
|
|
108
|
+
end
|
|
109
|
+
|
|
67
110
|
def embedded_argv
|
|
111
|
+
return default_argv unless @embedded_command
|
|
112
|
+
|
|
68
113
|
argv = @embedded_command.to_s.shellsplit
|
|
69
114
|
raise ConnectionError, 'Console command must not be empty' if argv.empty?
|
|
70
115
|
|
|
@@ -73,6 +118,14 @@ module Woods
|
|
|
73
118
|
raise ConnectionError, "Invalid console command: #{e.message}"
|
|
74
119
|
end
|
|
75
120
|
|
|
121
|
+
def default_argv
|
|
122
|
+
if @mode == 'direct'
|
|
123
|
+
binstub = File.expand_path('bin/rake', @config.fetch('directory', Dir.pwd))
|
|
124
|
+
return [binstub, 'woods:console'] if File.file?(binstub) && File.executable?(binstub)
|
|
125
|
+
end
|
|
126
|
+
DEFAULT_COMMAND.shellsplit
|
|
127
|
+
end
|
|
128
|
+
|
|
76
129
|
def docker_command
|
|
77
130
|
container = @config['container'] || raise(ConnectionError, 'Docker mode requires container name')
|
|
78
131
|
['docker', 'exec', '-i', container] + embedded_argv
|
|
@@ -620,9 +620,9 @@ module Woods
|
|
|
620
620
|
|
|
621
621
|
def handle_association_count(params)
|
|
622
622
|
model = resolve_model(params['model'])
|
|
623
|
-
association_name = params['association']
|
|
623
|
+
association_name = params['association'].to_sym
|
|
624
624
|
requested_scope = params['scope']
|
|
625
|
-
reflection = model.reflect_on_association(association_name
|
|
625
|
+
reflection = model.reflect_on_association(association_name)
|
|
626
626
|
|
|
627
627
|
raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}" unless reflection
|
|
628
628
|
|
|
@@ -637,13 +637,38 @@ module Woods
|
|
|
637
637
|
# association's own model before any database I/O runs (not just
|
|
638
638
|
# before the association is read): `model.find` below is itself a
|
|
639
639
|
# query, and a request with a bad scope should never reach it.
|
|
640
|
-
|
|
640
|
+
validate_association_scope!(requested_scope, reflection)
|
|
641
641
|
|
|
642
642
|
record = checked_relation(model).find(params['id'])
|
|
643
|
-
|
|
643
|
+
association = record.association(association_name)
|
|
644
|
+
return { 'count' => 0 } if empty_polymorphic_association?(association, reflection)
|
|
645
|
+
|
|
646
|
+
scope = association.scope
|
|
647
|
+
return { 'count' => 0 } unless scope
|
|
648
|
+
|
|
644
649
|
scope = apply_scope(scope, requested_scope, model_name: reflection.klass.name) if requested_scope
|
|
645
650
|
gate_association_sql!(scope)
|
|
646
|
-
{ 'count' => scope
|
|
651
|
+
{ 'count' => association_count(scope, reflection) }
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def validate_association_scope!(scope, reflection)
|
|
655
|
+
return unless scope
|
|
656
|
+
|
|
657
|
+
if reflection.respond_to?(:polymorphic?) && reflection.polymorphic?
|
|
658
|
+
raise ValidationError, 'Rejected: scoped polymorphic association counts require a concrete target model.'
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
validate_scope_columns!(scope, reflection.klass.name)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def empty_polymorphic_association?(association, reflection)
|
|
665
|
+
reflection.respond_to?(:polymorphic?) && reflection.polymorphic? && association.klass.nil?
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def association_count(scope, reflection)
|
|
669
|
+
return scope.count if reflection.collection?
|
|
670
|
+
|
|
671
|
+
scope.exists? ? 1 : 0
|
|
647
672
|
end
|
|
648
673
|
|
|
649
674
|
# Materialize a model's default scope once, inspect the resolved SQL,
|
|
@@ -73,6 +73,12 @@ module Woods
|
|
|
73
73
|
# disabled when the host requires a narrower database capability.
|
|
74
74
|
#
|
|
75
75
|
class RackMiddleware # rubocop:disable Metrics/ClassLength
|
|
76
|
+
EAGER_LOAD_UNAVAILABLE = JSON.generate(
|
|
77
|
+
jsonrpc: '2.0', id: nil,
|
|
78
|
+
error: { code: -32_000,
|
|
79
|
+
message: 'Console unavailable: Rails eager loading failed. ' \
|
|
80
|
+
'Fix the application load error and restart the server.' }
|
|
81
|
+
).freeze
|
|
76
82
|
# @param app [#call] The next Rack app in the middleware stack
|
|
77
83
|
# @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
|
|
78
84
|
# @param embedded_read_tools [Boolean, #call] Enable sql/query tools in
|
|
@@ -118,6 +124,11 @@ module Woods
|
|
|
118
124
|
|
|
119
125
|
def handle_request(env)
|
|
120
126
|
transport = ensure_transport
|
|
127
|
+
unless transport
|
|
128
|
+
return [503, { 'content-type' => 'application/json', 'cache-control' => 'no-store' },
|
|
129
|
+
[EAGER_LOAD_UNAVAILABLE]]
|
|
130
|
+
end
|
|
131
|
+
|
|
121
132
|
request_env = env.dup
|
|
122
133
|
request_env.delete('HTTP_MCP_SESSION_ID') if @stateless_mode
|
|
123
134
|
transport.handle_request(Rack::Request.new(request_env))
|
|
@@ -168,14 +179,16 @@ module Woods
|
|
|
168
179
|
# @return [::MCP::Server::Transports::StreamableHTTPTransport]
|
|
169
180
|
def ensure_transport
|
|
170
181
|
return @transport if @transport
|
|
182
|
+
return if @eager_load_failed
|
|
171
183
|
|
|
172
184
|
@mutex.synchronize do
|
|
173
185
|
return @transport if @transport
|
|
186
|
+
return if @eager_load_failed
|
|
174
187
|
|
|
175
188
|
check_blocked_tables_config!
|
|
176
189
|
|
|
177
190
|
require 'woods/console/server'
|
|
178
|
-
|
|
191
|
+
return unless eager_load_application
|
|
179
192
|
|
|
180
193
|
server = build_embedded_server
|
|
181
194
|
@stateless_mode = resolve_deferred(@stateless)
|
|
@@ -187,6 +200,21 @@ module Woods
|
|
|
187
200
|
end
|
|
188
201
|
end
|
|
189
202
|
|
|
203
|
+
# A failed loader may have registered only part of the application.
|
|
204
|
+
# Keep this worker unavailable rather than retrying against partial state.
|
|
205
|
+
def eager_load_application
|
|
206
|
+
Rails.application.eager_load!
|
|
207
|
+
true
|
|
208
|
+
rescue NameError => e
|
|
209
|
+
@eager_load_failed = true
|
|
210
|
+
structured_logger.error(
|
|
211
|
+
'console.eager_load.failed', error_class: e.class.name,
|
|
212
|
+
remediation: 'Run Rails.application.eager_load! in the application ' \
|
|
213
|
+
'environment; fix the error and restart.'
|
|
214
|
+
)
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
|
|
190
218
|
# Emit a prominent warning (or raise in production) when the Console MCP
|
|
191
219
|
# is enabled but no tables are blocked. An empty block list means Layer 1
|
|
192
220
|
# of the defense stack is fully inactive — every table in the database is
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'set'
|
|
4
4
|
require 'json'
|
|
5
|
+
require_relative 'source_contributors'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
# DependencyGraph tracks relationships between code units for:
|
|
@@ -32,7 +33,7 @@ module Woods
|
|
|
32
33
|
# enforce_dependencies: package units (Task 7)
|
|
33
34
|
# commit_count, change_frequency: git enrichment (Task 5)
|
|
34
35
|
NODE_ATTRIBUTE_KEYS = %i[
|
|
35
|
-
database table foreign_key_tables package enforce_dependencies commit_count change_frequency kind
|
|
36
|
+
database table foreign_key_tables package enforce_dependencies commit_count change_frequency kind source_paths
|
|
36
37
|
].freeze
|
|
37
38
|
|
|
38
39
|
# These extractors describe a whole source file rather than one constant.
|
|
@@ -86,7 +87,9 @@ module Woods
|
|
|
86
87
|
|
|
87
88
|
(@edges[unit.identifier] ||= {})[unit.type] =
|
|
88
89
|
self.class.normalize_edges(unit.dependencies, strict: true)
|
|
89
|
-
(@
|
|
90
|
+
node_paths(@nodes[unit.identifier][unit.type]).each do |path|
|
|
91
|
+
(@file_map[path] ||= Set.new).add(unit.identifier)
|
|
92
|
+
end
|
|
90
93
|
|
|
91
94
|
# Type index for filtering (Set-based for O(1) insert)
|
|
92
95
|
(@type_index[unit.type] ||= Set.new).add(unit.identifier)
|
|
@@ -126,7 +129,7 @@ module Woods
|
|
|
126
129
|
old_node = @nodes[identifier]&.[](t)
|
|
127
130
|
next unless old_node
|
|
128
131
|
|
|
129
|
-
drop_from_file_map(identifier,
|
|
132
|
+
node_paths(old_node).each { |path| drop_from_file_map(identifier, path, withdrawing) }
|
|
130
133
|
drop_from_type_index(identifier, old_node[:type])
|
|
131
134
|
end
|
|
132
135
|
end
|
|
@@ -187,7 +190,7 @@ module Woods
|
|
|
187
190
|
return unless path
|
|
188
191
|
|
|
189
192
|
return if (@nodes[identifier] || {}).any? do |type, node|
|
|
190
|
-
!withdrawing.include?(type) && node
|
|
193
|
+
!withdrawing.include?(type) && node_paths(node).include?(path)
|
|
191
194
|
end
|
|
192
195
|
|
|
193
196
|
return unless (ids = @file_map[path])
|
|
@@ -425,8 +428,9 @@ module Woods
|
|
|
425
428
|
def units_for_path(file_path)
|
|
426
429
|
(@file_map[file_path] || []).flat_map do |identifier|
|
|
427
430
|
nodes = sorted_nodes(@nodes[identifier] || {})
|
|
428
|
-
at_path = nodes.select { |_, node| node
|
|
429
|
-
|
|
431
|
+
at_path = nodes.select { |_, node| node_paths(node).include?(file_path) }
|
|
432
|
+
legacy = at_path.empty? && nodes.none? { |_, node| node.key?(:source_paths) }
|
|
433
|
+
(legacy ? nodes : at_path).map { |type, _| [identifier, type] }
|
|
430
434
|
end
|
|
431
435
|
end
|
|
432
436
|
|
|
@@ -640,6 +644,13 @@ module Woods
|
|
|
640
644
|
@suffix_groups = groups
|
|
641
645
|
end
|
|
642
646
|
|
|
647
|
+
# @param node [Hash] one typed graph node
|
|
648
|
+
# @return [Array<String>] every registered physical source
|
|
649
|
+
def node_paths(node)
|
|
650
|
+
node[:source_paths] || Array(node[:file_path])
|
|
651
|
+
end
|
|
652
|
+
private :node_paths
|
|
653
|
+
|
|
643
654
|
# Node attributes a unit's metadata supplies at registration time.
|
|
644
655
|
#
|
|
645
656
|
# `table` is taken only from model units and `enforce_dependencies` only
|
|
@@ -655,6 +666,11 @@ module Woods
|
|
|
655
666
|
attrs[:table] = metadata[:table_name] if unit.type == :model && !metadata[:table_name].nil?
|
|
656
667
|
tables = Array(metadata[:foreign_keys]).filter_map { |fk| fk[:to_table] || fk['to_table'] if fk.is_a?(Hash) }
|
|
657
668
|
attrs[:foreign_key_tables] = tables if tables.any?
|
|
669
|
+
if SourceContributors.multiple?(unit)
|
|
670
|
+
attrs[:source_paths] = SourceContributors.paths(unit).map do |path|
|
|
671
|
+
self.class.absolutize(path, self.class.graph_root)
|
|
672
|
+
end
|
|
673
|
+
end
|
|
658
674
|
attrs[:package] = metadata[:package] unless metadata[:package].nil?
|
|
659
675
|
if unit.type == :package && !metadata[:enforce_dependencies].nil?
|
|
660
676
|
attrs[:enforce_dependencies] = metadata[:enforce_dependencies]
|
|
@@ -928,11 +944,18 @@ module Woods
|
|
|
928
944
|
File.join(root, path)
|
|
929
945
|
end
|
|
930
946
|
|
|
947
|
+
def self.relocate_node_paths(node, &block)
|
|
948
|
+
result = node.dup
|
|
949
|
+
result[:file_path] = yield(node[:file_path]) if node[:file_path]
|
|
950
|
+
result[:source_paths] = node[:source_paths].map(&block) if node[:source_paths]
|
|
951
|
+
result
|
|
952
|
+
end
|
|
953
|
+
|
|
931
954
|
def self.relativize_nodes(nodes, root)
|
|
932
955
|
return nodes if root.nil?
|
|
933
956
|
|
|
934
957
|
nodes.transform_values do |node|
|
|
935
|
-
node
|
|
958
|
+
relocate_node_paths(node) { |path| relativize(path, root) }
|
|
936
959
|
end
|
|
937
960
|
end
|
|
938
961
|
|
|
@@ -940,7 +963,7 @@ module Woods
|
|
|
940
963
|
return nodes if root.nil?
|
|
941
964
|
|
|
942
965
|
nodes.transform_values do |node|
|
|
943
|
-
node
|
|
966
|
+
relocate_node_paths(node) { |path| absolutize(path, root) }
|
|
944
967
|
end
|
|
945
968
|
end
|
|
946
969
|
|
|
@@ -950,7 +973,7 @@ module Woods
|
|
|
950
973
|
return variants if root.nil?
|
|
951
974
|
|
|
952
975
|
variants.map do |record|
|
|
953
|
-
record
|
|
976
|
+
relocate_node_paths(record) { |path| relativize(path, root) }
|
|
954
977
|
end
|
|
955
978
|
end
|
|
956
979
|
|
|
@@ -989,7 +1012,7 @@ module Woods
|
|
|
989
1012
|
# @return [Object]
|
|
990
1013
|
def self.normalize_node_attribute(key, value)
|
|
991
1014
|
case key
|
|
992
|
-
when :foreign_key_tables then Array(value).map(&:to_s).uniq.sort
|
|
1015
|
+
when :source_paths, :foreign_key_tables then Array(value).map(&:to_s).uniq.sort
|
|
993
1016
|
when :commit_count then value.to_i
|
|
994
1017
|
when :enforce_dependencies then [true, false].include?(value) ? value : value.to_s
|
|
995
1018
|
else value.to_s
|
|
@@ -1124,6 +1147,7 @@ module Woods
|
|
|
1124
1147
|
file_path: absolutize(record[:file_path] || record['file_path'], root),
|
|
1125
1148
|
namespace: record[:namespace] || record['namespace']
|
|
1126
1149
|
}.merge(persisted_node_attributes(record))
|
|
1150
|
+
nodes[identifier][type] = relocate_node_paths(nodes[identifier][type]) { |path| absolutize(path, root) }
|
|
1127
1151
|
(edges[identifier] ||= {})[type] = normalize_edges(record[:edges] || record['edges'])
|
|
1128
1152
|
end
|
|
1129
1153
|
end
|
data/lib/woods/embedding/fake.rb
CHANGED
|
@@ -89,6 +89,18 @@ module Woods
|
|
|
89
89
|
@dims
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
alias configured_dimensions dimensions
|
|
93
|
+
|
|
94
|
+
# @return [Array] deterministic, non-secret embedding configuration
|
|
95
|
+
def cache_identity
|
|
96
|
+
[self.class.name, @model, @dims]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Pure constructor settings for restoring the deterministic provider.
|
|
100
|
+
def configuration_options
|
|
101
|
+
{ model: @model, dims: @dims }
|
|
102
|
+
end
|
|
103
|
+
|
|
92
104
|
# @return [String] the configured model name (default
|
|
93
105
|
# {DEFAULT_MODEL_NAME})
|
|
94
106
|
def model_name
|