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,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