woods 2.0.0.beta3 → 2.0.0.beta4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/CONTRIBUTING.md +29 -17
- data/README.md +92 -177
- data/docs/AGENT_GUIDE.md +26 -4
- data/docs/AGENT_SETUP.md +18 -8
- data/docs/BACKEND_MATRIX.md +5 -0
- data/docs/CONFIGURATION_REFERENCE.md +74 -8
- data/docs/CONSOLE_MCP_SETUP.md +45 -2
- data/docs/DOCKER_SETUP.md +1 -1
- data/docs/EXTRACTOR_REFERENCE.md +9 -1
- data/docs/INCREMENTAL_EXTRACTION.md +30 -6
- data/docs/MCP_SERVERS.md +57 -2
- data/docs/MCP_TOOL_COOKBOOK.md +4 -4
- data/docs/MCP_WORKTREE_SETUP.md +43 -83
- data/docs/PUBLISHED_INDEX.md +17 -0
- data/docs/RETRIEVAL_GUIDE.md +24 -5
- data/docs/TROUBLESHOOTING.md +12 -13
- data/docs/UPGRADING_TO_2.md +6 -2
- data/docs/WATCH_DAEMON.md +18 -8
- data/exe/woods-mcp-start +14 -9
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/woods/agent_configuration/applier.rb +5 -3
- data/lib/woods/agent_configuration/cli.rb +2 -2
- data/lib/woods/agent_configuration/layout.rb +13 -0
- data/lib/woods/console/credential_scanner.rb +4 -3
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +31 -9
- data/lib/woods/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/embedding/indexer.rb +24 -14
- data/lib/woods/extractor.rb +45 -12
- data/lib/woods/extractors/declared_parent.rb +55 -0
- data/lib/woods/extractors/graphql_extractor.rb +2 -11
- data/lib/woods/extractors/lib_extractor.rb +10 -8
- data/lib/woods/extractors/mailer_extractor.rb +6 -10
- data/lib/woods/extractors/model_extractor.rb +1 -15
- data/lib/woods/extractors/poro_extractor.rb +10 -8
- data/lib/woods/extractors/shared_utility_methods.rb +22 -5
- data/lib/woods/mcp/bearer_auth.rb +2 -1
- data/lib/woods/mcp/bootstrapper.rb +17 -4
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +11 -2
- data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
- data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
- data/lib/woods/mcp/server.rb +22 -28
- data/lib/woods/mcp/tool_contract.rb +1 -1
- data/lib/woods/mcp/tool_response_renderer.rb +16 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
- data/lib/woods/mcp/traversal_response.rb +22 -0
- data/lib/woods/path_dispatcher.rb +6 -5
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/rake_helpers.rb +2 -12
- data/lib/woods/retrieval/lexical_assembler.rb +14 -3
- data/lib/woods/retrieval/lexical_index.rb +2 -1
- data/lib/woods/session_tracer/file_store.rb +6 -1
- data/lib/woods/source_inputs/consumer_errors.rb +4 -0
- data/lib/woods/storage/pgvector.rb +6 -2
- data/lib/woods/temporal/json_snapshot_store.rb +35 -7
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +18 -4
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/woods-input-rules.sh +4 -4
- data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
- data/plugin/skills/woods-diagnose/SKILL.md +64 -33
- data/plugin/skills/woods-investigate/SKILL.md +51 -12
- data/plugin/skills/woods-mcp-config/SKILL.md +11 -11
- data/plugin/skills/woods-setup/SKILL.md +14 -11
- metadata +8 -5
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Extractors
|
|
7
|
+
# Reads only the selected declaration's explicit constant-path parent.
|
|
8
|
+
# Identity selection remains with the extractor; literals and unrelated
|
|
9
|
+
# declarations must never supply metadata for that identity.
|
|
10
|
+
module DeclaredParent
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(source, identifier)
|
|
14
|
+
parsed = Prism.parse(source)
|
|
15
|
+
return nil unless parsed.success?
|
|
16
|
+
|
|
17
|
+
find(parsed.value, identifier, '')&.first
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def find(node, identifier, namespace)
|
|
21
|
+
return unless node
|
|
22
|
+
|
|
23
|
+
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
24
|
+
name = constant_name(node.constant_path)
|
|
25
|
+
return unless name
|
|
26
|
+
|
|
27
|
+
qualified = name.start_with?('::') ? name.delete_prefix('::') : [namespace, name].reject(&:empty?).join('::')
|
|
28
|
+
if qualified == identifier
|
|
29
|
+
return [node.is_a?(Prism::ClassNode) ? constant_name(node.superclass) : nil]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return find(node.body, identifier, qualified)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
node.compact_child_nodes.each do |child|
|
|
36
|
+
match = find(child, identifier, namespace)
|
|
37
|
+
return match if match
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
private_class_method :find
|
|
42
|
+
|
|
43
|
+
def constant_name(node)
|
|
44
|
+
case node
|
|
45
|
+
when Prism::ConstantReadNode
|
|
46
|
+
node.name.to_s
|
|
47
|
+
when Prism::ConstantPathNode
|
|
48
|
+
parent = node.parent ? constant_name(node.parent) : ''
|
|
49
|
+
"#{parent}::#{node.name}" if parent
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
private_class_method :constant_name
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -441,11 +441,11 @@ module Woods
|
|
|
441
441
|
# @param unit_type [Symbol]
|
|
442
442
|
# @param runtime_class [Class, nil]
|
|
443
443
|
# @return [Hash]
|
|
444
|
-
def build_metadata(source,
|
|
444
|
+
def build_metadata(source, class_name, _unit_type, runtime_class)
|
|
445
445
|
{
|
|
446
446
|
# GraphQL classification
|
|
447
447
|
graphql_kind: detect_graphql_kind(source, runtime_class),
|
|
448
|
-
parent_class: extract_parent_class(source),
|
|
448
|
+
parent_class: extract_parent_class(source, class_name),
|
|
449
449
|
|
|
450
450
|
# Fields and arguments
|
|
451
451
|
fields: extract_fields(source, runtime_class),
|
|
@@ -508,15 +508,6 @@ module Woods
|
|
|
508
508
|
:object
|
|
509
509
|
end
|
|
510
510
|
|
|
511
|
-
# Extract the parent class name from source
|
|
512
|
-
#
|
|
513
|
-
# @param source [String]
|
|
514
|
-
# @return [String, nil]
|
|
515
|
-
def extract_parent_class(source)
|
|
516
|
-
match = source.match(/class\s+\w+\s*<\s*([\w:]+)/)
|
|
517
|
-
match ? match[1] : nil
|
|
518
|
-
end
|
|
519
|
-
|
|
520
511
|
# Extract field definitions from source and/or runtime
|
|
521
512
|
#
|
|
522
513
|
# @param source [String]
|
|
@@ -80,9 +80,11 @@ module Woods
|
|
|
80
80
|
file_path: file_path
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
+
parent_class = extract_parent_class(source, class_name)
|
|
84
|
+
|
|
83
85
|
unit.namespace = extract_namespace(class_name)
|
|
84
|
-
unit.source_code = annotate_source(source, class_name)
|
|
85
|
-
unit.metadata = extract_metadata(source,
|
|
86
|
+
unit.source_code = annotate_source(source, class_name, parent_class)
|
|
87
|
+
unit.metadata = extract_metadata(source, parent_class)
|
|
86
88
|
unit.dependencies = extract_dependencies(source)
|
|
87
89
|
|
|
88
90
|
unit
|
|
@@ -170,11 +172,11 @@ module Woods
|
|
|
170
172
|
#
|
|
171
173
|
# @param source [String] Ruby source code
|
|
172
174
|
# @param class_name [String] The inferred constant name
|
|
175
|
+
# @param parent_class [String, nil] Selected explicit parent
|
|
173
176
|
# @return [String] Annotated source
|
|
174
|
-
def annotate_source(source, class_name)
|
|
175
|
-
parent = extract_parent_class(source)
|
|
177
|
+
def annotate_source(source, class_name, parent_class)
|
|
176
178
|
entry_points = detect_entry_points(source)
|
|
177
|
-
parent_label =
|
|
179
|
+
parent_label = parent_class || 'none'
|
|
178
180
|
|
|
179
181
|
annotation = <<~ANNOTATION
|
|
180
182
|
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
@@ -195,14 +197,14 @@ module Woods
|
|
|
195
197
|
# Build the metadata hash for a lib unit.
|
|
196
198
|
#
|
|
197
199
|
# @param source [String] Ruby source code
|
|
198
|
-
# @param
|
|
200
|
+
# @param parent_class [String, nil] Selected explicit parent
|
|
199
201
|
# @return [Hash] Lib unit metadata
|
|
200
|
-
def extract_metadata(source,
|
|
202
|
+
def extract_metadata(source, parent_class)
|
|
201
203
|
{
|
|
202
204
|
public_methods: extract_public_methods(source),
|
|
203
205
|
class_methods: extract_class_methods(source),
|
|
204
206
|
initialize_params: extract_initialize_params(source),
|
|
205
|
-
parent_class:
|
|
207
|
+
parent_class: parent_class,
|
|
206
208
|
loc: count_loc(source),
|
|
207
209
|
method_count: source.scan(/def\s+(?:self\.)?\w+/).size,
|
|
208
210
|
entry_points: detect_entry_points(source)
|
|
@@ -116,12 +116,8 @@ module Woods
|
|
|
116
116
|
# ──────────────────────────────────────────────────────────────────────
|
|
117
117
|
|
|
118
118
|
def annotate_source(source, mailer)
|
|
119
|
-
actions = mailer.action_methods.
|
|
120
|
-
default_from =
|
|
121
|
-
mailer.default[:from]
|
|
122
|
-
rescue StandardError
|
|
123
|
-
nil
|
|
124
|
-
end
|
|
119
|
+
actions = mailer.action_methods.sort
|
|
120
|
+
default_from = extract_defaults(mailer)[:from]
|
|
125
121
|
|
|
126
122
|
<<~ANNOTATION
|
|
127
123
|
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
@@ -139,7 +135,7 @@ module Woods
|
|
|
139
135
|
# ──────────────────────────────────────────────────────────────────────
|
|
140
136
|
|
|
141
137
|
def extract_metadata(mailer, source)
|
|
142
|
-
actions = mailer.action_methods.
|
|
138
|
+
actions = mailer.action_methods.sort
|
|
143
139
|
|
|
144
140
|
{
|
|
145
141
|
# Actions (mail methods)
|
|
@@ -171,7 +167,7 @@ module Woods
|
|
|
171
167
|
|
|
172
168
|
def extract_defaults(mailer)
|
|
173
169
|
mailer_defaults = mailer.default
|
|
174
|
-
mailer_defaults.slice(:from, :reply_to, :cc, :bcc).compact
|
|
170
|
+
mailer_defaults.slice(:from, :reply_to, :cc, :bcc).compact.transform_values { |value| stable_filter(value) }
|
|
175
171
|
rescue StandardError
|
|
176
172
|
{}
|
|
177
173
|
end
|
|
@@ -182,7 +178,7 @@ module Woods
|
|
|
182
178
|
|
|
183
179
|
result = {
|
|
184
180
|
type: :"#{cb.kind}_action",
|
|
185
|
-
filter: cb
|
|
181
|
+
filter: callback_filter_label(cb)
|
|
186
182
|
}
|
|
187
183
|
result[:only] = only if only.any?
|
|
188
184
|
result[:except] = except if except.any?
|
|
@@ -274,7 +270,7 @@ module Woods
|
|
|
274
270
|
# ──────────────────────────────────────────────────────────────────────
|
|
275
271
|
|
|
276
272
|
def build_action_chunks(mailer, _source)
|
|
277
|
-
mailer.action_methods.filter_map do |action|
|
|
273
|
+
mailer.action_methods.sort.filter_map do |action|
|
|
278
274
|
action_source = extract_action_source(mailer, action)
|
|
279
275
|
next if action_source.nil? || action_source.strip.empty?
|
|
280
276
|
|
|
@@ -619,7 +619,7 @@ module Woods
|
|
|
619
619
|
entry = {
|
|
620
620
|
attribute: attribute,
|
|
621
621
|
type: v.class.name.demodulize.underscore.sub(/_validator$/, ''),
|
|
622
|
-
options: v.options.except(:if, :unless, :on),
|
|
622
|
+
options: v.options.except(:if, :unless, :on).transform_values { |value| stable_filter(value) },
|
|
623
623
|
conditions: format_validation_conditions(v)
|
|
624
624
|
}
|
|
625
625
|
entry[:implicit_belongs_to] = true if implicit_belongs_to_validator?(model, v)
|
|
@@ -650,20 +650,6 @@ module Woods
|
|
|
650
650
|
end.compact
|
|
651
651
|
end
|
|
652
652
|
|
|
653
|
-
# Preserve application labels; strip addresses only from Ruby's actual
|
|
654
|
-
# default representation. Nested identities occur for anonymous classes.
|
|
655
|
-
# These are descriptive labels, not serialization of callback object state.
|
|
656
|
-
def callback_filter_label(callback)
|
|
657
|
-
filter = callback_filter(callback)
|
|
658
|
-
label = filter.to_s
|
|
659
|
-
return label unless label.start_with?('#<')
|
|
660
|
-
|
|
661
|
-
owner = filter.is_a?(Module) ? Module : Kernel
|
|
662
|
-
return label unless label == owner.instance_method(:to_s).bind(filter).call
|
|
663
|
-
|
|
664
|
-
label.gsub(/:0x[0-9a-f]+(?=>)/i, '')
|
|
665
|
-
end
|
|
666
|
-
|
|
667
653
|
# Extract scopes with their source if available.
|
|
668
654
|
# Parses the full source with the AST layer to get accurate scope
|
|
669
655
|
# boundaries, falling back to regex line-scanning on parse failure.
|
|
@@ -82,9 +82,11 @@ module Woods
|
|
|
82
82
|
file_path: file_path
|
|
83
83
|
)
|
|
84
84
|
|
|
85
|
+
parent_class = extract_parent_class(source, class_name)
|
|
86
|
+
|
|
85
87
|
unit.namespace = extract_namespace(class_name)
|
|
86
|
-
unit.source_code = annotate_source(source, class_name)
|
|
87
|
-
unit.metadata = extract_metadata(source,
|
|
88
|
+
unit.source_code = annotate_source(source, class_name, parent_class)
|
|
89
|
+
unit.metadata = extract_metadata(source, parent_class)
|
|
88
90
|
unit.dependencies = extract_dependencies(source)
|
|
89
91
|
|
|
90
92
|
unit
|
|
@@ -177,10 +179,10 @@ module Woods
|
|
|
177
179
|
#
|
|
178
180
|
# @param source [String] Ruby source code
|
|
179
181
|
# @param class_name [String] The class name
|
|
182
|
+
# @param parent_class [String, nil] Selected explicit parent
|
|
180
183
|
# @return [String] Annotated source
|
|
181
|
-
def annotate_source(source, class_name)
|
|
182
|
-
|
|
183
|
-
parent_label = parent || 'none'
|
|
184
|
+
def annotate_source(source, class_name, parent_class)
|
|
185
|
+
parent_label = parent_class || 'none'
|
|
184
186
|
|
|
185
187
|
annotation = <<~ANNOTATION
|
|
186
188
|
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
@@ -200,14 +202,14 @@ module Woods
|
|
|
200
202
|
# Build the metadata hash for a PORO unit.
|
|
201
203
|
#
|
|
202
204
|
# @param source [String] Ruby source code
|
|
203
|
-
# @param
|
|
205
|
+
# @param parent_class [String, nil] Selected explicit parent
|
|
204
206
|
# @return [Hash] PORO metadata
|
|
205
|
-
def extract_metadata(source,
|
|
207
|
+
def extract_metadata(source, parent_class)
|
|
206
208
|
{
|
|
207
209
|
public_methods: extract_public_methods(source),
|
|
208
210
|
class_methods: extract_class_methods(source),
|
|
209
211
|
initialize_params: extract_initialize_params(source),
|
|
210
|
-
parent_class:
|
|
212
|
+
parent_class: parent_class,
|
|
211
213
|
loc: count_loc(source),
|
|
212
214
|
method_count: source.scan(/def\s+(?:self\.)?\w+/).size
|
|
213
215
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'declared_parent'
|
|
4
|
+
|
|
3
5
|
require_relative 'source_nesting'
|
|
4
6
|
require_relative 'method_parameters'
|
|
5
7
|
|
|
@@ -125,10 +127,10 @@ module Woods
|
|
|
125
127
|
# Extract the parent class name from a class definition.
|
|
126
128
|
#
|
|
127
129
|
# @param source [String] Ruby source code
|
|
128
|
-
# @
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
# @param class_name [String] Already selected extraction identity
|
|
131
|
+
# @return [String, nil] Explicit parent of that declaration, or nil
|
|
132
|
+
def extract_parent_class(source, class_name)
|
|
133
|
+
DeclaredParent.call(source, class_name)
|
|
132
134
|
end
|
|
133
135
|
|
|
134
136
|
# Count non-blank, non-comment lines of code.
|
|
@@ -243,7 +245,22 @@ module Woods
|
|
|
243
245
|
end
|
|
244
246
|
"#<#{filter.lambda? ? 'lambda' : 'Proc'} #{site}>"
|
|
245
247
|
end
|
|
246
|
-
|
|
248
|
+
|
|
249
|
+
# Preserve application labels; strip addresses only from Ruby's actual
|
|
250
|
+
# default representation. Nested identities occur for anonymous classes.
|
|
251
|
+
# These are descriptive labels, not serialization of callback object state.
|
|
252
|
+
def callback_filter_label(callback)
|
|
253
|
+
filter = callback_filter(callback)
|
|
254
|
+
label = filter.to_s
|
|
255
|
+
return label unless label.start_with?('#<')
|
|
256
|
+
|
|
257
|
+
owner = filter.is_a?(Module) ? Module : Kernel
|
|
258
|
+
return label unless label == owner.instance_method(:to_s).bind(filter).call
|
|
259
|
+
|
|
260
|
+
label.gsub(/:0x[0-9a-f]+(?=>)/i, '')
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private :callback_filter, :stable_filter, :callback_filter_label
|
|
247
264
|
|
|
248
265
|
# Human-readable label for a non-ActionFilter condition.
|
|
249
266
|
#
|
|
@@ -54,7 +54,8 @@ module Woods
|
|
|
54
54
|
|
|
55
55
|
expected = resolve_token
|
|
56
56
|
header = env['HTTP_AUTHORIZATION'].to_s
|
|
57
|
-
|
|
57
|
+
# Only the ASCII scheme changes case; do not normalize or trim the token.
|
|
58
|
+
presented = header.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header[7..] : nil
|
|
58
59
|
|
|
59
60
|
if expected && presented && Rack::Utils.secure_compare(expected, presented)
|
|
60
61
|
@app.call(env)
|
|
@@ -28,16 +28,19 @@ module Woods
|
|
|
28
28
|
# @param argv [Array<String>] Command-line arguments
|
|
29
29
|
# @return [String] Validated index directory path
|
|
30
30
|
def self.resolve_index_dir(argv)
|
|
31
|
-
dir = argv[0] || ENV['WOODS_DIR'] || Dir.pwd
|
|
31
|
+
dir = argv[0] || ENV['WOODS_DIR'] || ENV['WOODS_OUTPUT'] || Dir.pwd
|
|
32
|
+
examined = dir.empty? ? '(empty path)' : File.expand_path(dir)
|
|
32
33
|
|
|
33
34
|
unless Dir.exist?(dir)
|
|
34
|
-
warn "Error: Index directory does not exist: #{
|
|
35
|
+
warn "Error: Index directory does not exist: #{examined}"
|
|
36
|
+
warn index_path_remedy
|
|
35
37
|
exit 1
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
unless manifest_present?(dir)
|
|
39
|
-
warn "Error:
|
|
40
|
-
warn '
|
|
41
|
+
warn "Error: Could not resolve a published Woods index in: #{examined}"
|
|
42
|
+
warn 'Expected generation.json pointing to a payload manifest.json, or a legacy flat manifest.json.'
|
|
43
|
+
warn index_path_remedy
|
|
41
44
|
exit 1
|
|
42
45
|
end
|
|
43
46
|
|
|
@@ -61,9 +64,19 @@ module Woods
|
|
|
61
64
|
|
|
62
65
|
generation = Woods::Generation.new(output_dir: dir)
|
|
63
66
|
generation.payload_dir(generation.current).join('manifest.json').file?
|
|
67
|
+
rescue TypeError, NoMethodError
|
|
68
|
+
# A malformed marker must remain a startup failure, with the same
|
|
69
|
+
# selected-path guidance as a missing or unresolved payload.
|
|
70
|
+
false
|
|
64
71
|
end
|
|
65
72
|
private_class_method :manifest_present?
|
|
66
73
|
|
|
74
|
+
def self.index_path_remedy
|
|
75
|
+
'Point at the existing index with an explicit path, WOODS_DIR, or WOODS_OUTPUT. ' \
|
|
76
|
+
'If no index exists, run `bundle exec rake woods:extract` in your Rails app.'
|
|
77
|
+
end
|
|
78
|
+
private_class_method :index_path_remedy
|
|
79
|
+
|
|
67
80
|
# Build a snapshot store for temporal tracking.
|
|
68
81
|
#
|
|
69
82
|
# Auto-enables when a SQLite database already exists in the index directory,
|
|
@@ -295,7 +295,8 @@ module Woods
|
|
|
295
295
|
unless resolved.embedding_provider
|
|
296
296
|
warn '[woods-mcp] no woods.json and no embedding provider — serving ' \
|
|
297
297
|
'pattern/structural tools only. Run `rake woods:embed`, or set ' \
|
|
298
|
-
'OPENAI_API_KEY / run Ollama, to enable semantic search.'
|
|
298
|
+
'OPENAI_API_KEY / run Ollama, to enable semantic search. For ranked discovery with no embeddings, ' \
|
|
299
|
+
'set WOODS_RETRIEVAL_MODE=lexical in the MCP process environment and restart the server.'
|
|
299
300
|
end
|
|
300
301
|
[resolved, :autodetect]
|
|
301
302
|
end
|
|
@@ -60,11 +60,16 @@ module Woods
|
|
|
60
60
|
# @param auto_refresh [Boolean] re-read the index when its published
|
|
61
61
|
# generation moves. On by default; specs that assert caching behaviour
|
|
62
62
|
# turn it off.
|
|
63
|
-
# @raise [ArgumentError] if directory doesn't exist or
|
|
63
|
+
# @raise [ArgumentError] if directory doesn't exist or no published index resolves
|
|
64
64
|
def initialize(index_dir, auto_refresh: true)
|
|
65
65
|
@index_dir = Pathname.new(index_dir)
|
|
66
66
|
raise ArgumentError, "Index directory does not exist: #{index_dir}" unless @index_dir.directory?
|
|
67
|
-
|
|
67
|
+
unless manifest_present?
|
|
68
|
+
raise ArgumentError, "Could not resolve a published Woods index in: #{@index_dir.expand_path}\n" \
|
|
69
|
+
'Expected generation.json pointing to a payload manifest.json, or a legacy flat manifest.json. ' \
|
|
70
|
+
'Point IndexReader at an existing index root. ' \
|
|
71
|
+
'If no index exists, run `bundle exec rake woods:extract` in your Rails app.'
|
|
72
|
+
end
|
|
68
73
|
|
|
69
74
|
@unit_cache = {}
|
|
70
75
|
@unit_cache_signatures = {}
|
|
@@ -963,6 +968,10 @@ module Woods
|
|
|
963
968
|
|
|
964
969
|
marker = Woods::Generation.new(output_dir: @index_dir).current
|
|
965
970
|
resolve_payload_dir(marker).join('manifest.json').file?
|
|
971
|
+
rescue TypeError, NoMethodError
|
|
972
|
+
# Match Bootstrapper's startup preflight for malformed marker shapes.
|
|
973
|
+
# Keep this local: errors during later generation refresh still surface.
|
|
974
|
+
false
|
|
966
975
|
end
|
|
967
976
|
|
|
968
977
|
# The loaded generation's payload directory, without a freshness check.
|
|
@@ -152,11 +152,11 @@ module Woods
|
|
|
152
152
|
- **units_indexed** (manifest.json, `structure` tool) — total
|
|
153
153
|
ExtractedUnits written by the extractor. Canonical count.
|
|
154
154
|
- **graph_nodes** (`pagerank`, `dependencies`, `dependents`) —
|
|
155
|
-
units present in the dependency graph
|
|
156
|
-
|
|
155
|
+
units present in the dependency graph, including isolated units
|
|
156
|
+
with no incoming or outgoing edges.
|
|
157
157
|
- **searchable_entries** (`codebase_retrieve`) — retriever-store
|
|
158
|
-
entries
|
|
159
|
-
|
|
158
|
+
entries. Semantic mode may include per-chunk rows; lexical mode
|
|
159
|
+
ranks published units. Coverage depends on the configured store.
|
|
160
160
|
GLOSSARY
|
|
161
161
|
end
|
|
162
162
|
|
|
@@ -177,7 +177,7 @@ module Woods
|
|
|
177
177
|
|
|
178
178
|
GRAPH_ANALYSIS_SECTIONS.each do |section|
|
|
179
179
|
items = fetch_key(data, section)
|
|
180
|
-
next unless items.is_a?(Array) && items.any?
|
|
180
|
+
next unless items.is_a?(Array) && (items.any? || fetch_key(data, "#{section}_total"))
|
|
181
181
|
|
|
182
182
|
lines << "### #{section.tr('_', ' ').capitalize}"
|
|
183
183
|
lines << ''
|
|
@@ -194,11 +194,11 @@ module Woods
|
|
|
194
194
|
end
|
|
195
195
|
|
|
196
196
|
total_key = "#{section}_total"
|
|
197
|
-
if data
|
|
197
|
+
if fetch_key(data, total_key)
|
|
198
198
|
lines << ''
|
|
199
199
|
offset = fetch_key(data, "#{section}_offset", 0)
|
|
200
200
|
position = offset.positive? ? " from offset #{offset}" : ''
|
|
201
|
-
lines << "_Showing #{items.size} of #{data
|
|
201
|
+
lines << "_Showing #{items.size} of #{fetch_key(data, total_key)}#{position} (truncated)_"
|
|
202
202
|
end
|
|
203
203
|
lines << ''
|
|
204
204
|
end
|
|
@@ -417,6 +417,8 @@ module Woods
|
|
|
417
417
|
return lines.join("\n").rstrip
|
|
418
418
|
end
|
|
419
419
|
|
|
420
|
+
lines.concat(traversal_coverage_lines(data))
|
|
421
|
+
|
|
420
422
|
nodes.each do |id, info|
|
|
421
423
|
depth = fetch_key(info, :depth) || 0
|
|
422
424
|
deps = fetch_key(info, :deps, [])
|
|
@@ -438,7 +440,11 @@ module Woods
|
|
|
438
440
|
if fetch_key(data, :partial)
|
|
439
441
|
lines << "Partial traversal (#{fetch_key(data, :partial_reason)}); narrow depth/types/via or increase max_nodes/max_edges."
|
|
440
442
|
end
|
|
441
|
-
|
|
443
|
+
if (note = traversal_lower_bound_note(data, nodes.size))
|
|
444
|
+
lines << '' << "_#{note}_"
|
|
445
|
+
elsif fetch_key(data, :nodes_total)
|
|
446
|
+
lines << '' << truncation_note(data, nodes.size)
|
|
447
|
+
end
|
|
442
448
|
|
|
443
449
|
lines.join("\n").rstrip
|
|
444
450
|
end
|
|
@@ -126,9 +126,9 @@ module Woods
|
|
|
126
126
|
lines << 'Denominators:'
|
|
127
127
|
lines << ' units_indexed (manifest, structure): total ExtractedUnits written.'
|
|
128
128
|
lines << ' graph_nodes (pagerank, dependencies, dependents): units in the graph'
|
|
129
|
-
lines << ' (
|
|
130
|
-
lines << ' searchable_entries (codebase_retrieve): retriever-store entries
|
|
131
|
-
lines << ' per-chunk rows.
|
|
129
|
+
lines << ' (includes isolated units with no incoming/outgoing edges).'
|
|
130
|
+
lines << ' searchable_entries (codebase_retrieve): retriever-store entries; semantic mode may include'
|
|
131
|
+
lines << ' per-chunk rows; lexical mode ranks published units. Store coverage varies.'
|
|
132
132
|
|
|
133
133
|
lines.join("\n").rstrip
|
|
134
134
|
end
|
|
@@ -146,7 +146,7 @@ module Woods
|
|
|
146
146
|
|
|
147
147
|
GRAPH_ANALYSIS_SECTIONS.each do |section|
|
|
148
148
|
items = fetch_key(data, section)
|
|
149
|
-
next unless items.is_a?(Array) && items.any?
|
|
149
|
+
next unless items.is_a?(Array) && (items.any? || fetch_key(data, "#{section}_total"))
|
|
150
150
|
|
|
151
151
|
lines << "#{section.tr('_', ' ').upcase}:"
|
|
152
152
|
items.each do |item|
|
|
@@ -161,9 +161,9 @@ module Woods
|
|
|
161
161
|
|
|
162
162
|
total_key = "#{section}_total"
|
|
163
163
|
offset = fetch_key(data, "#{section}_offset", 0)
|
|
164
|
-
if data
|
|
164
|
+
if fetch_key(data, total_key)
|
|
165
165
|
position = offset.positive? ? " from offset #{offset}" : ''
|
|
166
|
-
lines << " (showing #{items.size} of #{data
|
|
166
|
+
lines << " (showing #{items.size} of #{fetch_key(data, total_key)}#{position}; truncated)"
|
|
167
167
|
end
|
|
168
168
|
lines << ''
|
|
169
169
|
end
|
|
@@ -279,6 +279,8 @@ module Woods
|
|
|
279
279
|
return lines.join("\n").rstrip
|
|
280
280
|
end
|
|
281
281
|
|
|
282
|
+
lines.concat(traversal_coverage_lines(data))
|
|
283
|
+
|
|
282
284
|
nodes.each do |id, info|
|
|
283
285
|
depth = fetch_key(info, :depth) || 0
|
|
284
286
|
deps = fetch_key(info, :deps, [])
|
|
@@ -294,7 +296,9 @@ module Woods
|
|
|
294
296
|
if fetch_key(data, :partial)
|
|
295
297
|
lines << "Partial traversal (#{fetch_key(data, :partial_reason)}); narrow depth/types/via or increase max_nodes/max_edges."
|
|
296
298
|
end
|
|
297
|
-
if
|
|
299
|
+
if (note = traversal_lower_bound_note(data, nodes.size))
|
|
300
|
+
lines << note
|
|
301
|
+
elsif fetch_key(data, :nodes_total)
|
|
298
302
|
offset = fetch_key(data, :nodes_offset, 0)
|
|
299
303
|
position = offset.positive? ? " from offset #{offset}" : ''
|
|
300
304
|
lines << " (showing #{nodes.size} of #{fetch_key(data, :nodes_total)}#{position}; truncated)"
|
data/lib/woods/mcp/server.rb
CHANGED
|
@@ -27,6 +27,7 @@ require_relative 'tasks/request_capture'
|
|
|
27
27
|
require_relative 'tasks/store'
|
|
28
28
|
require_relative 'tool_contract'
|
|
29
29
|
require_relative 'tool_response_renderer'
|
|
30
|
+
require_relative 'traversal_response'
|
|
30
31
|
require_relative 'version_aware_tool_dispatch'
|
|
31
32
|
|
|
32
33
|
module Woods
|
|
@@ -73,6 +74,7 @@ module Woods
|
|
|
73
74
|
# controls that actually shrink the answer are `depth`, `types` and
|
|
74
75
|
# `via`; `limit` and `offset` only page what those leave (B-183).
|
|
75
76
|
DEFAULT_TRAVERSAL_LIMIT = 50
|
|
77
|
+
DEFAULT_GRAPH_ANALYSIS_LIMIT = 20
|
|
76
78
|
|
|
77
79
|
class << self
|
|
78
80
|
# Build a configured MCP::Server with all tools and resources.
|
|
@@ -161,7 +163,8 @@ module Woods
|
|
|
161
163
|
'Narrow with depth, types and via first: they shrink the answer, ' \
|
|
162
164
|
'while limit and offset only page it. Returns a BFS tree with ' \
|
|
163
165
|
"depth, paged to #{DEFAULT_TRAVERSAL_LIMIT} nodes by default. " \
|
|
164
|
-
'max_nodes/max_edges bound the walk independently; partial_reason reports a budget cutoff. ' \
|
|
166
|
+
'max_nodes/max_edges bound the walk independently; partial_reason reports a budget cutoff and total_is_exact is false. ' \
|
|
167
|
+
'Published relationships are not exhaustive source-reference coverage. ' \
|
|
165
168
|
'Use explain:true for recorded directed relationships and bounded witnesses; ambiguous types remain explicit.',
|
|
166
169
|
reader_method: :traverse_dependencies,
|
|
167
170
|
render_key: :dependencies)
|
|
@@ -171,7 +174,8 @@ module Woods
|
|
|
171
174
|
'Narrow with depth, types and via first: they shrink the answer, ' \
|
|
172
175
|
'while limit and offset only page it. Returns a BFS tree with ' \
|
|
173
176
|
"depth, paged to #{DEFAULT_TRAVERSAL_LIMIT} nodes by default. " \
|
|
174
|
-
'max_nodes/max_edges bound the walk independently; partial_reason reports a budget cutoff. ' \
|
|
177
|
+
'max_nodes/max_edges bound the walk independently; partial_reason reports a budget cutoff and total_is_exact is false. ' \
|
|
178
|
+
'Published relationships are not exhaustive source-reference coverage. ' \
|
|
175
179
|
'Use explain:true for recorded directed relationships and bounded witnesses; ambiguous types remain explicit.',
|
|
176
180
|
reader_method: :traverse_dependents,
|
|
177
181
|
render_key: :dependents)
|
|
@@ -247,9 +251,9 @@ module Woods
|
|
|
247
251
|
!token.nil? && ids && !ids.empty?
|
|
248
252
|
end
|
|
249
253
|
|
|
250
|
-
def text_response(text)
|
|
254
|
+
def text_response(text, data: nil)
|
|
251
255
|
structured = { text: text }
|
|
252
|
-
structured[:data] = JSON.parse(text)
|
|
256
|
+
structured[:data] = data.nil? ? JSON.parse(text) : data
|
|
253
257
|
::MCP::Tool::Response.new(
|
|
254
258
|
[{ type: 'text', text: text }],
|
|
255
259
|
structured_content: structured
|
|
@@ -393,7 +397,7 @@ module Woods
|
|
|
393
397
|
|
|
394
398
|
sliced = offset.positive? ? original.drop(offset) : original
|
|
395
399
|
container[key] = limit ? truncate_section(sliced, limit) : sliced
|
|
396
|
-
if
|
|
400
|
+
if offset.positive? || container[key].size < original.size
|
|
397
401
|
container["#{key}_total"] = original.size
|
|
398
402
|
container["#{key}_truncated"] = true
|
|
399
403
|
end
|
|
@@ -403,12 +407,11 @@ module Woods
|
|
|
403
407
|
# Page a traversal result's `nodes` hash in place, in BFS order.
|
|
404
408
|
#
|
|
405
409
|
# Mirrors {#paginate_section}'s metadata keys (`nodes_total`,
|
|
406
|
-
# `nodes_truncated`, `nodes_offset`)
|
|
407
|
-
#
|
|
408
|
-
#
|
|
409
|
-
# exactly as it did before the bound existed (B-183).
|
|
410
|
+
# `nodes_truncated`, `nodes_offset`). A page that holds every admitted
|
|
411
|
+
# node adds no pagination keys (B-183). TraversalResponse separately
|
|
412
|
+
# annotates scope and total exactness before pagination.
|
|
410
413
|
#
|
|
411
|
-
# `nodes_total` marks *any*
|
|
414
|
+
# `nodes_total` marks *any* paged answer, not only one with more
|
|
412
415
|
# behind it. Keying it on `total > offset + limit` left the last page
|
|
413
416
|
# of a walk indistinguishable from a complete one: 21 nodes of 121,
|
|
414
417
|
# with nothing saying 100 were skipped. `nodes_truncated` still means
|
|
@@ -656,9 +659,10 @@ module Woods
|
|
|
656
659
|
result[:message] =
|
|
657
660
|
"Identifier '#{identifier}' not found in the index. Use 'search' to find valid identifiers."
|
|
658
661
|
end
|
|
662
|
+
TraversalResponse.annotate(result)
|
|
659
663
|
paginate_nodes.call(result, limit || DEFAULT_TRAVERSAL_LIMIT, offset || 0)
|
|
660
664
|
TraversalEvidencePage.apply(result)
|
|
661
|
-
respond.call(renderer.render(render_key, result))
|
|
665
|
+
respond.call(renderer.render(render_key, result), data: result)
|
|
662
666
|
end
|
|
663
667
|
end
|
|
664
668
|
|
|
@@ -698,32 +702,20 @@ module Woods
|
|
|
698
702
|
enum: ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS + %w[all],
|
|
699
703
|
description: 'Which analysis to return. Default: all'
|
|
700
704
|
},
|
|
701
|
-
limit: { type: 'integer', description:
|
|
705
|
+
limit: { type: 'integer', description: "Limit results per section (default: #{DEFAULT_GRAPH_ANALYSIS_LIMIT})" },
|
|
702
706
|
offset: { type: 'integer', description: 'Skip this many results per section (default: 0)' }
|
|
703
707
|
}
|
|
704
708
|
}
|
|
705
709
|
) do |server_context:, analysis: nil, limit: nil, offset: nil|
|
|
706
|
-
limit = coerce_int.call(limit)
|
|
710
|
+
limit = coerce_int.call(limit) || DEFAULT_GRAPH_ANALYSIS_LIMIT
|
|
707
711
|
offset = coerce_int.call(offset)
|
|
708
712
|
data = reader.graph_analysis
|
|
709
713
|
section = analysis || 'all'
|
|
710
714
|
effective_offset = offset || 0
|
|
711
715
|
|
|
712
|
-
result =
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS.each do |key|
|
|
716
|
-
paginate.call(truncated, key, limit, effective_offset)
|
|
717
|
-
end
|
|
718
|
-
truncated
|
|
719
|
-
else
|
|
720
|
-
data
|
|
721
|
-
end
|
|
722
|
-
else
|
|
723
|
-
single = { section => data[section] || [], 'stats' => data['stats'] }
|
|
724
|
-
paginate.call(single, section, limit, effective_offset) if limit || effective_offset.positive?
|
|
725
|
-
single
|
|
726
|
-
end
|
|
716
|
+
result = section == 'all' ? data.dup : { section => data[section] || [], 'stats' => data['stats'] }
|
|
717
|
+
sections = section == 'all' ? ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS : [section]
|
|
718
|
+
sections.each { |key| paginate.call(result, key, limit, effective_offset) }
|
|
727
719
|
|
|
728
720
|
respond.call(renderer.render(:graph_analysis, result))
|
|
729
721
|
end
|
|
@@ -1072,6 +1064,8 @@ module Woods
|
|
|
1072
1064
|
'Semantic search is disabled — no embedding provider is configured. ' \
|
|
1073
1065
|
'To enable: set OPENAI_API_KEY, or run Ollama locally ' \
|
|
1074
1066
|
'(brew install ollama && ollama serve && ollama pull nomic-embed-text). ' \
|
|
1067
|
+
'For ranked discovery with no embeddings, set WOODS_RETRIEVAL_MODE=lexical in the MCP process ' \
|
|
1068
|
+
'environment and restart the server. See docs/RETRIEVAL_GUIDE.md#embedding-free-lexical-retrieval. ' \
|
|
1075
1069
|
'Use the `search` tool for pattern-based matching in the meantime.',
|
|
1076
1070
|
code: :not_configured,
|
|
1077
1071
|
config_key: 'embedding_provider',
|