woods 2.0.0.beta2 → 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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module MCP
5
+ # Short, client-neutral instructions for initialize and modern discovery.
6
+ # Only tool registration shapes the text: readiness and retrieval can
7
+ # change after startup, so clients must consult live status before use.
8
+ module InitializationGuidance
9
+ # Contract budget for all supported wiring combinations, tested at build.
10
+ MAX_BYTES = 2048
11
+
12
+ WORKFLOW = <<~TEXT
13
+ Start with woods_status: check index readiness, generation freshness and relevant type counts before relying on results.
14
+ For exact names, discover identifiers with search (prefer literal exact_prefix/exact_suffix), then inspect with lookup. For conceptual questions, use codebase_retrieve only when woods_status reports retrieval enabled. Otherwise use search and lookup.
15
+ Follow dependencies or dependents at depth 1 or 2; narrow types and via before paging. Respect partial results and limits: a missing match is not proof of absence, and a partial traversal does not establish every dependent or leaf.
16
+ Verify important conclusions against current source and tests. Recorded relationships and inferred downstream impact do not prove runtime execution or test coverage.
17
+ Registration does not authorize extraction, configuration changes, or live Console access. Use only the tools registered here and operate within the user's authorized scope.
18
+ TEXT
19
+
20
+ # @param tool_names [Array<String>] actual names after conditional registration
21
+ # @return [String] stable instructions, without index data or provider calls
22
+ def self.for(tool_names)
23
+ "#{WORKFLOW}Registered tools: #{tool_names.sort.join(', ')}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -62,15 +62,12 @@ module Woods
62
62
  # @param enabled [#call, nil] Optional request-time predicate. When it
63
63
  # returns falsy the request passes through unguarded. Nil (the
64
64
  # default) means always guard.
65
- def initialize(app, allowed_origins: nil, path: nil, enabled: nil)
66
- @app = app
67
- @path = path
68
- @enabled = enabled
69
- if allowed_origins.respond_to?(:call)
70
- @allowed_source = allowed_origins
71
- else
72
- build_allow_list(allowed_origins)
73
- end
65
+ # Rails 6.0 forwards middleware options as a positional hash on Ruby 3.
66
+ # Delegate to explicit keywords to preserve required/unknown option checks.
67
+ def initialize(app, options = {}, **keywords)
68
+ raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
69
+
70
+ initialize_options(app, **options, **keywords)
74
71
  end
75
72
 
76
73
  # Rack entry point. Out-of-scope requests (non-matching `path:` prefix
@@ -97,6 +94,17 @@ module Woods
97
94
 
98
95
  private
99
96
 
97
+ def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil)
98
+ @app = app
99
+ @path = path
100
+ @enabled = enabled
101
+ if allowed_origins.respond_to?(:call)
102
+ @allowed_source = allowed_origins
103
+ else
104
+ build_allow_list(allowed_origins)
105
+ end
106
+ end
107
+
100
108
  # @param env [Hash] Rack environment
101
109
  # @return [Boolean] whether this request falls under the guard
102
110
  def guard?(env)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'index_reader'
