woods 2.0.1 → 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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -7
  3. data/CONTRIBUTING.md +134 -19
  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 -25
  10. data/docs/CONSOLE_MCP_SETUP.md +82 -30
  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 +20 -15
  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 +153 -38
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  33. data/lib/tasks/woods.rake +23 -7
  34. data/lib/tasks/woods_checks.rake +2 -2
  35. data/lib/woods/agent_configuration/cli.rb +1 -1
  36. data/lib/woods/agent_configuration/layout.rb +16 -2
  37. data/lib/woods/agent_configuration/plan.rb +13 -3
  38. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  39. data/lib/woods/agent_configuration/preflight.rb +5 -3
  40. data/lib/woods/builder.rb +17 -57
  41. data/lib/woods/cache/cache_middleware.rb +56 -30
  42. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  43. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  44. data/lib/woods/console/connection_manager.rb +56 -3
  45. data/lib/woods/console/embedded_executor.rb +30 -5
  46. data/lib/woods/console/rack_middleware.rb +29 -1
  47. data/lib/woods/dependency_graph.rb +34 -10
  48. data/lib/woods/embedding/fake.rb +12 -0
  49. data/lib/woods/embedding/indexer.rb +195 -98
  50. data/lib/woods/embedding/input_budget.rb +67 -0
  51. data/lib/woods/embedding/openai.rb +70 -20
  52. data/lib/woods/embedding/provider.rb +37 -25
  53. data/lib/woods/embedding/text_preparer.rb +76 -32
  54. data/lib/woods/embedding/token_counter.rb +18 -81
  55. data/lib/woods/embedding/vector_configuration.rb +48 -0
  56. data/lib/woods/extraction_identities.rb +175 -0
  57. data/lib/woods/extractor.rb +304 -107
  58. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  59. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  60. data/lib/woods/extractors/class_declarations.rb +121 -0
  61. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  62. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  63. data/lib/woods/extractors/event_extractor.rb +8 -0
  64. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  65. data/lib/woods/extractors/job_extractor.rb +5 -1
  66. data/lib/woods/extractors/lib_extractor.rb +132 -15
  67. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  68. data/lib/woods/extractors/manager_extractor.rb +7 -21
  69. data/lib/woods/extractors/migration_declaration.rb +87 -0
  70. data/lib/woods/extractors/migration_extractor.rb +5 -39
  71. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  72. data/lib/woods/extractors/policy_extractor.rb +9 -5
  73. data/lib/woods/extractors/poro_extractor.rb +112 -53
  74. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  75. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  76. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  77. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  78. data/lib/woods/extractors/source_nesting.rb +142 -106
  79. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  80. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  81. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  82. data/lib/woods/flow_assembler.rb +4 -1
  83. data/lib/woods/generation.rb +25 -0
  84. data/lib/woods/hooks/context_hint.rb +7 -2
  85. data/lib/woods/mcp/bootstrapper.rb +33 -7
  86. data/lib/woods/mcp/config_resolver.rb +26 -7
  87. data/lib/woods/mcp/index_reader.rb +125 -24
  88. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  89. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  90. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  91. data/lib/woods/mcp/search_results.rb +7 -1
  92. data/lib/woods/mcp/server.rb +24 -4
  93. data/lib/woods/module_reconciliation.rb +151 -0
  94. data/lib/woods/path_dispatcher.rb +7 -2
  95. data/lib/woods/rake_helpers.rb +43 -11
  96. data/lib/woods/release.rb +1 -1
  97. data/lib/woods/resilience/index_validator.rb +8 -3
  98. data/lib/woods/resilience/retryable_provider.rb +18 -1
  99. data/lib/woods/resolved_config.rb +68 -8
  100. data/lib/woods/retrieval/context_assembler.rb +3 -3
  101. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  102. data/lib/woods/retrieval/scope.rb +18 -2
  103. data/lib/woods/retrieval/source_evidence.rb +14 -2
  104. data/lib/woods/source_contributor_validation.rb +78 -0
  105. data/lib/woods/source_contributors.rb +116 -0
  106. data/lib/woods/source_inputs/handoff.rb +37 -0
  107. data/lib/woods/source_inputs/launcher.rb +53 -13
  108. data/lib/woods/source_inputs/manifest.rb +84 -3
  109. data/lib/woods/source_inputs/private_key.rb +44 -12
  110. data/lib/woods/source_inputs/scanner.rb +98 -27
  111. data/lib/woods/source_inputs/scopes.rb +1 -1
  112. data/lib/woods/source_inputs/session.rb +147 -15
  113. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  114. data/lib/woods/source_inputs/status.rb +40 -8
  115. data/lib/woods/source_inputs/verifier.rb +28 -5
  116. data/lib/woods/source_path_encoding.rb +33 -0
  117. data/lib/woods/source_references/cache.rb +284 -0
  118. data/lib/woods/source_references/collector.rb +120 -0
  119. data/lib/woods/source_references/extraction.rb +185 -0
  120. data/lib/woods/source_references/inputs.rb +134 -0
  121. data/lib/woods/source_references/parser_adapter.rb +134 -0
  122. data/lib/woods/source_references/pass.rb +152 -0
  123. data/lib/woods/source_references/prism_adapter.rb +116 -0
  124. data/lib/woods/source_references/registry.rb +178 -0
  125. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  126. data/lib/woods/source_references/value_class.rb +82 -0
  127. data/lib/woods/storage/metadata_store.rb +4 -1
  128. data/lib/woods/storage/qdrant.rb +2 -2
  129. data/lib/woods/unblocked/client.rb +12 -7
  130. data/lib/woods/unblocked/document_builder.rb +4 -1
  131. data/lib/woods/unblocked/exporter.rb +127 -37
  132. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  133. data/lib/woods/unblocked/uri_migration.rb +105 -0
  134. data/lib/woods/util/host_guard.rb +3 -2
  135. data/lib/woods/version.rb +1 -1
  136. data/lib/woods/watch/catch_up.rb +138 -0
  137. data/lib/woods/watch/claim_lease.rb +150 -0
  138. data/lib/woods/watch/cli.rb +26 -2
  139. data/lib/woods/watch/daemon.rb +80 -59
  140. data/lib/woods/watch/installation/options.rb +1 -1
  141. data/lib/woods/watch/installation/receipt.rb +6 -1
  142. data/lib/woods/watch/managed_child.rb +1 -1
  143. data/lib/woods/watch/supervisor.rb +1 -1
  144. data/lib/woods/watch/tree_scan.rb +14 -2
  145. data/plugin/.claude-plugin/plugin.json +1 -1
  146. data/plugin/hooks/adapters/normalize.rb +3 -2
  147. data/plugin/hooks/woods-input-rules.sh +4 -0
  148. data/plugin/hooks/woods-refresh.sh +15 -7
  149. data/plugin/hooks/woods-session-start.sh +60 -3
  150. data/plugin/skills/woods-diagnose/SKILL.md +334 -11
  151. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  152. data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
  153. data/plugin/skills/woods-setup/SKILL.md +53 -6
  154. metadata +32 -5
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'set'
4
5
  require 'net/http'
