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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -7
- data/CONTRIBUTING.md +134 -19
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -25
- data/docs/CONSOLE_MCP_SETUP.md +82 -30
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +20 -15
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +153 -38
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +56 -30
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/embedded_executor.rb +30 -5
- data/lib/woods/console/rack_middleware.rb +29 -1
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +334 -11
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
- data/plugin/skills/woods-setup/SKILL.md +53 -6
- 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
|
|
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
|
data/lib/woods/storage/qdrant.rb
CHANGED
|
@@ -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
|
|
197
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|