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
@@ -56,6 +56,8 @@ module Woods
56
56
  # @param klass [Class] A channel subclass
57
57
  # @return [ExtractedUnit, nil]
58
58
  def extract_channel(klass)
59
+ return nil unless app_channel?(klass)
60
+
59
61
  name = klass.name
60
62
  file_path = source_file_for(klass, name)
61
63
  source = read_source(file_path)
@@ -91,9 +93,12 @@ module Woods
91
93
  #
92
94
  # @return [Array<Class>]
93
95
  def channel_descendants
94
- ActionCable::Channel::Base.descendants.reject do |klass|
95
- klass.name.nil? || klass.name == 'ApplicationCable::Channel'
96
- end
96
+ ActionCable::Channel::Base.descendants.select { |klass| app_channel?(klass) }
97
+ end
98
+
99
+ def app_channel?(klass)
100
+ action_cable_available? && klass.name && klass.name != 'ApplicationCable::Channel' &&
101
+ klass < ActionCable::Channel::Base && app_source_file?(source_file_for(klass, klass.name))
97
102
  end
98
103
 
99
104
  # Locate the source file for a channel class.
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../source_references/value_class'
4
+
5
+ module Woods
6
+ module Extractors
7
+ # Selects the source-owned value class instead of its reopening namespace.
8
+ class AssignedValueDiscovery
9
+ CORE_METHODS = Module.instance_method(:instance_methods)
10
+ CORE_PRIVATE_METHODS = Module.instance_method(:private_instance_methods)
11
+
12
+ # @param file_path [String] original Ruby file
13
+ # @param analysis [Hash] original-source Collector result
14
+ # @param expected [String, nil] active loader's expected constant identity
15
+ # @param preserve_modules [Boolean] retain a canonical library module primary
16
+ # @return [String, nil] verified assigned child, or nil to preserve ordinary discovery
17
+ def call(file_path, analysis:, expected: nil, preserve_modules: false)
18
+ return if analysis['parse_error']
19
+
20
+ verifier = SourceReferences::ValueClass.new
21
+ declarations = analysis.fetch('declarations')
22
+ candidates = declarations.filter_map { |record| verifier.call(record, file_path: file_path) }
23
+ return expected if candidates.include?(expected)
24
+ return if candidates.empty? || owns_primary?(declarations, file_path, expected, preserve_modules)
25
+
26
+ candidates.first
27
+ end
28
+
29
+ private
30
+
31
+ def owns_primary?(declarations, file_path, expected, preserve_modules)
32
+ lookup = SourceReferences::RuntimeLookup.new
33
+ declarations.any? do |record|
34
+ next false if record['constructor'] || record.fetch('singleton_depth', 0).positive?
35
+ next false unless canonical_file?(record['owner'], file_path, lookup)
36
+ next true if record['kind'] == 'class'
37
+ next false unless preserve_modules
38
+
39
+ record['owner'] == expected || callable_module?(record['owner'], file_path, lookup)
40
+ end
41
+ end
42
+
43
+ def canonical_file?(identifier, file_path, lookup)
44
+ parts = identifier.split('::')
45
+ name = parts.pop
46
+ scope = parts.empty? ? Object : lookup.call("::#{parts.join('::')}", allow_private: true)[:value]
47
+ return false unless lookup.module_object?(scope)
48
+
49
+ location = SourceReferences::ValueClass::SOURCE_LOCATION.bind(scope).call(name, false)
50
+ location&.first && File.expand_path(location.first) == File.expand_path(file_path)
51
+ end
52
+
53
+ def callable_module?(identifier, path, lookup)
54
+ value = lookup.call("::#{identifier}", allow_private: true)[:value]
55
+ return false unless lookup.module_object?(value)
56
+
57
+ singleton = SourceReferences::ValueClass::SINGLETON_CLASS.bind(value).call
58
+ [value, singleton].any? { |scope| own_method_at?(scope, path) }
59
+ end
60
+
61
+ def own_method_at?(scope, path)
62
+ names = CORE_METHODS.bind(scope).call(false) + CORE_PRIVATE_METHODS.bind(scope).call(false)
63
+ names.any? do |name|
64
+ method = SourceReferences::ValueClass::INSTANCE_METHOD.bind(scope).call(name)
65
+ owner = SourceReferences::ValueClass::METHOD_OWNER.bind(method).call
66
+ next false unless SourceReferences::RuntimeLookup::CORE_EQUAL.bind(owner).call(scope)
67
+
68
+ location = SourceReferences::ValueClass::METHOD_SOURCE.bind(method).call
69
+ location && File.expand_path(location.first) == File.expand_path(path)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+ require_relative '../source_references/runtime_lookup'
5
+
6
+ module Woods
7
+ module Extractors
8
+ # Structural declaration evidence without loading or evaluating source.
9
+ module ClassDeclarations
10
+ class Unresolved < StandardError; end
11
+
12
+ module_function
13
+
14
+ # @param source [String] original Ruby source
15
+ # @return [Array<Hash>] class identities, lexical scopes and syntax nodes
16
+ def read(source)
17
+ parsed = Prism.parse(source)
18
+ return [] unless parsed.success?
19
+
20
+ Collector.new.call(parsed.value)
21
+ end
22
+
23
+ # @param declaration [Hash] record returned by .read
24
+ # @return [String, nil] constant-path parent, including Migration[version]
25
+ def parent_name(declaration)
26
+ parent = declaration.fetch(:node).superclass
27
+ parent = parent.receiver if parent.is_a?(Prism::CallNode) && parent.name == :[]
28
+ constant_name(parent)
29
+ end
30
+
31
+ # @param node [Prism::Node, nil] constant syntax
32
+ # @return [String, nil] written constant path, preserving leading ::
33
+ def constant_name(node)
34
+ case node
35
+ when Prism::ConstantReadNode then node.name.to_s
36
+ when Prism::ConstantPathNode
37
+ parent = node.parent ? constant_name(node.parent) : ''
38
+ "#{parent}::#{node.name}" if parent
39
+ end
40
+ end
41
+
42
+ # Qualified declaration receivers follow constant lookup; they do not
43
+ # automatically belong to the innermost syntactic namespace.
44
+ class Collector
45
+ def initialize
46
+ @lookup = SourceReferences::RuntimeLookup.new
47
+ @namespaces = {}
48
+ end
49
+
50
+ def call(node, nesting = [], result = [])
51
+ return result unless node
52
+ return result if node.is_a?(Prism::SingletonClassNode) || node.is_a?(Prism::DefNode)
53
+
54
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
55
+ name = ClassDeclarations.constant_name(node.constant_path)
56
+ return result unless name
57
+
58
+ identifier = declaration_identity(name, nesting)
59
+ @namespaces[identifier] = true
60
+ if node.is_a?(Prism::ClassNode)
61
+ result << { identifier: identifier, nesting: nesting, node: node, source: node.location.slice }
62
+ end
63
+ call(node.body, [identifier, *nesting], result)
64
+ else
65
+ node.compact_child_nodes.each { |child| call(child, nesting, result) }
66
+ end
67
+ result
68
+ end
69
+
70
+ private
71
+
72
+ def declaration_identity(name, nesting)
73
+ parts = name.delete_prefix('::').split('::')
74
+ return [name.start_with?('::') ? nil : nesting.first, parts.first].compact.join('::') if parts.one?
75
+
76
+ leaf = parts.pop
77
+ receiver = if name.start_with?('::')
78
+ namespace(parts.shift)
79
+ else
80
+ lexical_namespace(parts.shift, nesting)
81
+ end
82
+ parts.each { |part| receiver = namespace("#{receiver}::#{part}") }
83
+ "#{receiver}::#{leaf}"
84
+ end
85
+
86
+ def lexical_namespace(name, nesting)
87
+ nesting.each do |scope|
88
+ candidate = "#{scope}::#{name}"
89
+ return namespace(candidate) if @namespaces[candidate]
90
+
91
+ result = @lookup.call("::#{scope}", allow_private: true)
92
+ unresolved!(candidate) unless result[:status] == :resolved
93
+ next unless @lookup.reflect(result[:value], :const_defined?, name, false)
94
+
95
+ return namespace(candidate)
96
+ end
97
+
98
+ # Only after all lexical constant tables can be checked may inherited
99
+ # or root constants supply a receiver. An unloaded scope is unknown.
100
+ result = @lookup.call(name, nesting: nesting, allow_private: true)
101
+ return result[:target] if result[:status] == :resolved
102
+ return namespace(name) if result[:status] == :missing && result[:root_lookup]
103
+
104
+ unresolved!(name)
105
+ end
106
+
107
+ def namespace(name)
108
+ result = @lookup.call("::#{name}", allow_private: true)
109
+ return result[:target] if result[:status] == :resolved
110
+ return name if @namespaces[name] && result[:status] == :missing
111
+
112
+ unresolved!(name)
113
+ end
114
+
115
+ def unresolved!(name)
116
+ raise Unresolved, "Unresolved qualified declaration namespace: #{name}"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -42,9 +42,7 @@ module Woods
42
42
  extract_configuration_file(file)