4
+ require_relative '../retriever'
5
+ require_relative '../storage/metadata_store'
6
+ require_relative '../storage_identity'
7
+
8
+ module Woods
9
+ module MCP
10
+ # Loads only authoritative published extraction units. The reader pin spans
11
+ # snapshot construction AND the query, so a publication cannot mix units.
12
+ # No provider, vector adapter, host source read, or context cache is involved.
13
+ class PublishedLexicalRetriever
14
+ attr_reader :reader, :snapshot, :default_budget
15
+
16
+ def initialize(index_dir:, reader: nil, default_budget: 8000)
17
+ @default_budget = default_budget
18
+ @index_dir = Pathname.new(index_dir)
19
+ @reader = reader || IndexReader.new(index_dir)
20
+ @snapshot_mutex = Mutex.new
21
+ @snapshot = nil
22
+ end
23
+
24
+ def mode = :lexical
25
+ def vector_store = nil
26
+ def graph_store = nil
27
+ def metadata_store = @snapshot&.last&.metadata_store
28
+
29
+ # Server uses one reader for status, tool pinning and lexical retrieval.
30
+ # Called only during server construction, before request threads start.
31
+ def bind_reader(reader)
32
+ @reader = reader
33
+ @snapshot = nil
34
+ self
35
+ end
36
+
37
+ def warmup!
38
+ validate_publication!
39
+ reader.with_pinned_generation { snapshot_for_reader }
40
+ self
41
+ end
42
+
43
+ def retrieve(query, budget: @default_budget, **options)
44
+ validate_publication!
45
+ reader.with_pinned_generation do
46
+ retriever = snapshot_for_reader
47
+ options[:evidence_generation] = reader.loaded_generation if options[:evidence] && options[:evidence] != 'full'
48
+ result = retriever.retrieve(query, budget: budget, **options)
49
+ result.sources.each { |source| source[:generation] = reader.loaded_generation }
50
+ result
51
+ end
52
+ rescue Retriever::StoreError
53
+ raise
54
+ rescue StandardError => e
55
+ raise if e.is_a?(Woods::InvalidQueryError) || e.is_a?(ArgumentError)
56
+
57
+ raise Retriever::StoreError, "lexical index read failed: #{e.class}: #{e.message}"
58
+ end
59
+
60
+ # Explicit invalidation is used by reload after its candidate has been
61
+ # validated. Ordinary generation changes rebuild lazily under the pin.
62
+ def invalidate_snapshot!
63
+ @snapshot_mutex.synchronize { @snapshot = nil }
64
+ end
65
+
66
+ def install_snapshot!(snapshot)
67
+ @snapshot_mutex.synchronize { @snapshot = snapshot }
68
+ end
69
+
70
+ private
71
+
72
+ def validate_publication!
73
+ path = @index_dir.join(Generation::FILENAME)
74
+ unless path.exist?
75
+ raise IOError, 'published generation marker disappeared' if reader.loaded_generation
76
+
77
+ return
78
+ end
79
+ data = JSON.parse(AtomicFile.read(path))
80
+ unless data.is_a?(Hash) && data['number'].is_a?(Integer) && data['number'].positive?
81
+ raise IOError, 'invalid published generation marker'
82
+ end
83
+ return if data['payload'].nil?
84
+
85
+ payload = data['payload']
86
+ unless payload.is_a?(String) && !payload.empty? && !Pathname.new(payload).absolute?
87
+ raise IOError, 'invalid generation payload pointer'
88
+ end
89
+
90
+ directory = @index_dir.join(payload)
91
+ return if directory.directory? && directory.realpath.to_s.start_with?("#{@index_dir.realpath}/")
92
+
93
+ raise IOError, 'missing or escaping generation payload'
94
+ end
95
+
96
+ def snapshot_for_reader
97
+ @snapshot_mutex.synchronize do
98
+ key = reader.generation_identity
99
+ # Flat legacy indexes have no immutable generation identity. Rebuild
100
+ # each time instead of serving changed files from a permanent cache.
101
+ return @snapshot.last if key.first && @snapshot&.first == key
102
+
103
+ metadata = Storage::MetadataStore::InMemory.new
104
+ reader.each_unit do |unit|
105
+ metadata.store(StorageIdentity.key(unit.fetch('identifier'), unit.fetch('type')), unit)
106
+ end
107
+ candidate = Retriever.new(vector_store: nil, metadata_store: metadata, graph_store: nil,
108
+ embedding_provider: nil, mode: :lexical)
109
+ @snapshot = [key.freeze, candidate].freeze
110
+ candidate
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -60,7 +60,7 @@ module Woods
60
60
  lines = []
61
61
  lines << "## Search: \"#{query}\""
62
62
  lines << ''
63
- lines << "#{count} result#{'s' unless count == 1} found."
63
+ lines << "#{count} result#{'s' unless count == 1} returned."
64
64
  lines << ''
65
65
 
66
66
  results.each do |r|
@@ -80,6 +80,8 @@ module Woods
80
80
  lines << "**note:** #{note}" if note
81
81
  end
82
82
 
83
+ lines.concat(search_completeness_lines(data))
84
+
83
85
  lines.join("\n").rstrip
84
86
  end
85
87
 
@@ -150,11 +152,11 @@ module Woods
150
152
  - **units_indexed** (manifest.json, `structure` tool) — total
151
153
  ExtractedUnits written by the extractor. Canonical count.
152
154
  - **graph_nodes** (`pagerank`, `dependencies`, `dependents`) —
