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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CONTRIBUTING.md +29 -17
  4. data/README.md +92 -177
  5. data/docs/AGENT_GUIDE.md +26 -4
  6. data/docs/AGENT_SETUP.md +18 -8
  7. data/docs/BACKEND_MATRIX.md +5 -0
  8. data/docs/CONFIGURATION_REFERENCE.md +74 -8
  9. data/docs/CONSOLE_MCP_SETUP.md +45 -2
  10. data/docs/DOCKER_SETUP.md +1 -1
  11. data/docs/EXTRACTOR_REFERENCE.md +9 -1
  12. data/docs/INCREMENTAL_EXTRACTION.md +30 -6
  13. data/docs/MCP_SERVERS.md +57 -2
  14. data/docs/MCP_TOOL_COOKBOOK.md +4 -4
  15. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  16. data/docs/PUBLISHED_INDEX.md +17 -0
  17. data/docs/RETRIEVAL_GUIDE.md +24 -5
  18. data/docs/TROUBLESHOOTING.md +12 -13
  19. data/docs/UPGRADING_TO_2.md +6 -2
  20. data/docs/WATCH_DAEMON.md +18 -8
  21. data/exe/woods-mcp-start +14 -9
  22. data/lib/generators/woods/pgvector_generator.rb +8 -2
  23. data/lib/woods/agent_configuration/applier.rb +5 -3
  24. data/lib/woods/agent_configuration/cli.rb +2 -2
  25. data/lib/woods/agent_configuration/layout.rb +13 -0
  26. data/lib/woods/console/credential_scanner.rb +4 -3
  27. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  28. data/lib/woods/console/embedded_executor.rb +31 -9
  29. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  30. data/lib/woods/console/sql_table_scanner.rb +47 -7
  31. data/lib/woods/console/sql_validator.rb +49 -9
  32. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  33. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  34. data/lib/woods/embedding/indexer.rb +24 -14
  35. data/lib/woods/extractor.rb +45 -12
  36. data/lib/woods/extractors/declared_parent.rb +55 -0
  37. data/lib/woods/extractors/graphql_extractor.rb +2 -11
  38. data/lib/woods/extractors/lib_extractor.rb +10 -8
  39. data/lib/woods/extractors/mailer_extractor.rb +6 -10
  40. data/lib/woods/extractors/model_extractor.rb +1 -15
  41. data/lib/woods/extractors/poro_extractor.rb +10 -8
  42. data/lib/woods/extractors/shared_utility_methods.rb +22 -5
  43. data/lib/woods/mcp/bearer_auth.rb +2 -1
  44. data/lib/woods/mcp/bootstrapper.rb +17 -4
  45. data/lib/woods/mcp/config_resolver.rb +2 -1
  46. data/lib/woods/mcp/index_reader.rb +11 -2
  47. data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
  48. data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
  49. data/lib/woods/mcp/server.rb +22 -28
  50. data/lib/woods/mcp/tool_contract.rb +1 -1
  51. data/lib/woods/mcp/tool_response_renderer.rb +16 -0
  52. data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
  53. data/lib/woods/mcp/traversal_response.rb +22 -0
  54. data/lib/woods/path_dispatcher.rb +6 -5
  55. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  56. data/lib/woods/published_index.rb +2 -2
  57. data/lib/woods/rake_helpers.rb +2 -12
  58. data/lib/woods/retrieval/lexical_assembler.rb +14 -3
  59. data/lib/woods/retrieval/lexical_index.rb +2 -1
  60. data/lib/woods/session_tracer/file_store.rb +6 -1
  61. data/lib/woods/source_inputs/consumer_errors.rb +4 -0
  62. data/lib/woods/storage/pgvector.rb +6 -2
  63. data/lib/woods/temporal/json_snapshot_store.rb +35 -7
  64. data/lib/woods/version.rb +1 -1
  65. data/lib/woods/watch/daemon.rb +18 -4
  66. data/plugin/.claude-plugin/plugin.json +1 -1
  67. data/plugin/hooks/woods-input-rules.sh +4 -4
  68. data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
  69. data/plugin/skills/woods-diagnose/SKILL.md +64 -33
  70. data/plugin/skills/woods-investigate/SKILL.md +51 -12
  71. data/plugin/skills/woods-mcp-config/SKILL.md +11 -11
  72. data/plugin/skills/woods-setup/SKILL.md +14 -11
  73. 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, _class_name, _unit_type, runtime_class)
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, class_name)
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 = parent || 'none'
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 class_name [String] The inferred constant name
200
+ # @param parent_class [String, nil] Selected explicit parent
199
201
  # @return [Hash] Lib unit metadata
200
- def extract_metadata(source, _class_name)
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: extract_parent_class(source),
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.to_a
120
- default_from = begin
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.to_a
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.filter.to_s
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, class_name)
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
- parent = extract_parent_class(source)
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 class_name [String] The class name
205
+ # @param parent_class [String, nil] Selected explicit parent
204
206
  # @return [Hash] PORO metadata
205
- def extract_metadata(source, _class_name)
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: extract_parent_class(source),
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
- # @return [String, nil] Parent class name or nil
129
- def extract_parent_class(source)
130
- match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
131
- match ? match[1] : nil
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
- private :callback_filter, :stable_filter
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
- presented = header.start_with?('Bearer ') ? header.sub(/\ABearer /, '') : nil
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: #{dir}"
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: No manifest.json found in: #{dir}"
40
- warn 'Run `bundle exec rake woods:extract` in your Rails app first.'
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 has no manifest.json
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
- raise ArgumentError, "No manifest.json found in: #{index_dir}" unless manifest_present?
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. Excludes orphans
156
- that have no incoming or outgoing edges.
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, including per-chunk rows for units long enough to
159
- be chunked. Always ≥ units_indexed.
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[total_key]
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[total_key]}#{position} (truncated)_"
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
- lines << '' << truncation_note(data, nodes.size) if fetch_key(data, :nodes_total)
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 << ' (excludes orphans with no incoming/outgoing edges).'
130
- lines << ' searchable_entries (codebase_retrieve): retriever-store entries including'
131
- lines << ' per-chunk rows. Always >= units_indexed.'
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[total_key]
164
+ if fetch_key(data, total_key)
165
165
  position = offset.positive? ? " from offset #{offset}" : ''
166
- lines << " (showing #{items.size} of #{data[total_key]}#{position}; truncated)"
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 fetch_key(data, :nodes_total)
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)"
@@ -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 original.size > offset + (limit || original.size)
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`) so both renderers print the one
407
- # truncation line they already had for `graph_analysis`. A page that
408
- # holds every node adds no keys at all, so a small result renders
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* partial answer, not only one with more
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: 'Limit results per section (default: 20)' },
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 = if section == 'all'
713
- if limit || effective_offset.positive?
714
- truncated = data.dup
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',