woods 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +135 -10
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -18
  10. data/docs/CONSOLE_MCP_SETUP.md +141 -12
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +59 -2
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +184 -9
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/exe/woods-mcp-http +16 -9
  33. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  34. data/lib/tasks/woods.rake +23 -7
  35. data/lib/tasks/woods_checks.rake +2 -2
  36. data/lib/woods/agent_configuration/cli.rb +1 -1
  37. data/lib/woods/agent_configuration/layout.rb +16 -2
  38. data/lib/woods/agent_configuration/plan.rb +13 -3
  39. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  40. data/lib/woods/agent_configuration/preflight.rb +5 -3
  41. data/lib/woods/builder.rb +17 -57
  42. data/lib/woods/cache/cache_middleware.rb +68 -41
  43. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  44. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  45. data/lib/woods/console/adapter_family.rb +39 -0
  46. data/lib/woods/console/connection_manager.rb +56 -3
  47. data/lib/woods/console/credential_index.rb +33 -3
  48. data/lib/woods/console/embedded_executor.rb +431 -48
  49. data/lib/woods/console/model_validator.rb +8 -0
  50. data/lib/woods/console/rack_middleware.rb +68 -11
  51. data/lib/woods/console/redactor.rb +24 -10
  52. data/lib/woods/console/safe_context.rb +44 -7
  53. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  54. data/lib/woods/console/sql_table_scanner.rb +45 -34
  55. data/lib/woods/console/sql_validator.rb +37 -2
  56. data/lib/woods/dependency_graph.rb +34 -10
  57. data/lib/woods/embedding/fake.rb +12 -0
  58. data/lib/woods/embedding/indexer.rb +195 -98
  59. data/lib/woods/embedding/input_budget.rb +67 -0
  60. data/lib/woods/embedding/openai.rb +70 -20
  61. data/lib/woods/embedding/provider.rb +37 -25
  62. data/lib/woods/embedding/text_preparer.rb +76 -32
  63. data/lib/woods/embedding/token_counter.rb +18 -81
  64. data/lib/woods/embedding/vector_configuration.rb +48 -0
  65. data/lib/woods/extraction_identities.rb +175 -0
  66. data/lib/woods/extractor.rb +304 -107
  67. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  68. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  69. data/lib/woods/extractors/class_declarations.rb +121 -0
  70. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  71. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  72. data/lib/woods/extractors/event_extractor.rb +8 -0
  73. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  74. data/lib/woods/extractors/job_extractor.rb +5 -1
  75. data/lib/woods/extractors/lib_extractor.rb +132 -15
  76. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  77. data/lib/woods/extractors/manager_extractor.rb +7 -21
  78. data/lib/woods/extractors/migration_declaration.rb +87 -0
  79. data/lib/woods/extractors/migration_extractor.rb +5 -39
  80. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  81. data/lib/woods/extractors/policy_extractor.rb +9 -5
  82. data/lib/woods/extractors/poro_extractor.rb +112 -53
  83. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  84. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  85. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  86. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  87. data/lib/woods/extractors/source_nesting.rb +142 -106
  88. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  89. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  90. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  91. data/lib/woods/flow_assembler.rb +4 -1
  92. data/lib/woods/generation.rb +25 -0
  93. data/lib/woods/hooks/context_hint.rb +7 -2
  94. data/lib/woods/mcp/bearer_auth.rb +1 -1
  95. data/lib/woods/mcp/bootstrapper.rb +33 -7
  96. data/lib/woods/mcp/config_resolver.rb +26 -7
  97. data/lib/woods/mcp/index_reader.rb +125 -24
  98. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  99. data/lib/woods/mcp/origin_guard.rb +24 -77
  100. data/lib/woods/mcp/origin_policy.rb +124 -0
  101. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  102. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  103. data/lib/woods/mcp/search_results.rb +7 -1
  104. data/lib/woods/mcp/server.rb +24 -4
  105. data/lib/woods/module_reconciliation.rb +151 -0
  106. data/lib/woods/path_dispatcher.rb +7 -2
  107. data/lib/woods/railtie_support.rb +8 -0
  108. data/lib/woods/rake_helpers.rb +43 -11
  109. data/lib/woods/release.rb +1 -1
  110. data/lib/woods/resilience/index_validator.rb +8 -3
  111. data/lib/woods/resilience/retryable_provider.rb +18 -1
  112. data/lib/woods/resolved_config.rb +68 -8
  113. data/lib/woods/retrieval/context_assembler.rb +3 -3
  114. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  115. data/lib/woods/retrieval/scope.rb +18 -2
  116. data/lib/woods/retrieval/source_evidence.rb +14 -2
  117. data/lib/woods/source_contributor_validation.rb +78 -0
  118. data/lib/woods/source_contributors.rb +116 -0
  119. data/lib/woods/source_inputs/handoff.rb +37 -0
  120. data/lib/woods/source_inputs/launcher.rb +53 -13
  121. data/lib/woods/source_inputs/manifest.rb +84 -3
  122. data/lib/woods/source_inputs/private_key.rb +44 -12
  123. data/lib/woods/source_inputs/scanner.rb +98 -27
  124. data/lib/woods/source_inputs/scopes.rb +1 -1
  125. data/lib/woods/source_inputs/session.rb +147 -15
  126. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  127. data/lib/woods/source_inputs/status.rb +40 -8
  128. data/lib/woods/source_inputs/verifier.rb +28 -5
  129. data/lib/woods/source_path_encoding.rb +33 -0
  130. data/lib/woods/source_references/cache.rb +284 -0
  131. data/lib/woods/source_references/collector.rb +120 -0
  132. data/lib/woods/source_references/extraction.rb +185 -0
  133. data/lib/woods/source_references/inputs.rb +134 -0
  134. data/lib/woods/source_references/parser_adapter.rb +134 -0
  135. data/lib/woods/source_references/pass.rb +152 -0
  136. data/lib/woods/source_references/prism_adapter.rb +116 -0
  137. data/lib/woods/source_references/registry.rb +178 -0
  138. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  139. data/lib/woods/source_references/value_class.rb +82 -0
  140. data/lib/woods/storage/metadata_store.rb +4 -1
  141. data/lib/woods/storage/qdrant.rb +2 -2
  142. data/lib/woods/unblocked/client.rb +12 -7
  143. data/lib/woods/unblocked/document_builder.rb +4 -1
  144. data/lib/woods/unblocked/exporter.rb +127 -37
  145. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  146. data/lib/woods/unblocked/uri_migration.rb +105 -0
  147. data/lib/woods/util/host_guard.rb +3 -2
  148. data/lib/woods/version.rb +1 -1
  149. data/lib/woods/watch/catch_up.rb +138 -0
  150. data/lib/woods/watch/claim_lease.rb +150 -0
  151. data/lib/woods/watch/cli.rb +26 -2
  152. data/lib/woods/watch/daemon.rb +80 -59
  153. data/lib/woods/watch/installation/options.rb +1 -1
  154. data/lib/woods/watch/installation/receipt.rb +6 -1
  155. data/lib/woods/watch/managed_child.rb +1 -1
  156. data/lib/woods/watch/supervisor.rb +1 -1
  157. data/lib/woods/watch/tree_scan.rb +14 -2
  158. data/plugin/.claude-plugin/plugin.json +1 -1
  159. data/plugin/hooks/adapters/normalize.rb +3 -2
  160. data/plugin/hooks/woods-input-rules.sh +4 -0
  161. data/plugin/hooks/woods-refresh.sh +15 -7
  162. data/plugin/hooks/woods-session-start.sh +60 -3
  163. data/plugin/skills/woods-diagnose/SKILL.md +336 -2
  164. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  165. data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
  166. data/plugin/skills/woods-setup/SKILL.md +56 -1
  167. metadata +34 -5
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'woods'
5
+ require_relative 'collector'
6
+ require_relative 'registry'
7
+ require_relative 'inputs'
8
+ require_relative 'cache'
9
+
10
+ module Woods
11
+ module SourceReferences
12
+ # Retained references cannot be reconstructed safely from current bytes when
13
+ # their extraction provenance or edge ownership is unavailable.
14
+ class RebuildRequired < Woods::ExtractionError; end
15
+
16
+ # Resolves candidates against the complete current typed registry. Parsing is
17
+ # cached by captured HMAC, including candidates whose targets do not yet exist.
18
+ # Runtime lookup is repeated every run: source text alone cannot fingerprint
19
+ # changed ancestors, aliases or lexical shadows in a reloaded application.
20
+ class Pass
21
+ CALLER_TYPES = %w[model controller service poro lib concern].freeze
22
+ Result = Struct.new(:dependencies, :cache, :paths, keyword_init: true)
23
+
24
+ # @param root [String, Pathname] application root
25
+ # @param session [SourceInputs::Session] original source capture
26
+ # @param units [Array<Hash>] complete string-keyed typed unit set
27
+ # @param extractor_keys [Hash{Symbol => Symbol}] unit type to consumer key
28
+ # @param baseline [Hash, nil] validated preceding generation cache
29
+ # @param refreshed [Array<Array<String>>] units genuinely re-extracted
30
+ # @param full [Boolean] all runtime units were re-extracted
31
+ # @param collector [Collector] source-only parser
32
+ # rubocop:disable Metrics/ParameterLists -- explicit per-run collaborators and provenance state
33
+ def initialize(root:, session:, units:, extractor_keys:, baseline: nil, refreshed: [], full: false,
34
+ collector: Collector.new)
35
+ @root = File.expand_path(root.to_s)
36
+ @session = session
37
+ @units = units
38
+ @keys = extractor_keys
39
+ @baseline = baseline
40
+ @refreshed = refreshed.to_set
41
+ @full = full
42
+ @collector = collector
43
+ end
44
+
45
+ # rubocop:enable Metrics/ParameterLists
46
+
47
+ # @return [Result] complete dependency replacements and next cache
48
+ def call
49
+ raise_rebuild('reference cache is missing') if !@full && !@baseline
50
+
51
+ @inputs = Inputs.new(root: @root, session: @session, collector: @collector)
52
+ @files = @inputs.call(units: @units, baseline: @baseline, fresh: method(:fresh?), extractor_keys: @keys)
53
+ sources = @files.transform_values { |entry| entry['analysis'] }
54
+ @registry = Registry.new(units: @units, sources: sources, root: @root)
55
+ @previous = Array(@baseline&.fetch('owners')).to_h { |owner| [[owner['type'], owner['identifier']], owner] }
56
+ build_result
57
+ end
58
+
59
+ private
60
+
61
+ def build_result
62
+ dependencies = {}
63
+ owners = @units.filter_map do |unit|
64
+ key = [unit['type'], unit['identifier']]
65
+ path = @inputs.relative(unit['file_path'])
66
+ additions = []
67
+ if CALLER_TYPES.include?(unit['type'])
68
+ base = original_dependencies(unit, key)
69
+ additions = all_resolved_dependencies(unit) - base
70
+ dependencies[key] = base + additions
71
+ end
72
+ # Target-only units also crossed Inputs' consumption gate. Retain
73
+ # their exact ownership so bounded freshness can reuse that proof.
74
+ owner_record(unit, path, additions)
75
+ end
76
+ owners.sort_by! { |owner| [owner['type'], owner['identifier']] }
77
+ cache = { 'version' => Cache::VERSION, 'files' => @files, 'owners' => owners }
78
+ Result.new(dependencies: dependencies, cache: cache, paths: @files.keys)
79
+ end
80
+
81
+ def owner_record(unit, path, additions)
82
+ return unless Registry::TYPES.include?(unit['type']&.to_sym)
83
+ return unless @files.key?(path) && RuntimeLookup::CONSTANT.match?(unit['identifier'].to_s)
84
+
85
+ record = { 'type' => unit['type'], 'identifier' => unit['identifier'], 'file_path' => path,
86
+ 'added' => additions }
87
+ record['file_paths'] = reference_paths(unit) if SourceContributors.multiple?(unit)
88
+ record
89
+ end
90
+
91
+ def fresh?(unit)
92
+ @full || @refreshed.include?([unit['type'], unit['identifier']])
93
+ end
94
+
95
+ def original_dependencies(unit, key)
96
+ dependencies = unit.fetch('dependencies', []).map { |edge| normalize_edge(edge) }
97
+ return dependencies if @full || @refreshed.include?(key)
98
+
99
+ prior = ownership_record(unit, key)
100
+ return dependencies unless prior
101
+
102
+ prior.fetch('added').each do |edge|
103
+ position = dependencies.index(edge)
104
+ raise_rebuild("reference ownership differs for #{key.join(':')}") unless position
105
+
106
+ dependencies.delete_at(position)
107
+ end
108
+ dependencies
109
+ end
110
+
111
+ def ownership_record(unit, key)
112
+ path = @inputs.relative(unit['file_path'])
113
+ prior = @previous[key]
114
+ expected = @files.key?(path) || @baseline.fetch('files').key?(path)
115
+ if expected && RuntimeLookup::CONSTANT.match?(unit['identifier'].to_s) &&
116
+ !same_owner_paths?(prior, unit, path)
117
+ raise_rebuild("reference ownership is missing or incompatible for #{key.join(':')}")
118
+ end
119
+ prior
120
+ end
121
+
122
+ def all_resolved_dependencies(unit)
123
+ reference_paths(unit).flat_map { |source| resolved_dependencies(unit, source) }.uniq
124
+ end
125
+
126
+ def same_owner_paths?(prior, unit, path)
127
+ prior && prior['file_path'] == path && (prior['file_paths'] || [path]) == reference_paths(unit)
128
+ end
129
+
130
+ def reference_paths(unit)
131
+ SourceContributors.paths(unit).map { |path| @inputs.relative(path) }
132
+ end
133
+
134
+ def normalize_edge(edge)
135
+ edge.transform_keys(&:to_s).transform_values { |value| value.is_a?(Symbol) ? value.to_s : value }
136
+ end
137
+
138
+ def resolved_dependencies(unit, path)
139
+ records = @files.dig(path, 'analysis', 'references') || []
140
+ owned = records.select { |reference| reference['owner'] == unit['identifier'] }
141
+ edges = owned.filter_map { |reference| @registry.resolve(reference, file_path: path) }
142
+ edges.map! { |edge| normalize_edge(edge) }
143
+ edges.uniq.sort_by { |edge| [edge['type'], edge['target'], edge['via']] }
144
+ end
145
+
146
+ def raise_rebuild(reason)
147
+ raise RebuildRequired, "Source-reference baseline needs a full extraction (#{reason}). " \
148
+ 'Run bin/rails woods:extract; the previous published generation remains active.'
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Woods
6
+ module SourceReferences
7
+ # Retains AST context that the general Woods flow AST intentionally omits.
8
+ class PrismAdapter
9
+ KINDS = { class_node: :declaration, module_node: :declaration, constant_read_node: :constant,
10
+ constant_path_node: :constant, singleton_class_node: :singleton, def_node: :method }.freeze
11
+ CONSTANT_WRITES = %i[
12
+ constant_write_node constant_path_write_node constant_operator_write_node constant_path_operator_write_node
13
+ constant_and_write_node constant_path_and_write_node constant_or_write_node constant_path_or_write_node
14
+ ].freeze
15
+ DYNAMIC_BLOCK_METHODS = %i[class_eval module_eval instance_eval class_exec module_exec instance_exec].freeze
16
+
17
+ def parse(source)
18
+ result = Prism.parse(source)
19
+ error = result.errors.first
20
+ [result.value, error && { 'message' => error.message, 'line' => error.location.start_line }]
21
+ end
22
+
23
+ def kind(node)
24
+ return KINDS[node.type] if KINDS.key?(node.type)
25
+
26
+ case node.type
27
+ when :constant_write_node, :constant_path_write_node then value_constructor(node) ? :value_class : :other
28
+ when :call_node then dynamic_scope?(node) ? :dynamic_scope : :other
29
+ else :other
30
+ end
31
+ end
32
+
33
+ def constant_name(node)
34
+ return unless node
35
+ return node.name.to_s if node.type == :constant_read_node
36
+ return unless node.type == :constant_path_node
37
+
38
+ parent = node.parent ? constant_name(node.parent) : ''
39
+ name = node.respond_to?(:name) ? node.name : node.child.name
40
+ "#{parent}::#{name}" if parent
41
+ end
42
+
43
+ def children(node)
44
+ return [node.value].compact if CONSTANT_WRITES.include?(node.type)
45
+ return [] if %i[constant_target_node constant_path_target_node].include?(node.type)
46
+ return [node.value].compact if node.type == :multi_write_node
47
+ return [node.parameters, node.body].compact if node.type == :def_node
48
+
49
+ node.compact_child_nodes
50
+ end
51
+
52
+ def value_constructor(node)
53
+ call = node.value
54
+ return unless call&.type == :call_node
55
+
56
+ name = constant_name(call.receiver)
57
+ name if { new: %w[Struct ::Struct], define: %w[Data ::Data] }.fetch(call.name, []).include?(name)
58
+ end
59
+
60
+ def assignment_name(node)
61
+ node.type == :constant_write_node ? node.name.to_s : constant_name(node.target)
62
+ end
63
+
64
+ def declaration_name(node)
65
+ constant_name(node.constant_path)
66
+ end
67
+
68
+ def declaration_kind(node)
69
+ node.type == :class_node ? 'class' : 'module'
70
+ end
71
+
72
+ def superclass(node)
73
+ node.superclass if node.type == :class_node
74
+ end
75
+
76
+ def body(node)
77
+ node.body
78
+ end
79
+
80
+ def self_receiver?(node)
81
+ node.expression.type == :self_node
82
+ end
83
+
84
+ def local_method?(node)
85
+ node.receiver.nil? || node.receiver.type == :self_node
86
+ end
87
+
88
+ def line(node)
89
+ node.location.start_line
90
+ end
91
+
92
+ def end_line(node)
93
+ node.location.end_line
94
+ end
95
+
96
+ private
97
+
98
+ def dynamic_scope?(node)
99
+ return false unless node.block&.type == :block_node
100
+ return true if DYNAMIC_BLOCK_METHODS.include?(node.name)
101
+
102
+ foreign_method?(node) || anonymous_class?(node.name, node.receiver)
103
+ end
104
+
105
+ def foreign_method?(node)
106
+ %i[define_method define_singleton_method].include?(node.name) &&
107
+ node.receiver && node.receiver.type != :self_node
108
+ end
109
+
110
+ def anonymous_class?(method, receiver)
111
+ name = constant_name(receiver)&.delete_prefix('::')
112
+ { new: %w[Class Module Struct], define: ['Data'] }.fetch(method, []).include?(name)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'runtime_lookup'
5
+ require_relative 'value_class'
6
+ require_relative '../source_contributors'
7
+
8
+ module Woods
9
+ module SourceReferences
10
+ # Maps verified source declarations to extracted constant-owned units.
11
+ # File-profile and other non-constant identities are never resolution targets.
12
+ class Registry # rubocop:disable Metrics/ClassLength -- keep runtime, source and autoload ownership checks together
13
+ TYPES = %i[model controller service poro lib concern job mailer component view_component
14
+ graphql_type graphql_mutation graphql_resolver graphql_query serializer manager
15
+ policy validator pundit_policy decorator action_cable_channel].freeze
16
+
17
+ # @param units [Array<ExtractedUnit, Hash>] current complete typed unit set
18
+ # @param sources [Hash{String => Hash}] path to collector result
19
+ # @param root [String, Pathname] application root
20
+ def initialize(units:, sources:, root:)
21
+ @root = File.expand_path(root.to_s)
22
+ @lookup = RuntimeLookup.new
23
+ @sources = sources.to_h { |path, result| [absolute(path), result] }
24
+ @all_types = units.group_by { |unit| field(unit, :identifier) }
25
+ @entries = units.flat_map { |unit| SourceContributors.paths(unit).filter_map { |path| entry(unit, path) } }
26
+ @owners = @entries.group_by { |record| [record[:path], record[:identifier]] }
27
+ @targets = @entries.group_by { |record| record[:identifier] }
28
+ end
29
+
30
+ # @param reference [Hash] JSON-compatible collector reference record
31
+ # @param file_path [String] source containing the reference
32
+ # @return [Hash, nil] a dependency, or nil for an unresolved reference
33
+ def resolve(reference, file_path:)
34
+ result = explain(reference, file_path: file_path)
35
+ return unless result['status'] == 'resolved'
36
+
37
+ { type: result.fetch('type').to_sym, target: result.fetch('target'), via: :code_reference }
38
+ end
39
+
40
+ # @param reference [Hash] collected spelling, owner and lexical nesting
41
+ # @param file_path [String] original source path
42
+ # @return [Hash] JSON-compatible resolution or explicit unresolved reason
43
+ def explain(reference, file_path:)
44
+ return unresolved('unverified_owner') unless owner?(reference['owner'], file_path: file_path)
45
+
46
+ return unresolved('unsupported_singleton_scope') if reference.fetch('singleton_depth', 0).positive?
47
+
48
+ result = @lookup.call(reference['name'], nesting: reference.fetch('nesting', []))
49
+ target = result[:target] || autoload_target(result) || source_only_target(reference, result)
50
+ return unresolved(result[:reason] || 'constant_missing') unless target
51
+
52
+ target_result(target, owner: reference['owner'])
53
+ end
54
+
55
+ # @param identifier [String] source declaration identity
56
+ # @param file_path [String] original source path
57
+ # @return [Boolean] whether exactly one extracted type owns the declaration
58
+ def owner?(identifier, file_path:)
59
+ records = @owners[[absolute(file_path), identifier]]
60
+ records && !records.empty? && !ambiguous?(identifier)
61
+ end
62
+
63
+ private
64
+
65
+ def target_result(target, owner:)
66
+ return unresolved('ambiguous_target') if ambiguous?(target)
67
+
68
+ entries = @targets[target]
69
+ return unresolved('target_not_indexed') unless entries&.any?
70
+ return unresolved('self_reference') if target == owner
71
+
72
+ { 'status' => 'resolved', 'target' => target, 'type' => entries.first[:type].to_s }
73
+ end
74
+
75
+ def entry(unit, path)
76
+ type = field(unit, :type)&.to_sym
77
+ return unless TYPES.include?(type)
78
+
79
+ identifier = field(unit, :identifier)
80
+ return unless eligible_identity?(identifier, path)
81
+
82
+ path = absolute(path)
83
+ source = @sources[path]
84
+ return unless verified_declaration?(source, identifier, type, path)
85
+
86
+ { identifier: identifier, type: type, path: path }
87
+ end
88
+
89
+ def eligible_identity?(identifier, path)
90
+ path && identifier.is_a?(String) && RuntimeLookup::CONSTANT.match?(identifier)
91
+ end
92
+
93
+ def verified_declaration?(source, identifier, type, path)
94
+ return false unless source && source['parse_error'].nil?
95
+
96
+ source.fetch('declarations', []).any? { |decl| declaration?(decl, identifier, type, path) }
97
+ end
98
+
99
+ def declaration?(declaration, identifier, type, path)
100
+ return false unless declaration['owner'] == identifier
101
+ return false if declaration.fetch('singleton_depth', 0).positive?
102
+
103
+ return ValueClass.new.call(declaration, file_path: path) == identifier if declaration['constructor']
104
+
105
+ result = @lookup.call(declaration['name'], nesting: declaration.fetch('enclosing_nesting', []),
106
+ allow_private: true)
107
+ return runtime_declaration?(declaration, identifier, result) if result[:status] == :resolved
108
+
109
+ source_declaration?(declaration, identifier, type, path)
110
+ end
111
+
112
+ def source_declaration?(declaration, identifier, type, path)
113
+ return false unless type == :lib && static_declaration?(declaration, identifier)
114
+
115
+ root_result = @lookup.call("::#{identifier}", allow_private: true)
116
+ root_result[:status] == :missing || autoload_declaration?(root_result, identifier, path)
117
+ end
118
+
119
+ def runtime_declaration?(declaration, identifier, result)
120
+ kind = @lookup.class_object?(result[:value]) ? 'class' : 'module'
121
+ result[:target] == identifier && declaration['kind'] == kind
122
+ end
123
+
124
+ def static_declaration?(declaration, identifier)
125
+ name = declaration['name']
126
+ return false unless name.is_a?(String) && RuntimeLookup::CONSTANT.match?(name)
127
+ return name.delete_prefix('::') == identifier if name.start_with?('::')
128
+
129
+ nesting = declaration.fetch('enclosing_nesting', [])
130
+ return name == identifier if nesting.empty?
131
+
132
+ !name.include?('::') && [nesting.first, name].join('::') == identifier
133
+ end
134
+
135
+ def autoload_declaration?(result, identifier, path)
136
+ registered = result[:autoload_path]
137
+ result[:pending_target] == identifier && registered.is_a?(String) &&
138
+ Pathname.new(registered).absolute? && absolute(registered) == path
139
+ end
140
+
141
+ def autoload_target(result)
142
+ name = result[:pending_target]
143
+ candidates = @targets[name]
144
+ return unless candidates&.any?
145
+
146
+ name if candidates.all? { |entry| entry[:type] == :lib && autoload_declaration?(result, name, entry[:path]) }
147
+ end
148
+
149
+ def source_only_target(reference, result)
150
+ return unless result[:status] == :missing
151
+
152
+ return unless reference['name'].to_s.start_with?('::') || result[:root_lookup]
153
+
154
+ name = reference['name'].to_s.delete_prefix('::')
155
+ candidates = @targets[name]
156
+ name if candidates&.all? { |entry| entry[:type] == :lib }
157
+ end
158
+
159
+ def ambiguous?(identifier)
160
+ @all_types.fetch(identifier, []).map { |unit| field(unit, :type).to_s }.uniq.size > 1
161
+ end
162
+
163
+ def field(unit, name)
164
+ return unit.public_send(name) unless unit.is_a?(Hash)
165
+
166
+ unit.key?(name) ? unit[name] : unit[name.to_s]
167
+ end
168
+
169
+ def absolute(path)
170
+ File.expand_path(path.to_s, @root)
171
+ end
172
+
173
+ def unresolved(reason)
174
+ { 'status' => 'unresolved', 'reason' => reason }
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module SourceReferences
5
+ # Reads already-loaded constant tables without invoking application overrides,
6
+ # const_missing, or an autoload. Results are scoped to one extraction run.
7
+ class RuntimeLookup
8
+ CORE_TYPE = Module.instance_method(:===)
9
+ CORE_EQUAL = BasicObject.instance_method(:equal?)
10
+ CONSTANT = /\A(?:::)?[[:upper:]][[:word:]]*(?:::[[:upper:]][[:word:]]*)*\z/
11
+ REFLECTION = %i[const_defined? const_get autoload? ancestors name constants].to_h do |method|
12
+ [method, Module.instance_method(method)]
13
+ end.freeze
14
+
15
+ # @param name [String] a literal constant path
16
+ # @param nesting [Array<String>] lexical scopes, innermost first
17
+ # @return [Hash] lookup status and canonical target or unresolved reason
18
+ def call(name, nesting: [], allow_private: false)
19
+ return unknown('invalid_constant') unless valid_name?(name)
20
+
21
+ parts = name.delete_prefix('::').split('::')
22
+ scopes = name.start_with?('::') ? { scopes: [Object] } : lexical_scopes(nesting)
23
+ return scopes unless scopes[:scopes]
24
+
25
+ result = first_constant(scopes[:scopes], parts.shift, public_only: name.start_with?('::') && !allow_private)
26
+ parts.each do |part|
27
+ return result.except(:pending_target, :autoload_path) unless module_object?(result[:value])
28
+
29
+ result = first_constant(qualified_scopes(result[:value]), part, public_only: !allow_private)
30
+ end
31
+ result
32
+ end
33
+
34
+ # @param scope [Module] runtime module to inspect
35
+ # @param method [Symbol] one of the bound core reflection methods
36
+ # @return [Object] core reflection result
37
+ def reflect(scope, method, *args)
38
+ REFLECTION.fetch(method).bind(scope).call(*args)
39
+ end
40
+
41
+ # @return [Boolean] core class/module checks that cannot dispatch to application overrides
42
+ def module_object?(value)
43
+ CORE_TYPE.bind(Module).call(value)
44
+ end
45
+
46
+ # @return [Boolean] whether a runtime object is a class
47
+ def class_object?(value)
48
+ CORE_TYPE.bind(Class).call(value)
49
+ end
50
+
51
+ private
52
+
53
+ def valid_name?(name)
54
+ name.is_a?(String) && CONSTANT.match?(name)
55
+ end
56
+
57
+ def identical?(left, right)
58
+ CORE_EQUAL.bind(left).call(right)
59
+ end
60
+
61
+ def qualified_scopes(receiver)
62
+ ancestors = reflect(receiver, :ancestors)
63
+ return [receiver, *ancestors] if identical?(receiver, Object)
64
+
65
+ [receiver, *ancestors.take_while { |ancestor| !identical?(ancestor, Object) }]
66
+ end
67
+
68
+ def lexical_scopes(nesting)
69
+ return { scopes: [Object] } if nesting.empty?
70
+
71
+ modules = nesting.map do |name|
72
+ result = call("::#{name}", allow_private: true)
73
+ return unknown('unloaded_scope') unless module_object?(result[:value])
74
+
75
+ result[:value]
76
+ end
77
+ ancestors = reflect(modules.first, :ancestors)
78
+ ancestors += [Object] unless class_object?(modules.first)
79
+ { scopes: modules + ancestors }
80
+ end
81
+
82
+ def first_constant(scopes, name, public_only: false)
83
+ scopes.each do |scope|
84
+ next unless reflect(scope, :const_defined?, name, false)
85
+ return unknown('private_constant') if private_access?(scope, name, public_only)
86
+
87
+ autoload_path = reflect(scope, :autoload?, name, false)
88
+ return pending_autoload(scope, name, autoload_path) if autoload_path
89
+
90
+ return constant_value(scope, name)
91
+ end
92
+ { status: :missing, reason: 'constant_missing', root_lookup: scopes.any? { |scope| identical?(scope, Object) } }
93
+ rescue NameError
94
+ unknown('constant_changed')
95
+ end
96
+
97
+ # Expose registration evidence without evaluating the source file. Only
98
+ # Registry may promote it, after matching a captured library declaration.
99
+ def pending_autoload(scope, name, path)
100
+ namespace = identical?(scope, Object) ? nil : reflect(scope, :name)
101
+ return unknown('autoload_pending') if namespace.nil? && !identical?(scope, Object)
102
+
103
+ unknown('autoload_pending').merge(pending_target: [namespace, name].compact.join('::'), autoload_path: path)
104
+ end
105
+
106
+ def private_access?(scope, name, public_only)
107
+ public_only && !reflect(scope, :constants, false).include?(name.to_sym)
108
+ end
109
+
110
+ def constant_value(scope, name)
111
+ value = reflect(scope, :const_get, name, false)
112
+ return unknown('non_module_constant') unless module_object?(value)
113
+
114
+ canonical = reflect(value, :name)
115
+ namespace = identical?(scope, Object) ? nil : reflect(scope, :name)
116
+ expected = [namespace, name].compact.join('::')
117
+ return unknown('constant_alias') unless canonical == expected
118
+
119
+ { status: :resolved, target: canonical, value: value }
120
+ end
121
+
122
+ def unknown(reason)
123
+ { status: :unknown, reason: reason }
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'runtime_lookup'
4
+
5
+ module Woods
6
+ module SourceReferences
7
+ # Verifies literal value-class assignment evidence against loaded Ruby state.
8
+ # Bound core methods avoid application reflection overrides and never run a
9
+ # constructor, autoload, const_missing, or an application method body.
10
+ class ValueClass
11
+ SOURCE_LOCATION = Module.instance_method(:const_source_location)
12
+ SINGLETON_CLASS = Object.instance_method(:singleton_class)
13
+ INSTANCE_METHOD = Module.instance_method(:instance_method)
14
+ METHOD_OWNER = UnboundMethod.instance_method(:owner)
15
+ METHOD_SOURCE = UnboundMethod.instance_method(:source_location)
16
+
17
+ def initialize
18
+ @lookup = RuntimeLookup.new
19
+ end
20
+
21
+ # @param declaration [Hash] collector declaration with a literal constructor
22
+ # @param file_path [String] original file containing the assignment
23
+ # @return [String, nil] canonical, source-owned class identity
24
+ def call(declaration, file_path:)
25
+ return unless candidate?(declaration)
26
+
27
+ factory = factory(declaration)
28
+ return unless factory
29
+
30
+ result = @lookup.call(declaration['name'], nesting: declaration['enclosing_nesting'], allow_private: true)
31
+ return unless value_class?(result, declaration, factory)
32
+ return unless owns_assignment?(result[:target], declaration, file_path)
33
+
34
+ result[:target]
35
+ rescue NameError, TypeError, ArgumentError
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ def candidate?(declaration)
42
+ declaration['constructor'] && declaration.fetch('singleton_depth', 0).zero?
43
+ end
44
+
45
+ def value_class?(result, declaration, factory)
46
+ result[:target] == declaration['owner'] && @lookup.class_object?(result[:value]) &&
47
+ @lookup.reflect(result[:value], :ancestors).any? { |ancestor| identical?(ancestor, factory) }
48
+ end
49
+
50
+ def factory(declaration)
51
+ name = declaration['constructor']
52
+ core_name = name.delete_prefix('::')
53
+ return unless %w[Struct Data].include?(core_name)
54
+
55
+ actual = @lookup.call(name, nesting: declaration['enclosing_nesting'])[:value]
56
+ core = @lookup.call("::#{core_name}")[:value]
57
+ return unless core && identical?(actual, core)
58
+
59
+ singleton = SINGLETON_CLASS.bind(core).call
60
+ method = INSTANCE_METHOD.bind(singleton).call(core_name == 'Struct' ? :new : :define)
61
+ return unless identical?(METHOD_OWNER.bind(method).call, singleton) && METHOD_SOURCE.bind(method).call.nil?
62
+
63
+ core
64
+ end
65
+
66
+ def owns_assignment?(identifier, declaration, file_path)
67
+ parts = identifier.split('::')
68
+ name = parts.pop
69
+ scope = parts.empty? ? Object : @lookup.call("::#{parts.join('::')}", allow_private: true)[:value]
70
+ return false unless @lookup.module_object?(scope)
71
+
72
+ location = SOURCE_LOCATION.bind(scope).call(name, false)
73
+ location && File.expand_path(location.first) == File.expand_path(file_path) &&
74
+ location.last.between?(declaration['line'], declaration['end_line'])
75
+ end
76
+
77
+ def identical?(left, right)
78
+ RuntimeLookup::CORE_EQUAL.bind(left).call(right)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -115,7 +115,10 @@ module Woods
115
115
  raise NotImplementedError
116
116
  end
117
117
 
118
- # Every stored identifier.
118
+ # Every stored identity, including source-empty records without vectors.
119
+ # Return internal typed keys unchanged; do not collapse same-name units
120
+ # to their shared public identifier. Embedding reconciliation uses this
121
+ # inventory independently of the optional snapshot #each_entry seam.
119
122
  #
120
123
  # Used by the evaluation harness's {Woods::Evaluation::BaselineRunner}
121
124
  # to build naive baselines (grep/random/file-level) to score the real
@@ -193,8 +193,8 @@ module Woods
193
193
  def self.validate_host_visibility!(host, allow_private_hosts:)
194
194
  return if allow_private_hosts
195
195
 
196
- # Canonicalize (strip port, trailing dot, IPv6 brackets) via
197
- # the shared helper so Qdrant and OriginGuard stay in sync.
196
+ # Canonicalize URL hosts before applying the numeric-host checks
197
+ # also used by the HTTP origin policy.
198
198
  canonical = Util::HostGuard.canonicalize(host)
199
199
 
200
200
  # Non-canonical numeric hosts (hex `0x7f000001`, octal