153
- units present in the dependency graph. Excludes orphans
154
- that have no incoming or outgoing edges.
155
+ units present in the dependency graph, including isolated units
156
+ with no incoming or outgoing edges.
155
157
  - **searchable_entries** (`codebase_retrieve`) — retriever-store
156
- entries, including per-chunk rows for units long enough to
157
- 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.
158
160
  GLOSSARY
159
161
  end
160
162
 
@@ -175,7 +177,7 @@ module Woods
175
177
 
176
178
  GRAPH_ANALYSIS_SECTIONS.each do |section|
177
179
  items = fetch_key(data, section)
178
- next unless items.is_a?(Array) && items.any?
180
+ next unless items.is_a?(Array) && (items.any? || fetch_key(data, "#{section}_total"))
179
181
 
180
182
  lines << "### #{section.tr('_', ' ').capitalize}"
181
183
  lines << ''
@@ -192,11 +194,11 @@ module Woods
192
194
  end
193
195
 
194
196
  total_key = "#{section}_total"
195
- if data[total_key]
197
+ if fetch_key(data, total_key)
196
198
  lines << ''
197
199
  offset = fetch_key(data, "#{section}_offset", 0)
198
200
  position = offset.positive? ? " from offset #{offset}" : ''
199
- lines << "_Showing #{items.size} of #{data[total_key]}#{position} (truncated)_"
201
+ lines << "_Showing #{items.size} of #{fetch_key(data, total_key)}#{position} (truncated)_"
200
202
  end
201
203
  lines << ''
202
204
  end
@@ -415,6 +417,8 @@ module Woods
415
417
  return lines.join("\n").rstrip
416
418
  end
417
419
 
420
+ lines.concat(traversal_coverage_lines(data))
421
+
418
422
  nodes.each do |id, info|
419
423
  depth = fetch_key(info, :depth) || 0
420
424
  deps = fetch_key(info, :deps, [])
@@ -431,7 +435,16 @@ module Woods
431
435
  deps.each { |d| lines << "#{indent} - #{d}" }
432
436
  end
433
437
 
434
- lines << '' << truncation_note(data, nodes.size) if fetch_key(data, :nodes_total)
438
+ lines.concat(TraversalEvidenceText.lines(fetch_key(data, :explanation)))
439
+
440
+ if fetch_key(data, :partial)
441
+ lines << "Partial traversal (#{fetch_key(data, :partial_reason)}); narrow depth/types/via or increase max_nodes/max_edges."
442
+ end
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
435
448
 
436
449
  lines.join("\n").rstrip
437
450
  end
@@ -63,7 +63,7 @@ module Woods
63
63
  results = fetch_key(data, :results, [])
64
64
 
65
65
  lines = []
66
- lines << "Search: \"#{query}\" (#{count} results)"
66
+ lines << "Search: \"#{query}\" (#{count} result#{'s' unless count == 1} returned)"
67
67
  lines << DIVIDER
68
68
 
69
69
  results.each do |r|
@@ -79,6 +79,7 @@ module Woods
79
79
  partial = fetch_key(data, :partial, false)
80
80
  lines << 'partial: true' if partial
81
81
  lines << "note: #{note}" if note
82
+ lines.concat(search_completeness_lines(data))
82
83
 
83
84
  lines.join("\n").rstrip
84
85
  end
@@ -125,9 +126,9 @@ module Woods
125
126
  lines << 'Denominators:'
126
127
  lines << ' units_indexed (manifest, structure): total ExtractedUnits written.'
127
128
  lines << ' graph_nodes (pagerank, dependencies, dependents): units in the graph'
128
- lines << ' (excludes orphans with no incoming/outgoing edges).'
129
- lines << ' searchable_entries (codebase_retrieve): retriever-store entries including'
130
- 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.'
131
132
 
132
133
  lines.join("\n").rstrip
133
134
  end
@@ -145,7 +146,7 @@ module Woods
145
146
 
146
147
  GRAPH_ANALYSIS_SECTIONS.each do |section|
147
148
  items = fetch_key(data, section)
148
- next unless items.is_a?(Array) && items.any?
149
+ next unless items.is_a?(Array) && (items.any? || fetch_key(data, "#{section}_total"))
149
150
 
150
151
  lines << "#{section.tr('_', ' ').upcase}:"
151
152
  items.each do |item|
@@ -160,9 +161,9 @@ module Woods
160
161
 
161
162
  total_key = "#{section}_total"
162
163
  offset = fetch_key(data, "#{section}_offset", 0)