43
43
  end
44
44
 
45
- profiler = BehavioralProfile.new
46
- profile = profiler.extract
47
- SourceInputs::ConsumerErrors.record(self) if SourceInputs::ConsumerErrors.failed?(profiler)
45
+ profile = extract_behavioral_profile
48
46
  units << profile if profile
49
47
 
50
48
  units
@@ -53,6 +51,16 @@ module Woods
53
51
  units || []
54
52
  end
55
53
 
54
+ # Re-derive the synthetic unit from resolved runtime values, never from
55
+ # its nominal config/application.rb source path.
56
+ # @return [ExtractedUnit, nil]
57
+ def extract_behavioral_profile
58
+ profiler = BehavioralProfile.new
59
+ profile = profiler.extract
60
+ SourceInputs::ConsumerErrors.record(self) if SourceInputs::ConsumerErrors.failed?(profiler)
61
+ profile
62
+ end
63
+
56
64
  # Extract a single configuration file
57
65
  #
58
66
  # @param file_path [String] Path to the configuration file
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'class_declarations'
4
+ require_relative '../source_references/runtime_lookup'
5
+
6
+ module Woods
7
+ module Extractors
8
+ # Runtime qualification belongs to the selected class and its actual source.
9
+ # An unavailable class may use limited declaration evidence; a foreign one
10
+ # must never lend its ancestry to a same-named file.
11
+ class DeclarationAncestry
12
+ SOURCE_LOCATION = Module.instance_method(:const_source_location)
13
+
14
+ # @param source [String] original source, never evaluated
15
+ # @param identifier [String] selected extraction identity
16
+ # @param file_path [String] source file owning the unit
17
+ def initialize(source:, identifier:, file_path:)
18
+ @lookup = SourceReferences::RuntimeLookup.new
19
+ @declaration = declaration(source, identifier)
20
+ result = @lookup.call("::#{identifier}", allow_private: true)
21
+ @runtime = result[:value] if result[:status] == :resolved
22
+ @foreign = if @runtime
23
+ !owned_class?(identifier, file_path)
24
+ else
25
+ result[:status] == :unknown && !%w[autoload_pending unloaded_scope].include?(result[:reason])
26
+ end
27
+ @names = ancestry_names(@runtime) if @runtime && !@foreign
28
+ end
29
+
30
+ # @return [Boolean] whether this file can qualify as a delegator
31
+ def manager?
32
+ return false if @foreign
33
+ return @names.include?('Delegator') if @names
34
+
35
+ %w[SimpleDelegator Delegator].include?(parent_name&.delete_prefix('::')) || delegate_class? ||
36
+ @declaration&.fetch(:source)&.match?(/include\s+(?:::)?Delegator\b/) || false
37
+ end
38
+
39
+ # @return [Symbol] supported delegation metadata, without running a factory
40
+ def delegation_type
41
+ return :simple_delegator if @names&.include?('SimpleDelegator')
42
+ return :simple_delegator if !@names && parent_name&.delete_prefix('::') == 'SimpleDelegator'
43
+ return :delegate_class if delegate_class?
44
+
45
+ :unknown
46
+ end
47
+
48
+ # @return [Boolean] whether a loaded class belongs to another file
49
+ def foreign?
50
+ !!@foreign
51
+ end
52
+
53
+ # @return [Boolean] conventional ApplicationPolicy ancestry
54
+ def application_policy?
55
+ return false if @foreign
56
+ return @names.drop(1).any? { |name| name.split('::').last == 'ApplicationPolicy' } if @names
57
+
58
+ parent_name&.split('::')&.last == 'ApplicationPolicy'
59
+ end
60
+
61
+ private
62
+
63
+ def declaration(source, identifier)
64
+ ClassDeclarations.read(source).find { |record| record[:identifier] == identifier }
65
+ rescue ClassDeclarations::Unresolved
66
+ # Missing static evidence must not suppress independently owned runtime
67
+ # ancestry or change the generic policy extractor's emission policy.
68
+ nil
69
+ end
70
+
71
+ def parent_name
72
+ ClassDeclarations.parent_name(@declaration) if @declaration
73
+ end
74
+
75
+ def delegate_class?
76
+ parent = @declaration&.fetch(:node)&.superclass
77
+ parent.is_a?(Prism::CallNode) && parent.name == :DelegateClass && parent.receiver.nil?
78
+ end
79
+
80
+ def owned_class?(identifier, path)
81
+ return false unless @lookup.class_object?(@runtime)
82
+
83
+ location = SOURCE_LOCATION.bind(Object).call(identifier, false)
84
+ location && File.expand_path(location.first) == File.expand_path(path)
85
+ end
86
+
87
+ def ancestry_names(klass)
88
+ @lookup.reflect(klass, :ancestors).filter_map { |ancestor| @lookup.reflect(ancestor, :name) }
89
+ end
90
+ end
91
+ end
92
+ end
@@ -187,6 +187,14 @@ module Woods
187
187
  all_paths = (data[:publishers] + data[:subscribers]).uniq
188
188
  combined_source = load_source_files(all_paths)
189
189
 
190
+ # Keep absolute paths for source reads; emitted paths must not make
191
+ # metadata or annotation hashes depend on the checkout directory.
192
+ prefix = File.join(Rails.root.to_s, '')
193
+ data = data.merge(
194
+ publishers: data[:publishers].map { |path| path.delete_prefix(prefix) },
195
+ subscribers: data[:subscribers].map { |path| path.delete_prefix(prefix) }
196
+ )
197
+
190
198
  unit = ExtractedUnit.new(
191
199
  type: :event,
192
200
  identifier: event_name,