5
6
  require 'uri'
6
7
  require 'woods'
@@ -145,9 +146,12 @@ module Woods
145
146
  query = "limit=#{limit}"
146
147
  query += "&after=#{URI.encode_www_form_component(after)}" if after
147
148
  result = request(:get, "documents?#{query}")
148
- return result if result.is_a?(Array)
149
+ page = result.is_a?(Array) ? result : result.is_a?(Hash) && (result['items'] || result['data'])
150
+ unless page.is_a?(Array) && page.all?(Hash)
151
+ raise Woods::Error, 'Unblocked document inventory incomplete: malformed page'
152
+ end
149
153
 
150
- result['items'] || result['data'] || []
154
+ page
151
155
  end
152
156
 
153
157
  # List every document in a collection, paging until exhausted.
@@ -160,20 +164,21 @@ module Woods
160
164
  def all_documents(collection_id:)
161
165
  docs = []
162
166
  after = nil
167
+ seen_cursors = Set.new
163
168
 
164
169
  loop do
165
170
  page = list_documents(limit: PAGE_SIZE, after: after)
166
171
  break if page.empty?
167
172
 
168
173
  docs.concat(page)
169
- break if page.size < PAGE_SIZE
170
-
171
174
  after = page.last['id']
172
- # A full page with no cursor id would refetch page 1 forever —
173
- # stop with what we have rather than loop against the budget.
174
- break if after.nil?
175
+ unless after.is_a?(String) && !after.empty? && seen_cursors.add?(after)
176
+ raise Woods::Error, 'Unblocked document inventory incomplete: missing or repeated pagination cursor'
177
+ end
175
178
  end
176
179
 
180
+ return docs if collection_id.nil?
181
+
177
182
  docs.select { |doc| doc['collectionId'] == collection_id }
178
183
  end
179
184
 
@@ -28,6 +28,9 @@ module Woods
28
28
  @ref = normalize_ref(ref)
29
29
  end
30
30
 
31
+ # @return [String] normalized branch/ref used in citation and ownership identity
32
+ attr_reader :ref
33
+
31
34
  # Ref used when the index does not say which one it came from.
32
35
  DEFAULT_REF = 'main'
33
36
 
@@ -60,7 +63,7 @@ module Woods
60
63
  file_path = unit_data['file_path']
61
64
  return @repo_url unless file_path
62
65
 
63
- "#{@repo_url}/blob/#{@ref}/#{encode_path(file_path)}"
66
+ "#{@repo_url}/blob/#{encode_path(@ref)}/#{encode_path(file_path)}"
64
67
  end
65
68
 
66
69
  private