163
- if data[total_key]
164
+ if fetch_key(data, total_key)
164
165
  position = offset.positive? ? " from offset #{offset}" : ''
165
- lines << " (showing #{items.size} of #{data[total_key]}#{position}; truncated)"
166
+ lines << " (showing #{items.size} of #{fetch_key(data, total_key)}#{position}; truncated)"
166
167
  end
167
168
  lines << ''
168
169
  end
@@ -278,6 +279,8 @@ module Woods
278
279
  return lines.join("\n").rstrip
279
280
  end
280
281
 
282
+ lines.concat(traversal_coverage_lines(data))
283
+
281
284
  nodes.each do |id, info|
282
285
  depth = fetch_key(info, :depth) || 0
283
286
  deps = fetch_key(info, :deps, [])
@@ -288,7 +291,14 @@ module Woods
288
291
  deps.each { |d| lines << "#{indent} -> #{d}" }
289
292
  end
290
293
 
291
- if fetch_key(data, :nodes_total)
294
+ lines.concat(TraversalEvidenceText.lines(fetch_key(data, :explanation)))
295
+
296
+ if fetch_key(data, :partial)
297
+ lines << "Partial traversal (#{fetch_key(data, :partial_reason)}); narrow depth/types/via or increase max_nodes/max_edges."
298
+ end
299
+ if (note = traversal_lower_bound_note(data, nodes.size))
300
+ lines << note
301
+ elsif fetch_key(data, :nodes_total)
292
302
  offset = fetch_key(data, :nodes_offset, 0)
293
303
  position = offset.positive? ? " from offset #{offset}" : ''
294
304
  lines << " (showing #{nodes.size} of #{fetch_key(data, :nodes_total)}#{position}; truncated)"
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Woods
6
+ module MCP
7
+ # Accumulates a bounded search prefix and evidence about its completeness.
8
+ # A full page is not evidence of truncation: only an additional distinct
9
+ # typed match establishes that more results exist. Budget/timeout cutoffs
10
+ # leave both the total and the existence of another match unknown.
11
+ class SearchResults
12
+ NARROWING_HINT = 'Narrow types, literal exact_prefix/exact_suffix filters, or the requested deep fields.'
13
+
14
+ def self.unavailable
15
+ { status: 'unknown', reason: 'unreadable_or_corrupt_source', has_more: nil,
16
+ total_matches: nil, matched_lower_bound: nil }
17
+ end
18
+
19
+ def initialize(limit:)
20
+ @limit = limit
21
+ @matches = []
22
+ @seen = Set.new
23
+ @reason = nil
24
+ end
25
+
26
+ # Admit at most one lookahead match beyond the returned page.
27
+ # @return [self]
28
+ def add(identifier:, type:, match_field:)
29
+ return self if result_limit_reached?
30
+ return self unless @seen.add?([type, identifier])
31
+
32
+ @matches << { identifier: identifier, type: type, match_field: match_field }
33
+ @reason = 'result_limit' if @matches.size > @limit
34
+ self
35
+ end
36
+
37
+ def result_limit_reached?
38
+ @reason == 'result_limit'
39
+ end
40
+
41
+ # Stop because the remaining search domain could not be examined.
42
+ def stop(reason)
43
+ @reason = reason
44
+ end
45
+
46
+ # The caller must establish exhaustion rather than infer it from page size.
47
+ def finish
48
+ @reason ||= 'exhausted'
49
+ self
50
+ end
51
+
52
+ # @param note [String, nil] existing advisory/timeout guidance
53
+ # @return [Hash] returned rows plus additive completeness metadata
54
+ def response(note: nil)
55
+ raise ArgumentError, 'Search has not finished' unless @reason
56
+
57
+ complete = @reason == 'exhausted'
58
+ result = {
59
+ results: @matches.first(@limit),
60
+ completeness: {
61
+ status: complete ? 'complete' : 'partial', reason: @reason,
62
+ has_more: complete ? false : (true if result_limit_reached?),
63
+ total_matches: complete ? @matches.size : nil,
64
+ matched_lower_bound: @matches.size
65
+ }
66
+ }
67
+ result[:partial] = true unless complete
68
+ result[:hint] = NARROWING_HINT unless complete
69
+ result[:note] = note unless note.nil? || note.empty?
70
+ result
71
+ end
72
+ end
73
+ end
74
+ end