woods 2.0.0.beta2 → 2.0.0.beta3

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 (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -1
  3. data/CONTRIBUTING.md +173 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +199 -14
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +117 -1
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +55 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/builder.rb +21 -5
  56. data/lib/woods/cache/cache_middleware.rb +28 -7
  57. data/lib/woods/cache/cache_store.rb +4 -5
  58. data/lib/woods/change_set.rb +5 -4
  59. data/lib/woods/console/credential_index.rb +20 -2
  60. data/lib/woods/console/credential_scanner.rb +14 -14
  61. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  62. data/lib/woods/console/embedded_executor.rb +1 -1
  63. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  64. data/lib/woods/console/rack_middleware.rb +22 -13
  65. data/lib/woods/console/server.rb +18 -16
  66. data/lib/woods/dependency_graph.rb +65 -13
  67. data/lib/woods/embedding/corpus.rb +94 -0
  68. data/lib/woods/embedding/indexer.rb +90 -46
  69. data/lib/woods/embedding/openai.rb +17 -6
  70. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  71. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  72. data/lib/woods/export/typed_reader.rb +56 -0
  73. data/lib/woods/extractor.rb +232 -137
  74. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  75. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  76. data/lib/woods/extractors/caching_extractor.rb +3 -1
  77. data/lib/woods/extractors/concern_extractor.rb +64 -6
  78. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  79. data/lib/woods/extractors/controller_extractor.rb +13 -4
  80. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  81. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  82. data/lib/woods/extractors/engine_extractor.rb +3 -1
  83. data/lib/woods/extractors/event_extractor.rb +4 -2
  84. data/lib/woods/extractors/factory_extractor.rb +3 -1
  85. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  86. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  87. data/lib/woods/extractors/job_extractor.rb +6 -19
  88. data/lib/woods/extractors/lib_extractor.rb +3 -1
  89. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  90. data/lib/woods/extractors/manager_extractor.rb +3 -1
  91. data/lib/woods/extractors/method_parameters.rb +53 -0
  92. data/lib/woods/extractors/middleware_argument.rb +65 -0
  93. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  94. data/lib/woods/extractors/migration_extractor.rb +3 -1
  95. data/lib/woods/extractors/model_extractor.rb +39 -33
  96. data/lib/woods/extractors/package_extractor.rb +24 -4
  97. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  98. data/lib/woods/extractors/policy_extractor.rb +3 -1
  99. data/lib/woods/extractors/poro_extractor.rb +3 -1
  100. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  101. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  102. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  103. data/lib/woods/extractors/route_extractor.rb +3 -1
  104. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  106. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  107. data/lib/woods/extractors/service_extractor.rb +3 -1
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  109. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  110. data/lib/woods/extractors/source_nesting.rb +1 -1
  111. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  112. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  113. data/lib/woods/extractors/validator_extractor.rb +3 -1
  114. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  116. data/lib/woods/gem_mapper.rb +2 -0
  117. data/lib/woods/git_history.rb +116 -0
  118. data/lib/woods/graph_analyzer.rb +35 -6
  119. data/lib/woods/hooks/context_cli.rb +54 -0
  120. data/lib/woods/hooks/context_event.rb +88 -0
  121. data/lib/woods/hooks/context_hint.rb +73 -0
  122. data/lib/woods/hooks/context_impact.rb +77 -0
  123. data/lib/woods/hooks/context_output.rb +47 -0
  124. data/lib/woods/hooks/context_state.rb +102 -0
  125. data/lib/woods/hooks/refresh.rb +79 -0
  126. data/lib/woods/hooks/rule_projection.rb +78 -0
  127. data/lib/woods/input_rules.rb +19 -0
  128. data/lib/woods/mcp/bearer_auth.rb +20 -12
  129. data/lib/woods/mcp/bootstrapper.rb +62 -0
  130. data/lib/woods/mcp/index_reader.rb +323 -160
  131. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  132. data/lib/woods/mcp/origin_guard.rb +17 -9
  133. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  134. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  135. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  136. data/lib/woods/mcp/search_results.rb +74 -0
  137. data/lib/woods/mcp/server.rb +158 -37
  138. data/lib/woods/mcp/tool_contract.rb +2 -0
  139. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  140. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  141. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  142. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  143. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  144. data/lib/woods/notion/exporter.rb +56 -17
  145. data/lib/woods/obsidian/destination_plan.rb +98 -0
  146. data/lib/woods/obsidian/name_mapper.rb +19 -3
  147. data/lib/woods/obsidian/note_builder.rb +19 -10
  148. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  149. data/lib/woods/operator/pipeline_guard.rb +18 -13
  150. data/lib/woods/path_dispatcher.rb +7 -1
  151. data/lib/woods/payload_store.rb +27 -26
  152. data/lib/woods/railtie.rb +3 -3
  153. data/lib/woods/railtie_support.rb +12 -12
  154. data/lib/woods/rake_helpers.rb +392 -0
  155. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  156. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  157. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  158. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  159. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  160. data/lib/woods/resilience/index_validator.rb +112 -23
  161. data/lib/woods/retrieval/context_assembler.rb +50 -15
  162. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  163. data/lib/woods/retrieval/lexical_index.rb +119 -0
  164. data/lib/woods/retrieval/ranker.rb +4 -2
  165. data/lib/woods/retrieval/scope.rb +108 -0
  166. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  167. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  168. data/lib/woods/retrieval/search_executor.rb +86 -27
  169. data/lib/woods/retrieval/source_evidence.rb +200 -0
  170. data/lib/woods/retriever.rb +98 -22
  171. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  172. data/lib/woods/session_tracer/middleware.rb +10 -12
  173. data/lib/woods/session_tracer/redis_store.rb +22 -6
  174. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  175. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  176. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  177. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  178. data/lib/woods/source_inputs/handoff.rb +102 -0
  179. data/lib/woods/source_inputs/launcher.rb +157 -0
  180. data/lib/woods/source_inputs/manifest.rb +124 -0
  181. data/lib/woods/source_inputs/private_key.rb +55 -0
  182. data/lib/woods/source_inputs/scanner.rb +171 -0
  183. data/lib/woods/source_inputs/scopes.rb +71 -0
  184. data/lib/woods/source_inputs/session.rb +214 -0
  185. data/lib/woods/source_inputs/status.rb +84 -0
  186. data/lib/woods/source_inputs/verifier.rb +107 -0
  187. data/lib/woods/storage/metadata_store.rb +25 -25
  188. data/lib/woods/storage/pgvector.rb +29 -8
  189. data/lib/woods/storage/qdrant.rb +17 -7
  190. data/lib/woods/storage/vector_store.rb +18 -6
  191. data/lib/woods/tasks.rb +3 -2
  192. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  193. data/lib/woods/unblocked/exporter.rb +59 -70
  194. data/lib/woods/version.rb +1 -1
  195. data/lib/woods/watch/boot_snapshot.rb +52 -0
  196. data/lib/woods/watch/daemon.rb +136 -28
  197. data/lib/woods/watch/listen_watcher.rb +4 -0
  198. data/lib/woods/watch/polling_watcher.rb +5 -1
  199. data/lib/woods/watch/status.rb +20 -15
  200. data/lib/woods/watch/tree_scan.rb +21 -13
  201. data/lib/woods/watch/watcher.rb +4 -1
  202. data/lib/woods.rb +50 -11
  203. data/plugin/.claude-plugin/plugin.json +1 -1
  204. data/plugin/hooks/adapters/normalize.jq +15 -0
  205. data/plugin/hooks/adapters/normalize.rb +63 -0
  206. data/plugin/hooks/hooks.json +20 -0
  207. data/plugin/hooks/woods-context.sh +50 -0
  208. data/plugin/hooks/woods-input-rules.sh +159 -0
  209. data/plugin/hooks/woods-opencode.mjs +65 -0
  210. data/plugin/hooks/woods-post-edit.sh +2 -225
  211. data/plugin/hooks/woods-refresh.sh +260 -0
  212. data/plugin/hooks/woods-session-start.sh +47 -55
  213. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  214. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  215. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  216. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  217. data/plugin/skills/woods-setup/SKILL.md +107 -6
  218. metadata +84 -5
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'set'
5
+ require_relative '../storage_identity'
6
+ require_relative '../storage/metadata_store'
7
+
8
+ module Woods
9
+ module Retrieval
10
+ # Resolves explicit scope from published metadata, never host filesystem
11
+ # paths. Ownership is exact; path prefixes match complete directory segments.
12
+ # Every eligible unit is selected before any search strategy applies a limit.
13
+ class Scope
14
+ class InvalidScopeError < ArgumentError; end
15
+
16
+ attr_reader :packages, :source_paths, :keys, :metadata_store
17
+
18
+ def self.requested?(packages: nil, source_paths: nil)
19
+ [packages, source_paths].any? { |list| !list.nil? && list != [] }
20
+ end
21
+
22
+ def initialize(metadata_store:, packages: nil, source_paths: nil, types: nil, exclude_types: nil)
23
+ @packages = normalize_list(packages, 'packages').freeze
24
+ @source_paths = normalize_list(source_paths, 'source_paths').map do |path|
25
+ normalize_path(path)
26
+ end.uniq.sort.freeze
27
+ records = metadata_store.all_identifiers.sort.to_h do |key|
28
+ record = metadata_store.find(key)
29
+ raise InvalidScopeError, "missing metadata for scoped unit #{key.inspect}" unless record.is_a?(Hash)
30
+
31
+ [key, JSON.parse(JSON.generate(record))]
32
+ end
33
+ validate_packages!(records.values)
34
+ @metadata_store = Storage::MetadataStore::InMemory.new
35
+ records.each do |key, record|
36
+ next unless eligible?(record, types, exclude_types)
37
+
38
+ @metadata_store.store(key, record)
39
+ end
40
+ @keys = @metadata_store.all_identifiers.sort.freeze
41
+ @key_set = @keys.to_set.freeze
42
+ end
43
+
44
+ def include?(key)
45
+ @key_set.include?(key.to_s.sub(/#chunk_\d+\z/, ''))
46
+ end
47
+
48
+ def summary
49
+ { packages: packages, source_paths: source_paths, eligible_units: keys.size }
50
+ end
51
+
52
+ private
53
+
54
+ def normalize_list(list, name)
55
+ return [] if list.nil?
56
+ unless list.is_a?(Array) && list.all? { |value| value.is_a?(String) && !value.empty? && !value.include?("\0") }
57
+ raise InvalidScopeError, "#{name} must be an array of nonempty strings"
58
+ end
59
+
60
+ list.uniq.sort.map { |value| value.dup.freeze }
61
+ end
62
+
63
+ def normalize_path(path)
64
+ if path.start_with?('/') || path.match?(/\A[A-Za-z]:/) || path.include?('\\')
65
+ raise InvalidScopeError, "source path must be application-relative: #{path.inspect}"
66
+ end
67
+
68
+ segments = []
69
+ path.split('/').each do |part|
70
+ next if part.empty? || part == '.'
71
+
72
+ if part == '..'
73
+ raise InvalidScopeError, "source path escapes application root: #{path.inspect}" if segments.empty?
74
+
75
+ segments.pop
76
+ else
77
+ segments << part
78
+ end
79
+ end
80
+ (segments.empty? ? '.' : segments.join('/')).freeze
81
+ end
82
+
83
+ def validate_packages!(records)
84
+ names = records.filter_map { |unit| unit['identifier'] if unit['type'] == 'package' }
85
+ names.concat(records.filter_map { |unit| unit.dig('metadata', 'package') })
86
+ unknown = packages - names
87
+ raise InvalidScopeError, "unknown package scope: #{unknown.join(', ')}" unless unknown.empty?
88
+ end
89
+
90
+ def eligible?(unit, types, excluded)
91
+ return false unless packages.empty? || packages.include?(unit.dig('metadata', 'package'))
92
+ return false unless source_paths.empty? || path_match?(unit['file_path'])
93
+ return Array(types).map(&:to_s).include?(unit['type']) if types && !types.empty?
94
+
95
+ !Array(excluded).map(&:to_s).include?(unit['type'])
96
+ end
97
+
98
+ def path_match?(path)
99
+ return false unless path.is_a?(String) && !path.empty? && !path.include?("\0")
100
+
101
+ normalized = normalize_path(path)
102
+ source_paths.any? { |prefix| prefix == '.' || normalized == prefix || normalized.start_with?("#{prefix}/") }
103
+ rescue InvalidScopeError
104
+ false
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Retrieval
5
+ # Expansion remains inside caller eligibility. Graphs retain their existing
6
+ # bare-identifier ambiguity: the executor resolves eligible typed variants.
7
+ class ScopedGraphStore
8
+ def initialize(store:, scope:)
9
+ @store = store
10
+ @identifiers = scope.keys.to_set { |key| StorageIdentity.identifier(key) }
11
+ end
12
+
13
+ def dependencies_of(identifier)
14
+ return [] unless @store && @identifiers.include?(identifier)
15
+
16
+ @store.dependencies_of(identifier).select { |target| @identifiers.include?(target) }
17
+ end
18
+
19
+ def dependents_of(identifier)
20
+ return [] unless @store && @identifiers.include?(identifier)
21
+
22
+ @store.dependents_of(identifier).select { |target| @identifiers.include?(target) }
23
+ end
24
+
25
+ def pagerank
26
+ return {} unless @store
27
+
28
+ @store.pagerank.slice(*@identifiers)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Retrieval
5
+ # Complete, bounded native searches across eligible raw vector IDs. Raw IDs
6
+ # preserve typed/chunk identity without requiring a new vector payload or an
7
+ # embedding migration. One outer executor embeds the query once.
8
+ class ScopedVectorStore
9
+ BATCH_SIZE = 100
10
+
11
+ def initialize(store:, scope:)
12
+ @store = store
13
+ @scope = scope
14
+ end
15
+
16
+ def search(query_vector, limit: 10, filters: {})
17
+ unless @store.respond_to?(:supports_id_filter?) && @store.supports_id_filter?
18
+ raise Scope::InvalidScopeError, 'vector adapter does not support complete ID-scoped search'
19
+ end
20
+
21
+ ids = @store.each_id.select { |id| eligible_id?(id, filters) }.uniq.sort
22
+ # Type eligibility comes from authoritative unit metadata, including for
23
+ # legacy vectors without a type payload. Other payload filters stay native.
24
+ filters = filters.reject { |key, _| key.to_s == 'type' }
25
+ best = []
26
+ ids.each_slice(BATCH_SIZE) do |batch|
27
+ # Fetch every bounded batch member before our raw-ID tie-break. A
28
+ # backend may order equal scores differently (or use SQL collation).
29
+ results = @store.search(query_vector, limit: batch.size, filters: filters, ids: batch)
30
+ results.each do |result|
31
+ next if batch.include?(result.id)
32
+
33
+ raise Scope::InvalidScopeError, 'vector adapter returned an ID outside the requested scope'
34
+ end
35
+ best = (best + results).sort_by { |result| [-result.score, result.id] }.first(limit)
36
+ end
37
+ best
38
+ rescue NotImplementedError
39
+ raise Scope::InvalidScopeError, 'vector adapter cannot enumerate IDs for complete scoped search'
40
+ end
41
+
42
+ private
43
+
44
+ def eligible_id?(id, filters)
45
+ return false unless @scope.include?(id)
46
+
47
+ types = filters[:type] || filters['type']
48
+ return true unless types
49
+
50
+ record = @scope.metadata_store.find(id.sub(/#chunk_\d+\z/, ''))
51
+ Array(types).map(&:to_s).include?(record['type'])
52
+ end
53
+ end
54
+ end
55
+ end
@@ -86,8 +86,11 @@ module Woods
86
86
  type_filter: type_filter
87
87
  )
88
88
 
89
+ candidates = expand_graph_candidates(candidates)
90
+ candidates = order_hybrid_candidates(candidates) if strategy == :hybrid
91
+
89
92
  ExecutionResult.new(
90
- candidates: bounded_candidates(expand_graph_candidates(candidates), limit, strategy),
93
+ candidates: bounded_candidates(candidates, limit, strategy),
91
94
  strategy: strategy,
92
95
  query: query
93
96
  )
@@ -155,7 +158,7 @@ module Woods
155
158
  when :keyword
156
159
  execute_keyword(classification: classification, limit: limit)
157
160
  when :graph
158
- execute_graph(classification: classification, limit: limit)
161
+ execute_graph(query: query, limit: limit)
159
162
  when :hybrid
160
163
  execute_hybrid(query, classification: classification, limit: limit, type_filter: type_filter)
161
164
  when :direct
@@ -318,11 +321,11 @@ module Woods
318
321
  # Graph strategy: find related units via dependency traversal.
319
322
  #
320
323
  # @return [Array<Candidate>]
321
- def execute_graph(classification:, limit:)
324
+ def execute_graph(query:, limit:)
322
325
  candidates = []
323
326
 
324
- # First, use keywords to find seed identifiers in the metadata store
325
- seeds = find_seed_identifiers(classification)
327
+ # Resolve original query subjects to canonical metadata identifiers
328
+ seeds = find_seed_identifiers(query)
326
329
  return [] if seeds.empty?
327
330
 
328
331
  seeds.each do |seed_id|
@@ -376,7 +379,7 @@ module Woods
376
379
 
377
380
  # Graph expansion on top vector results
378
381
  graph_candidates = []
379
- vector_candidates.first(3).each do |candidate|
382
+ order_hybrid_candidates(vector_candidates).first(3).each do |candidate|
380
383
  deps = @graph_store.dependencies_of(StorageIdentity.identifier(candidate.identifier.sub(/#chunk_\d+\z/, '')))
381
384
  deps.each do |dep|
382
385
  graph_candidates << Candidate.new(
@@ -385,10 +388,16 @@ module Woods
385
388
  end
386
389
  end
387
390
 
388
- # Sorted (not deduplicated) so the caller's later `.first(limit)`
389
- # keeps the strongest candidates across all sources instead of
390
- # exhausting the budget on whichever source is concatenated first.
391
- (vector_candidates + keyword_candidates + graph_candidates).sort_by { |c| -c.score }
391
+ # Keep every source occurrence for RRF. Ordering happens after typed
392
+ # graph identities expand, before the caller truncates unique units.
393
+ vector_candidates + keyword_candidates + graph_candidates
394
+ end
395
+
396
+ # Equal scores must not let insertion order or Ruby's sort implementation
397
+ # choose graph seeds, retained units, or a source's RRF rank. Identity is
398
+ # the tie-break; source makes cross-source occurrences deterministic too.
399
+ def order_hybrid_candidates(candidates)
400
+ candidates.sort_by { |candidate| [-candidate.score, candidate.identifier.to_s, candidate.source.to_s] }
392
401
  end
393
402
 
394
403
  # Direct strategy: look up specific identifiers from keywords.
@@ -449,27 +458,77 @@ module Woods
449
458
  { type: type_filter.map(&:to_s) }
450
459
  end
451
460
 
452
- # Find seed identifiers from classification keywords via metadata search.
453
- #
454
- # @param classification [QueryClassifier::Classification]
455
- # @return [Array<String>]
456
- def find_seed_identifiers(classification)
457
- seeds = []
458
-
459
- # Try direct lookups for capitalized keywords (likely class names)
460
- classification.keywords.each do |keyword|
461
- capitalized = keyword.split('_').map(&:capitalize).join
462
- result = @metadata_store.find(capitalized)
463
- seeds << capitalized if result
461
+ # Keep graph subjects separate from intent words and preserve namespace
462
+ # separators/case that the general-purpose classifier keywords discard.
463
+ GRAPH_INTENT_WORDS = %w[trace follow track call calls called depends used find locate search look].freeze
464
+ private_constant :GRAPH_INTENT_WORDS
465
+
466
+ def find_seed_identifiers(query)
467
+ records = graph_query_records(query)
468
+ exact = records.flat_map do |term, matches|
469
+ graph_identifier_matches(matches, term)
470
+ end
471
+ return exact.map { |record| record['id'] }.uniq.sort unless exact.empty?
472
+
473
+ # A qualified miss must not seed another namespace through source-text
474
+ # mentions. Unqualified prose can still fall back to subject metadata.
475
+ records.reject { |term, _| term.include?('::') }.values.flatten
476
+ .map { |record| record['id'] }.uniq.sort.first(3)
477
+ end
478
+
479
+ # Sentence-capitalized instructions are still prose. Keep an instruction
480
+ # token only when it is also an explicitly spelled, existing identifier.
481
+ def graph_query_records(query)
482
+ query.scan(/\w+(?:::\w+)*/).uniq.filter_map do |term|
483
+ instruction = QueryClassifier::STOP_WORDS.include?(term.downcase) ||
484
+ GRAPH_INTENT_WORDS.include?(term.downcase)
485
+ next if instruction && !term.match?(/[A-Z]/)
486
+
487
+ records = graph_subject_records(term)
488
+ records = records.select { |record| graph_record_identifier(record) == term } if instruction
489
+ [term, records]
490
+ end.to_h
491
+ end
492
+
493
+ # Prefer the spelling supplied by the caller before inferring snake_case
494
+ # variants. Metadata search recovers canonical and typed storage identities.
495
+ def graph_subject_records(term)
496
+ direct = @metadata_store.find(term)
497
+ return [direct.merge('id' => term)] if direct
498
+
499
+ records = @metadata_store.search(term)
500
+ exact = records.select { |record| graph_record_identifier(record) == term }
501
+ return exact unless exact.empty?
502
+
503
+ camelized = term.split('::').map { |part| part.split('_').map(&:capitalize).join }.join('::')
504
+ return records if camelized == term
505
+
506
+ inferred = @metadata_store.find(camelized)
507
+ return [inferred.merge('id' => camelized)] if inferred
508
+
509
+ (records + @metadata_store.search(camelized)).uniq { |record| record['id'] }
510
+ end
511
+
512
+ # Exact qualified identity wins over a bounded, deterministic set of
513
+ # unqualified namespace suffix matches; source-text mentions are not roots.
514
+ def graph_identifier_matches(records, term)
515
+ literal = records.select { |record| graph_record_identifier(record) == term }
516
+ return literal unless literal.empty?
517
+
518
+ exact = records.select do |record|
519
+ graph_record_identifier(record).delete('_').casecmp?(term.delete('_'))
464
520
  end
521
+ return exact unless exact.empty?
522
+ return [] if term.include?('::')
465
523
 
466
- # Fall back to search if no direct hits
467
- if seeds.empty? && classification.keywords.any?
468
- results = @metadata_store.search(classification.keywords.join(' '))
469
- seeds = results.first(3).map { |r| r['id'] }
524
+ suffix_matches = records.select do |record|
525
+ graph_record_identifier(record).split('::').last.delete('_').casecmp?(term.delete('_'))
470
526
  end
527
+ suffix_matches.sort_by { |record| record['id'] }.first(3)
528
+ end
471
529
 
472
- seeds
530
+ def graph_record_identifier(record)
531
+ record['identifier'] || StorageIdentity.identifier(record['id'])
473
532
  end
474
533
 
475
534
  # Deduplicate candidates, keeping the highest-scored entry per identifier.
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require_relative '../ast/parser'
6
+ require_relative 'lexical_index'
7
+
8
+ module Woods
9
+ module Retrieval
10
+ # Selects complete spans from published unit bytes, never from host files.
11
+ # Published coordinates deliberately do not claim physical source locations:
12
+ # extractors may synthesize headers or inline commented concern bodies.
13
+ class SourceEvidence
14
+ MODES = %w[full compact outline].freeze
15
+ Result = Struct.new(:text, :provenance, keyword_init: true)
16
+ Span = Struct.new(:kind, :name, :lexical_owner, :receiver, :start_byte, :end_byte, :start_line, :end_line,
17
+ keyword_init: true)
18
+ private_constant :Span
19
+
20
+ def self.validate_mode!(mode)
21
+ return mode if MODES.include?(mode)
22
+
23
+ raise ArgumentError, 'evidence must be full, compact, or outline'
24
+ end
25
+
26
+ def initialize(unit:, query: nil, generation: nil, parser: Ast::Parser.new)
27
+ @unit = unit
28
+ @source = field(:source_code).to_s
29
+ @query = terms(query)
30
+ @generation = generation
31
+ @parser = parser
32
+ end
33
+
34
+ # The caller's counter includes the same tokenizer/estimate used by its
35
+ # response budget. No selected definition or metadata value is cut in half.
36
+ def render(mode:, budget:, counter:)
37
+ self.class.validate_mode!(mode)
38
+ raise ArgumentError, 'budget must be a positive Integer' unless budget.is_a?(Integer) && budget.positive?
39
+ raise ArgumentError, 'compact evidence requires compact or outline mode' if mode == 'full'
40
+
41
+ spans = source_spans
42
+ selected = []
43
+ metadata = []
44
+ ordered = spans.sort_by { |span| [-relevance(span), span.start_byte] }
45
+ relevant, other = ordered.partition { |span| @query.empty? || relevance(span).positive? }
46
+ relevant.each do |span|
47
+ trial = selected + [span]
48
+ selected = trial if counter.call(format_evidence(mode, trial, metadata, spans.size)) <= budget
49
+ end
50
+ runtime_records.each do |record|
51
+ trial = metadata + [record]
52
+ metadata = trial if counter.call(format_evidence(mode, selected, trial, spans.size)) <= budget
53
+ end
54
+ other.each do |span|
55
+ trial = selected + [span]
56
+ selected = trial if counter.call(format_evidence(mode, trial, metadata, spans.size)) <= budget
57
+ end
58
+ text = format_evidence(mode, selected, metadata, spans.size)
59
+ return Result.new(text: '', provenance: provenance(mode, [], [], spans)) if counter.call(text) > budget
60
+
61
+ Result.new(text: text, provenance: provenance(mode, selected, metadata, spans))
62
+ end
63
+
64
+ private
65
+
66
+ def field(key)
67
+ @unit[key] || @unit[key.to_s]
68
+ end
69
+
70
+ def source_spans
71
+ return [] if @source.empty?
72
+
73
+ spans = []
74
+ walk(@parser.parse(@source), nil, spans)
75
+ return whole_source_span('whole_source_fallback') unless spans.all? { |span| complete_definition?(span) }
76
+
77
+ spans.concat(concern_spans)
78
+ return spans unless spans.empty?
79
+
80
+ whole_source_span('published_source')
81
+ rescue Woods::ExtractionError
82
+ # Non-Ruby or legacy synthesized source remains available as one whole
83
+ # published span. An unavailable boundary is never guessed with regex.
84
+ whole_source_span('unparsed_source')
85
+ end
86
+
87
+ def whole_source_span(kind)
88
+ [Span.new(kind: kind, name: field(:identifier), lexical_owner: nil,
89
+ start_byte: 0, end_byte: @source.bytesize, start_line: 1, end_line: @source.lines.size)]
90
+ end
91
+
92
+ # A heredoc body may live outside a def node's syntactic range (notably
93
+ # endless defs and same-line `def ...; <<~DOC; end`). Refuse a broken
94
+ # isolated definition and fall back to the complete published source.
95
+ def complete_definition?(span)
96
+ @parser.valid_fragment?(span_text(span))
97
+ end
98
+
99
+ def walk(node, owner, spans, singleton_scope: false)
100
+ return unless node.is_a?(Ast::Node)
101
+
102
+ if %i[class module].include?(node.type)
103
+ singleton_scope = false
104
+ name = node.method_name.to_s
105
+ owner = name.start_with?('::') ? name.delete_prefix('::') : [owner, name].compact.join('::')
106
+ elsif %i[def defs].include?(node.type)
107
+ if node.start_byte && node.end_byte
108
+ kind = if singleton_scope
109
+ 'singleton_body_method'
110
+ else
111
+ (node.type == :def ? 'instance_method' : 'singleton_method')
112
+ end
113
+ spans << Span.new(kind: kind,
114
+ name: node.method_name, lexical_owner: owner, receiver: node.receiver,
115
+ start_byte: node.start_byte,
116
+ end_byte: node.end_byte, start_line: node.line, end_line: node.end_line)
117
+ end
118
+ return # Nested definitions/blocks are already covered by the complete outer method.
119
+ end
120
+ node.children&.each do |child|
121
+ walk(child, owner, spans, singleton_scope: singleton_scope || node.type == :sclass)
122
+ end
123
+ end
124
+
125
+ # These are Woods' published display blocks, not physical concern source.
126
+ # Require matching published metadata and delimiters; preserve every byte,
127
+ # including comment prefixes, instead of reconstructing executable Ruby.
128
+ def concern_spans
129
+ metadata = field(:metadata) || {}
130
+ Array(metadata['inlined_concerns'] || metadata[:inlined_concerns]).filter_map do |name|
131
+ escaped = Regexp.escape(name.to_s)
132
+ pattern = /^# │ Included from: #{escaped}[^\S\r\n]*│\r?\n.*?^# ─+ End #{escaped} ─+\r?$/m
133
+ match = pattern.match(@source)
134
+ next unless match
135
+
136
+ start_byte = @source[0...match.begin(0)].bytesize
137
+ end_byte = @source[0...match.end(0)].bytesize
138
+ Span.new(kind: 'inlined_concern_display', name: name, lexical_owner: name,
139
+ start_byte: start_byte, end_byte: end_byte,
140
+ start_line: @source.byteslice(0, start_byte).count("\n") + 1,
141
+ end_line: @source.byteslice(0, end_byte).count("\n") + 1)
142
+ end
143
+ end
144
+
145
+ def terms(value)
146
+ value.to_s.gsub(/(\p{Ll}|\d)(\p{Lu})/u, '\1 \2').downcase.scan(/[\p{L}\p{N}]+/u)
147
+ .reject { |term| QueryClassifier::STOP_WORDS.include?(term) }.uniq
148
+ end
149
+
150
+ def relevance(span)
151
+ ((terms(span.name) & @query).size * 4) + (terms(span_text(span)) & @query).size
152
+ end
153
+
154
+ def span_text(span)
155
+ @source.byteslice(span.start_byte...span.end_byte)
156
+ end
157
+
158
+ def runtime_records
159
+ metadata = field(:metadata)
160
+ return [] unless metadata.is_a?(Hash)
161
+
162
+ LexicalIndex::RUNTIME_FIELDS.filter_map do |key|
163
+ value = metadata[key] || metadata[key.to_sym]
164
+ next if value.nil?
165
+
166
+ record = [key, value]
167
+ record if @query.empty? || (terms(JSON.generate(record)) & @query).any?
168
+ end
169
+ end
170
+
171
+ def format_evidence(mode, selected, metadata, total)
172
+ parts = ["Evidence: #{mode}; published-unit coordinates (physical location unavailable).",
173
+ "Unit: #{field(:type)}:#{field(:identifier)}; path: #{field(:file_path)}",
174
+ "Source SHA256: #{Digest::SHA256.hexdigest(@source)}",
175
+ "Generation: #{@generation || 'unavailable (not recorded by this metadata store)'}"]
176
+ selected.each do |span|
177
+ label = "#{span.kind} #{span.lexical_owner} #{span.name}; published lines #{span.start_line}-#{span.end_line}"
178
+ parts << (mode == 'outline' ? label : "#{label}\n```ruby\n#{span_text(span)}\n```")
179
+ end
180
+ parts << "Published runtime metadata:\n#{JSON.generate(metadata.to_h)}" unless metadata.empty?
181
+ parts << "Omitted: #{total - selected.size} source spans; remaining source/metadata not reproduced. " \
182
+ 'Use lookup with evidence: full for the complete published unit.'
183
+ parts.join("\n\n")
184
+ end
185
+
186
+ def provenance(mode, selected, metadata, all)
187
+ { mode: mode, owner: { identifier: field(:identifier), type: field(:type) },
188
+ coordinate_system: 'published_unit', source_sha256: Digest::SHA256.hexdigest(@source),
189
+ source_path: field(:file_path), physical_location: nil,
190
+ physical_location_reason: 'published unit may contain synthesized or inlined source',
191
+ generation: @generation, generation_status: @generation ? 'recorded' : 'unavailable',
192
+ spans: selected.map { |span| span.to_h.merge(sha256: Digest::SHA256.hexdigest(span_text(span))) },
193
+ omitted_spans: all.size - selected.size, runtime_fields: metadata.map(&:first),
194
+ source_complete: false, full_evidence: { tool: 'lookup', evidence: 'full',
195
+ identifier: field(:identifier), type: field(:type),
196
+ source_sha256: Digest::SHA256.hexdigest(@source) } }
197
+ end
198
+ end
199
+ end
200
+ end