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,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'pass'
|
|
4
|
+
require_relative 'cache'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module SourceReferences
|
|
8
|
+
# Pipeline hooks keep candidate cache, typed forward edges and reverse graph
|
|
9
|
+
# publication in one generation. A reference-only rewrite is not extraction
|
|
10
|
+
# and must not refresh runtime metadata, timestamps or Git facts.
|
|
11
|
+
module Extraction # rubocop:disable Metrics/ModuleLength -- shared full/incremental atomic publication hooks
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def enrich_source_references_full
|
|
15
|
+
units = @results.values.flatten
|
|
16
|
+
records = units.map { |unit| reference_record(unit) }
|
|
17
|
+
result = reference_pass(records, full: true)
|
|
18
|
+
units.each do |unit|
|
|
19
|
+
edges = result.dependencies[[unit.type.to_s, unit.identifier]]
|
|
20
|
+
next unless edges
|
|
21
|
+
|
|
22
|
+
# Full extraction only adds records, so retain each extractor's exact
|
|
23
|
+
# in-memory representations for its existing specialized relationships.
|
|
24
|
+
unit.dependencies += internal_reference_edges(edges.drop(unit.dependencies.size))
|
|
25
|
+
end
|
|
26
|
+
write_source_reference_cache(result)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def internal_reference_edges(edges)
|
|
30
|
+
edges.map do |edge|
|
|
31
|
+
edge.transform_keys(&:to_sym).merge(type: edge.fetch('type').to_sym, via: edge.fetch('via').to_sym)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reference_record(unit)
|
|
36
|
+
{ 'type' => unit.type.to_s, 'identifier' => unit.identifier, 'file_path' => unit.file_path,
|
|
37
|
+
'metadata' => unit.metadata, 'dependencies' => JSON.parse(JSON.generate(unit.dependencies)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Called before any incremental consumer mutates the seeded generation.
|
|
41
|
+
def prepare_source_reference_baseline
|
|
42
|
+
@source_reference_baseline = Cache.read(payload_dir.join(Cache::FILE_NAME))
|
|
43
|
+
return if @source_reference_baseline
|
|
44
|
+
|
|
45
|
+
empty_source_reference_baseline!
|
|
46
|
+
rescue Cache::Invalid
|
|
47
|
+
empty_source_reference_baseline!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def empty_source_reference_baseline!
|
|
51
|
+
if source_reference_identities.any? { |type, identifier| source_reference_node?(type, identifier) }
|
|
52
|
+
raise RebuildRequired,
|
|
53
|
+
'Source-reference baseline is missing or incompatible. Run bin/rails woods:extract once for a full ' \
|
|
54
|
+
'extraction; the previous published generation remains active.'
|
|
55
|
+
end
|
|
56
|
+
@source_reference_baseline = { 'version' => Cache::VERSION, 'files' => {}, 'owners' => [] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def source_reference_node?(type, identifier)
|
|
60
|
+
return false unless Registry::TYPES.include?(type)
|
|
61
|
+
return false unless RuntimeLookup::CONSTANT.match?(identifier)
|
|
62
|
+
|
|
63
|
+
path = @dependency_graph.node(identifier, type: type)[:file_path]
|
|
64
|
+
return false if path.nil?
|
|
65
|
+
|
|
66
|
+
relative = File.expand_path(path, Rails.root).delete_prefix("#{File.expand_path(Rails.root)}/")
|
|
67
|
+
relative.match?(%r{\A(?:app|lib)/.*\.rb\z}) &&
|
|
68
|
+
relative.split('/').none? { |part| %w[vendor node_modules assets].include?(part) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def source_reference_identities
|
|
72
|
+
@dependency_graph.to_h.fetch(:type_index).flat_map do |type, identifiers|
|
|
73
|
+
identifiers.map { |identifier| [type.to_sym, identifier] }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def enrich_source_references_incremental(affected_types)
|
|
78
|
+
records = source_reference_payload_records
|
|
79
|
+
result = reference_pass(records.values.map { |entry| entry.fetch(:data) }, full: false)
|
|
80
|
+
touched = Set.new
|
|
81
|
+
result.dependencies.each do |identity, edges|
|
|
82
|
+
record = records.fetch(identity)
|
|
83
|
+
next if record.fetch(:data).fetch('dependencies', []) == edges
|
|
84
|
+
|
|
85
|
+
rewrite_source_reference_edges(record, edges, affected_types)
|
|
86
|
+
touched.add(identity.last)
|
|
87
|
+
end
|
|
88
|
+
write_source_reference_cache(result)
|
|
89
|
+
touched
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def source_reference_payload_records
|
|
93
|
+
owned = Array(@source_reference_baseline&.fetch('owners')).to_set do |owner|
|
|
94
|
+
[owner['type'], owner['identifier']]
|
|
95
|
+
end
|
|
96
|
+
source_reference_identities.to_h do |type, identifier|
|
|
97
|
+
read_caller = Pass::CALLER_TYPES.include?(type.to_s) &&
|
|
98
|
+
(source_reference_node?(type, identifier) || owned.include?([type.to_s, identifier]))
|
|
99
|
+
record = if read_caller
|
|
100
|
+
source_reference_caller_record(type, identifier)
|
|
101
|
+
else
|
|
102
|
+
node = @dependency_graph.node(identifier, type: type)
|
|
103
|
+
{ data: { 'type' => type.to_s, 'identifier' => identifier,
|
|
104
|
+
'file_path' => node[:file_path], 'dependencies' => [] } }
|
|
105
|
+
end
|
|
106
|
+
[[type.to_s, identifier], record]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def source_reference_caller_record(type, identifier)
|
|
111
|
+
key = self.class::TYPE_TO_EXTRACTOR_KEY.fetch(type)
|
|
112
|
+
path = payload_dir.join(key.to_s, collision_safe_filename(identifier))
|
|
113
|
+
data = JSON.parse(AtomicFile.read(path))
|
|
114
|
+
unless data.is_a?(Hash) && data['type'] == type.to_s && data['identifier'] == identifier
|
|
115
|
+
raise Woods::ExtractionError, "Source-reference unit identity mismatch for #{type}:#{identifier}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
{ data: data, path: path, extractor_key: key }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def reference_pass(units, full:)
|
|
122
|
+
Pass.new(root: Rails.root, session: @source_inputs, units: units,
|
|
123
|
+
extractor_keys: self.class::TYPE_TO_EXTRACTOR_KEY, baseline: @source_reference_baseline,
|
|
124
|
+
refreshed: @source_reference_refreshed || [], full: full).call
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def rewrite_source_reference_edges(record, edges, affected_types)
|
|
128
|
+
data = record.fetch(:data)
|
|
129
|
+
type = data.fetch('type').to_sym
|
|
130
|
+
identifier = data.fetch('identifier')
|
|
131
|
+
node = @dependency_graph.node(identifier, type: type)
|
|
132
|
+
unit = reference_unit(data, node, edges)
|
|
133
|
+
mark_dependents_dirty(identifier)
|
|
134
|
+
@dependency_graph.register(unit)
|
|
135
|
+
@dependency_graph.annotate(identifier, type: type, **DependencyGraph.persisted_node_attributes(node))
|
|
136
|
+
mark_dependents_dirty(identifier)
|
|
137
|
+
data['dependencies'] = edges
|
|
138
|
+
AtomicFile.write(record.fetch(:path), json_serialize(data), durable: payload_writes_durable?)
|
|
139
|
+
affected_types.add(record.fetch(:extractor_key))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def reference_unit(data, node, edges)
|
|
143
|
+
unit = ExtractedUnit.new(type: data.fetch('type').to_sym, identifier: data.fetch('identifier'),
|
|
144
|
+
file_path: node[:file_path])
|
|
145
|
+
unit.namespace = node[:namespace]
|
|
146
|
+
unit.metadata = data.fetch('metadata', {})
|
|
147
|
+
unit.dependencies = edges
|
|
148
|
+
unit
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def write_source_reference_cache(result)
|
|
152
|
+
Cache.write(payload_dir.join(Cache::FILE_NAME), result.cache)
|
|
153
|
+
@source_reference_paths = result.paths.to_set
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The final source scan is already required by provenance publication.
|
|
157
|
+
# Reuse it to reject graph evidence whose captured inputs became unstable.
|
|
158
|
+
def verify_source_reference_publication!(manifest)
|
|
159
|
+
return if @source_reference_paths.nil? || @source_reference_paths.empty?
|
|
160
|
+
|
|
161
|
+
errors = manifest.data.fetch('errors')
|
|
162
|
+
unstable = errors.select do |error|
|
|
163
|
+
%w[source_changed_during_extraction source_changed_during_read source_file_unreadable
|
|
164
|
+
source_tree_unavailable external_source_path nonregular_source unverified_symlink_directory
|
|
165
|
+
undecodable_source_path
|
|
166
|
+
scan_time_budget scan_file_budget scan_byte_budget].include?(error['reason'])
|
|
167
|
+
end
|
|
168
|
+
return if unstable.empty?
|
|
169
|
+
|
|
170
|
+
raise Woods::ExtractionError,
|
|
171
|
+
'Source-reference source changed or could not be verified before publication ' \
|
|
172
|
+
"(#{source_verification_details(unstable)})"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def source_verification_details(errors)
|
|
176
|
+
details = errors.first(3).map do |error|
|
|
177
|
+
path = error['path']
|
|
178
|
+
path ? "#{error['reason']}: #{SourcePathEncoding.diagnostic(path)}" : error['reason']
|
|
179
|
+
end
|
|
180
|
+
details << "#{errors.size - 3} more source verification errors" if errors.size > 3
|
|
181
|
+
details.join('; ')
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'registry'
|
|
4
|
+
require_relative '../source_inputs/stable_reader'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module SourceReferences
|
|
8
|
+
# Collects original, captured sources once per path and reuses only candidates
|
|
9
|
+
# whose keyed identity still matches. Reads never advance runtime provenance.
|
|
10
|
+
class Inputs
|
|
11
|
+
MAX_SOURCE_BYTES = 256 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
# @param root [String] application root
|
|
14
|
+
# @param session [SourceInputs::Session] per-run capture and consumer ledger
|
|
15
|
+
# @param collector [Collector] source-only parser
|
|
16
|
+
def initialize(root:, session:, collector:)
|
|
17
|
+
@root = File.expand_path(root.to_s)
|
|
18
|
+
@session = session
|
|
19
|
+
@collector = collector
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param units [Array<Hash>] complete typed unit set
|
|
23
|
+
# @param baseline [Hash, nil] validated prior cache
|
|
24
|
+
# @param fresh [Proc] true only for genuinely re-extracted units
|
|
25
|
+
# @param extractor_keys [Hash{Symbol => Symbol}] consumer mapping
|
|
26
|
+
# @return [Hash] relative paths to identity and candidate evidence
|
|
27
|
+
def call(units:, baseline:, fresh:, extractor_keys:)
|
|
28
|
+
@bytes = 0
|
|
29
|
+
@baseline = baseline
|
|
30
|
+
@trusted_baseline = @session.respond_to?(:trusted_reference_baseline?) &&
|
|
31
|
+
@session.trusted_reference_baseline?(baseline)
|
|
32
|
+
@baseline_owners = Array(baseline&.fetch('owners')).to_h do |owner|
|
|
33
|
+
[owner.values_at('type', 'identifier'), owner]
|
|
34
|
+
end
|
|
35
|
+
grouped = group_sources(units)
|
|
36
|
+
grouped.keys.sort.each_with_object({}) do |path, files|
|
|
37
|
+
identity = captured_identity(path)
|
|
38
|
+
next unless identity
|
|
39
|
+
|
|
40
|
+
grouped.fetch(path).each { |unit| verify_consumption(unit, path, fresh, extractor_keys) }
|
|
41
|
+
files[path] = analysis(path, identity)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param path [String, nil] unit source path
|
|
46
|
+
# @return [String, nil] application-relative path, or nil outside the root
|
|
47
|
+
def relative(path)
|
|
48
|
+
return if path.nil? || path.to_s.empty?
|
|
49
|
+
|
|
50
|
+
absolute = File.expand_path(path.to_s, @root)
|
|
51
|
+
absolute.delete_prefix("#{@root}/") if absolute.start_with?("#{@root}/")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def group_sources(units)
|
|
57
|
+
grouped = Hash.new { |hash, path| hash[path] = [] }
|
|
58
|
+
units.select { |unit| eligible?(unit) }.each do |unit|
|
|
59
|
+
SourceContributors.paths(unit).each do |source_path|
|
|
60
|
+
path = relative(source_path)
|
|
61
|
+
raise Woods::ExtractionError, 'Invalid source-reference contributor path' unless eligible_path?(path)
|
|
62
|
+
|
|
63
|
+
grouped[path] << unit
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
grouped
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def eligible?(unit)
|
|
70
|
+
path = relative(unit['file_path'])
|
|
71
|
+
Registry::TYPES.include?(unit['type']&.to_sym) && eligible_path?(path) &&
|
|
72
|
+
RuntimeLookup::CONSTANT.match?(unit['identifier'].to_s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def eligible_path?(path)
|
|
76
|
+
path&.end_with?('.rb') && path.match?(%r{\A(?:app|lib)/}) &&
|
|
77
|
+
path.split('/').none? { |part| %w[vendor node_modules assets].include?(part) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def captured_identity(path)
|
|
81
|
+
identity = @session.source_identity(path)
|
|
82
|
+
absolute = File.join(@root, path)
|
|
83
|
+
if identity.nil? && (File.exist?(absolute) || File.symlink?(absolute))
|
|
84
|
+
raise Woods::ExtractionError, "Source-reference input was not captured: #{path}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
identity
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def analysis(path, identity)
|
|
91
|
+
prior = @baseline&.fetch('files')&.[](path)
|
|
92
|
+
return prior if prior && prior['identity'] == identity
|
|
93
|
+
|
|
94
|
+
source = stable_source(path)
|
|
95
|
+
@bytes += source.fetch('source').bytesize
|
|
96
|
+
raise Woods::ExtractionError, 'Source-reference parse byte budget exceeded' if @bytes > MAX_SOURCE_BYTES
|
|
97
|
+
|
|
98
|
+
result = @collector.call(source.fetch('source'))
|
|
99
|
+
raise Woods::ExtractionError, "Could not parse source references in #{path}" if result['parse_error']
|
|
100
|
+
|
|
101
|
+
{ 'identity' => source.fetch('identity'), 'analysis' => result }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def stable_source(path)
|
|
105
|
+
@session.read_source(path)
|
|
106
|
+
rescue SourceInputs::StableReader::Error => e
|
|
107
|
+
raise Woods::ExtractionError, "Could not verify source references in #{path}: #{e.reason}. " \
|
|
108
|
+
'Retry the complete extraction batch in a fresh process against stable source; ' \
|
|
109
|
+
'the previous published generation remains active.'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def retained_owner?(unit, path)
|
|
113
|
+
return false unless @trusted_baseline
|
|
114
|
+
|
|
115
|
+
prior = @baseline_owners[unit.values_at('type', 'identifier')]
|
|
116
|
+
return false unless prior
|
|
117
|
+
|
|
118
|
+
paths = SourceContributors.paths(unit).map { |source| relative(source) }
|
|
119
|
+
paths.include?(path) && (prior['file_paths'] || [prior['file_path']]) == paths
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def verify_consumption(unit, path, fresh, extractor_keys)
|
|
123
|
+
return if fresh.call(unit)
|
|
124
|
+
|
|
125
|
+
consumer = extractor_keys[unit['type'].to_sym]
|
|
126
|
+
unchanged = @baseline&.dig('files', path, 'identity') == @session.source_identity(path)
|
|
127
|
+
return if unchanged && consumer && (@session.consumed_source?(consumer, path) || retained_owner?(unit, path))
|
|
128
|
+
|
|
129
|
+
raise RebuildRequired, "Source-reference baseline needs a full extraction: unverified source #{path}. " \
|
|
130
|
+
'Run bin/rails woods:extract; the previous published generation remains active.'
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module SourceReferences
|
|
5
|
+
# Optional parser-gem backend with the same ownership rules as Prism.
|
|
6
|
+
class ParserAdapter
|
|
7
|
+
KINDS = { class: :declaration, module: :declaration, const: :constant, sclass: :singleton,
|
|
8
|
+
def: :method, defs: :method }.freeze
|
|
9
|
+
DYNAMIC_BLOCK_METHODS = %i[class_eval module_eval instance_eval class_exec module_exec instance_exec].freeze
|
|
10
|
+
|
|
11
|
+
def parse(source)
|
|
12
|
+
require 'parser/current' unless defined?(::Parser::CurrentRuby)
|
|
13
|
+
parser = ::Parser::CurrentRuby.default_parser
|
|
14
|
+
diagnostics = []
|
|
15
|
+
parser.diagnostics.consumer = ->(diagnostic) { diagnostics << diagnostic }
|
|
16
|
+
parser.diagnostics.all_errors_are_fatal = true
|
|
17
|
+
parser.diagnostics.ignore_warnings = true
|
|
18
|
+
# Ruby source defaults to UTF-8 even when read as untouched binary bytes.
|
|
19
|
+
# Source::Buffer still honors an explicit encoding magic comment.
|
|
20
|
+
input = source.encoding == Encoding::BINARY ? source.dup.force_encoding(Encoding::UTF_8) : source
|
|
21
|
+
buffer = ::Parser::Source::Buffer.new('(source references)', source: input)
|
|
22
|
+
[parser.parse(buffer), nil]
|
|
23
|
+
rescue ::Parser::SyntaxError => e
|
|
24
|
+
diagnostic = diagnostics.last
|
|
25
|
+
[nil, { 'message' => e.message, 'line' => diagnostic&.location&.line || 1 }]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def kind(node)
|
|
29
|
+
return KINDS[node.type] if KINDS.key?(node.type)
|
|
30
|
+
|
|
31
|
+
case node.type
|
|
32
|
+
when :casgn then value_constructor(node) ? :value_class : :other
|
|
33
|
+
when :block, :numblock then dynamic_scope?(node) ? :dynamic_scope : :other
|
|
34
|
+
else :other
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def constant_name(node)
|
|
39
|
+
return unless node
|
|
40
|
+
return '' if node.type == :cbase
|
|
41
|
+
return unless node.type == :const
|
|
42
|
+
|
|
43
|
+
parent, name = node.children
|
|
44
|
+
prefix = parent ? constant_name(parent) : nil
|
|
45
|
+
return if parent && !prefix
|
|
46
|
+
|
|
47
|
+
[prefix, name.to_s].compact.join('::')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def children(node)
|
|
51
|
+
nodes = case node.type
|
|
52
|
+
when :casgn then [node.children[2]]
|
|
53
|
+
when :masgn then [node.children[1]]
|
|
54
|
+
when :defs then node.children.drop(2)
|
|
55
|
+
else node.children
|
|
56
|
+
end
|
|
57
|
+
nodes.grep(::Parser::AST::Node)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def value_constructor(node)
|
|
61
|
+
call = node.children[2]
|
|
62
|
+
return unless call
|
|
63
|
+
|
|
64
|
+
call = call.children.first if %i[block numblock].include?(call.type)
|
|
65
|
+
return unless call.type == :send
|
|
66
|
+
|
|
67
|
+
receiver, method = call.children
|
|
68
|
+
name = constant_name(receiver)
|
|
69
|
+
name if { new: %w[Struct ::Struct], define: %w[Data ::Data] }.fetch(method, []).include?(name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def assignment_name(node)
|
|
73
|
+
parent, name = node.children
|
|
74
|
+
prefix = parent ? constant_name(parent) : nil
|
|
75
|
+
return if parent && !prefix
|
|
76
|
+
|
|
77
|
+
[prefix, name.to_s].compact.join('::')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def declaration_name(node)
|
|
81
|
+
constant_name(node.children.first)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def declaration_kind(node)
|
|
85
|
+
node.type.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def superclass(node)
|
|
89
|
+
node.children[1] if node.type == :class
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def body(node)
|
|
93
|
+
node.children.last
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self_receiver?(node)
|
|
97
|
+
node.children.first.type == :self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def local_method?(node)
|
|
101
|
+
node.type == :def || node.children.first.type == :self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def line(node)
|
|
105
|
+
node.location.expression.line
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def end_line(node)
|
|
109
|
+
node.location.expression.last_line
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def dynamic_scope?(node)
|
|
115
|
+
call = node.children.first
|
|
116
|
+
return false unless %i[send csend].include?(call.type)
|
|
117
|
+
|
|
118
|
+
receiver, method = call.children
|
|
119
|
+
return true if DYNAMIC_BLOCK_METHODS.include?(method)
|
|
120
|
+
|
|
121
|
+
foreign_method?(method, receiver) || anonymous_class?(method, receiver)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def foreign_method?(method, receiver)
|
|
125
|
+
%i[define_method define_singleton_method].include?(method) && receiver && receiver.type != :self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def anonymous_class?(method, receiver)
|
|
129
|
+
name = constant_name(receiver)&.delete_prefix('::')
|
|
130
|
+
{ new: %w[Class Module Struct], define: ['Data'] }.fetch(method, []).include?(name)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -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
|