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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'woods/atomic_file'
4
+ require 'woods/change_set'
4
5
  require 'woods/generation'
5
6
  require 'woods/git_command'
6
7
 
@@ -9,6 +10,16 @@ module Woods
9
10
  module RakeHelpers # rubocop:disable Metrics/ModuleLength -- existing task helpers grouped without changing behavior
10
11
  module_function
11
12
 
13
+ # @return [Float, nil] blank values leave the raw task resident
14
+ def woods_watch_idle_timeout
15
+ value = ENV['WOODS_WATCH_IDLE_TIMEOUT'].to_s.strip
16
+ return if value.empty?
17
+
18
+ Float(value)
19
+ rescue ArgumentError, TypeError
20
+ raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be a number of seconds or blank'
21
+ end
22
+
12
23
  # ── Multi-instance helpers (#164 phase 4) ────────────────────────────────
13
24
  #
14
25
  # Worktrees are disjoint by construction (each has its own Rails.root and
@@ -59,6 +70,11 @@ module Woods
59
70
  end
60
71
 
61
72
  def woods_with_extraction_lock(output_dir, wait: nil, raise_on_timeout: false, &block)
73
+ require 'woods/source_inputs/handoff'
74
+ if ENV[Woods::SourceInputs::Handoff::ENV_KEY]
75
+ Woods::SourceInputs::Handoff.validate_output!(root: woods_task_root, output_dir: output_dir)
76
+ end
77
+
62
78
  # Requires first. The default wait reads a constant from the daemon, so
63
79
  # resolving it above these lines NameError'd every write task — the same
64
80
  # load-order bug as the missing require in `woods:watch`, reintroduced one
@@ -195,7 +211,8 @@ module Woods
195
211
  def woods_sweep_index_dir(output_dir, lock_name)
196
212
  preserved = [
197
213
  output_dir.join("#{lock_name}.lock"),
198
- output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name))
214
+ output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name)),
215
+ *['', '.lock', '.lease'].map { |suffix| output_dir.join("#{Woods::Watch::ClaimLease::CLAIM}#{suffix}") }
199
216
  ]
200
217
  output_dir.children.each do |entry|
201
218
  FileUtils.rm_rf(entry) unless preserved.include?(entry)
@@ -235,8 +252,9 @@ module Woods
235
252
  # naming both.
236
253
  #
237
254
  # The diff is rooted at the extracted application (`git -C Rails.root`),
238
- # consistent with {Woods::GitProvenance} (#262): extraction launched from
239
- # another checkout must not diff that checkout's history.
255
+ # consistent with {Woods::GitProvenance} (#262). Explicit `--relative` and
256
+ # the current-directory pathspec keep a nested application's paths relative
257
+ # to Rails.root and exclude sibling applications in the same repository.
240
258
  #
241
259
  # The child status is carried out, not discarded (M1): `Open3.capture2`
242
260
  # turned an unresolvable range — a GitLab zero-SHA, an unfetched GitHub base
@@ -245,7 +263,7 @@ module Woods
245
263
  # never ran.
246
264
  #
247
265
  # @param range [String] a git diff range/revision expression
248
- # @param root [Pathname, String] repository root the diff runs against
266
+ # @param root [Pathname, String] application root the diff runs against
249
267
  # @return [Array(Array<String>, String, nil)] the changed paths (both halves
250
268
  # of any rename included), or nil paths plus a human-readable failure when
251
269
  # the range could not be resolved
@@ -254,7 +272,7 @@ module Woods
254
272
  output, error, status = Open3.capture3(
255
273
  *Woods::GitCommand.argv(
256
274
  root, '-c', 'core.quotePath=false',
257
- 'diff', '--name-status', '-z', '--no-renames', range
275
+ 'diff', '--relative', '--name-status', '-z', '--no-renames', range, '--', '.'
258
276
  )
259
277
  )
260
278
  return [woods_parse_git_diff_name_status(output), nil] if status.success?
@@ -289,11 +307,11 @@ module Woods
289
307
  # @return [Array<String>] changed paths
290
308
  def woods_incremental_changed_paths(output_dir)
291
309
  explicit = ENV.fetch('CHANGED_FILES', nil)
292
- return explicit.split(',').map(&:strip) if explicit
310
+ return woods_normalize_incremental_paths(explicit.split(',')) if explicit
293
311
 
294
312
  range = woods_incremental_range
295
313
  changed_files, failure = woods_changed_paths_for_range(range)
296
- return changed_files unless failure
314
+ return woods_normalize_incremental_paths(changed_files) unless failure
297
315
 
298
316
  if woods_daemon_coverage(output_dir) == :running
299
317
  puts "Could not resolve the git diff range #{range.inspect} (#{failure})."
@@ -309,17 +327,31 @@ module Woods
309
327
  exit 1
310
328
  end
311
329
 
330
+ # Normalize before the task filters against application-relative rules.
331
+ # ChangeSet retains outside-root paths as absolute; exclude those here so
332
+ # traversal spellings cannot reach extraction as application source. This
333
+ # is lexical containment, preserving deleted files and symlink spellings.
334
+ #
335
+ # @param paths [Array<String>] absolute or application-relative paths
336
+ # @return [Array<String>] unique, contained application-relative paths
337
+ def woods_normalize_incremental_paths(paths)
338
+ ChangeSet.new(paths: paths, root: Rails.root).relative_paths.reject { |path| Pathname.new(path).absolute? }
339
+ end
340
+
312
341
  # The git range `woods:incremental` diffs, from the CI environment or the
313
342
  # last-commit default.
314
343
  #
315
344
  # @return [String]
316
345
  def woods_incremental_range
317
- if ENV['CI_COMMIT_BEFORE_SHA']
346
+ before = ENV['CI_COMMIT_BEFORE_SHA'].to_s.strip
347
+ current = ENV['CI_COMMIT_SHA'].to_s.strip
348
+ base = ENV['GITHUB_BASE_REF'].to_s.strip
349
+ if !before.empty?
318
350
  # GitLab CI
319
- "#{ENV['CI_COMMIT_BEFORE_SHA']}..#{ENV.fetch('CI_COMMIT_SHA', nil)}"
320
- elsif ENV['GITHUB_BASE_REF']
351
+ "#{before}..#{current.empty? ? 'HEAD' : current}"
352
+ elsif !base.empty?
321
353
  # GitHub Actions PR
322
- "origin/#{ENV['GITHUB_BASE_REF']}...HEAD"
354
+ "origin/#{base}...HEAD"
323
355
  else
324
356
  # Default: changes since last commit
325
357
  'HEAD~1'
data/lib/woods/release.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Woods
4
4
  # Maintainer-only release machinery, deliberately not packaged with the gem.
5
- # These files back `release:prepare` and `release:reopen`, which only ever run
5
+ # These files back `release:prepare`, `release:reopen`, and `release:retarget`, which run
6
6
  # from a source checkout of this repository.
7
7
  module Release
8
8
  # Every refusal the release flow raises. `lib/tasks/release.rake` rescues
@@ -132,19 +132,24 @@ module Woods
132
132
 
133
133
  warn_unresolvable_paths(warnings, unresolvable)
134
134
  validate_dependency_graph(payload, errors)
135
- validate_source_inputs(payload, errors)
135
+ validate_source_inputs(payload, errors, warnings)
136
136
  end
137
137
 
138
138
  # Optional for old generations; malformed new provenance is an artifact
139
139
  # integrity error. Source drift itself is advisory and belongs to status.
140
- def validate_source_inputs(payload, errors)
140
+ def validate_source_inputs(payload, errors, warnings)
141
141
  path = File.join(payload, SourceInputs::Manifest::FILE_NAME)
142
142
  return unless File.exist?(path)
143
143
 
144
144
  File.open(path, File::RDONLY | File::NONBLOCK) do |file|
145
145
  raise SourceInputs::Manifest::Invalid unless file.stat.file?
146
146
 
147
- SourceInputs::Manifest.parse(file.read(SourceInputs::Manifest::MAX_BYTES + 1))
147
+ manifest = SourceInputs::Manifest.parse(file.read(SourceInputs::Manifest::MAX_BYTES + 1))
148
+ if manifest.unavailable?
149
+ evidence = manifest.data.fetch('unavailable')
150
+ warnings << "Source freshness unavailable: #{evidence['reason']} (#{evidence['size_bytes']} bytes, " \
151
+ "limit #{evidence['limit_bytes']}); the code index remains usable"
152
+ end
148
153
  end
149
154
  rescue SourceInputs::Manifest::Invalid, SystemCallError, IOError
150
155
  errors << 'Invalid source_inputs.json provenance artifact'
@@ -81,6 +81,23 @@ module Woods
81
81
  @provider.model_name
82
82
  end
83
83
 
84
+ # Optional pure configuration contracts; neither may probe the provider.
85
+ def cache_identity
86
+ @provider.cache_identity if @provider.respond_to?(:cache_identity)
87
+ end
88
+
89
+ def input_budget
90
+ @provider.input_budget if @provider.respond_to?(:input_budget)
91
+ end
92
+
93
+ def configured_dimensions
94
+ @provider.configured_dimensions if @provider.respond_to?(:configured_dimensions)
95
+ end
96
+
97
+ def requested_dimensions
98
+ @provider.requested_dimensions if @provider.respond_to?(:requested_dimensions)
99
+ end
100
+
84
101
  # Delegate the per-provider input cap. The retry wrapper does not
85
102
  # change the provider's budget, so just hand through whatever the
86
103
  # inner provider reports. `respond_to?` alone is the wrong guard
@@ -135,7 +152,7 @@ module Woods
135
152
  begin
136
153
  attempt += 1
137
154
  yield
138
- rescue CircuitOpenError, ArgumentError
155
+ rescue CircuitOpenError, ArgumentError, Embedding::InputLimitError
139
156
  raise
140
157
  rescue StandardError => e
141
158
  raise e if attempt > @max_retries || !retryable_error?(e)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'time'
5
+ require 'uri'
5
6
  require_relative 'mcp/errors'
6
7
 
7
8
  module Woods
@@ -29,6 +30,12 @@ module Woods
29
30
  # The only schema version this gem release can read or write.
30
31
  SCHEMA_VERSION_SUPPORTED = 1
31
32
 
33
+ BUILTIN_PROVIDERS = {
34
+ 'Woods::Embedding::Provider::OpenAI' => :openai,
35
+ 'Woods::Embedding::Provider::Ollama' => :ollama,
36
+ 'Woods::Embedding::Provider::Fake' => :fake
37
+ }.freeze
38
+
32
39
  # @return [Integer]
33
40
  attr_reader :schema_version
34
41
 
@@ -38,7 +45,7 @@ module Woods
38
45
  # @return [Time]
39
46
  attr_reader :created_at
40
47
 
41
- # @return [Hash] Provider details — :class, :model, :host, :num_ctx, :read_timeout, :dimension
48
+ # @return [Hash] Provider details, including observed :dimension and :requested_dimensions
42
49
  attr_reader :embedding_provider
43
50
 
44
51
  # @return [Hash] Store types — :vector_store, :metadata_store, :graph_store (Symbols)
@@ -74,9 +81,9 @@ module Woods
74
81
  # the model, not the config — and doesn't hurt OpenAI, whose provider
75
82
  # exposes the same +#dimensions+ interface.
76
83
  #
77
- # When +provider:+ is omitted, dimension falls back to
78
- # +config.embedding_options[:dimension]+ (useful for specs and for
79
- # offline ResolvedConfig construction where no provider exists).
84
+ # Known injected providers expose pure constructor settings, including a
85
+ # configured or already observed width, without probing. Otherwise an
86
+ # omitted +provider:+ uses the declared embedding options for offline capture.
80
87
  #
81
88
  # @param config [Woods::Configuration]
82
89
  # @param gem_version [String] Defaults to {Woods::VERSION}
@@ -87,7 +94,11 @@ module Woods
87
94
  require_relative 'version'
88
95
 
89
96
  opts = (config.embedding_options || {}).transform_keys(&:to_sym)
90
- declared_dim = opts[:dimensions] || opts[:dimension] || opts[:dims]
97
+ concrete = unwrap_provider(provider || config.embedding_provider)
98
+ if BUILTIN_PROVIDERS.key?(concrete.class.name) && concrete.respond_to?(:configuration_options)
99
+ opts = concrete.configuration_options
100
+ end
101
+ declared_dim = opts[:expected_dimensions] || opts[:dimensions] || opts[:dimension] || opts[:dims]
91
102
  dim = provider.respond_to?(:dimensions) ? provider.dimensions : declared_dim
92
103
  model = provider.respond_to?(:model_name) ? provider.model_name : (opts[:model] || config.embedding_model)
93
104
 
@@ -99,6 +110,11 @@ module Woods
99
110
  num_ctx: opts[:num_ctx],
100
111
  read_timeout: opts[:read_timeout]
101
112
  }.compact
113
+ provider_hash[:requested_dimensions] = if provider.respond_to?(:requested_dimensions)
114
+ provider.requested_dimensions
115
+ else
116
+ opts[:dimensions] || opts[:dimension]
117
+ end
102
118
 
103
119
  new(
104
120
  schema_version: SCHEMA_VERSION_SUPPORTED,
@@ -123,7 +139,7 @@ module Woods
123
139
  @schema_version = schema_version
124
140
  @gem_version = gem_version.to_s.freeze
125
141
  @created_at = created_at
126
- @embedding_provider = deep_freeze(embedding_provider)
142
+ @embedding_provider = deep_freeze(self.class.sanitize_provider(embedding_provider))
127
143
  @stores = deep_freeze(stores)
128
144
  @store_options = deep_freeze(store_options)
129
145
  freeze
@@ -206,6 +222,22 @@ module Woods
206
222
  to_snapshot_json.freeze
207
223
  end
208
224
 
225
+ # Only known built-ins with safely recorded settings can be reconstructed.
226
+ # An explicitly configured host provider may still use this snapshot.
227
+ def requires_host_provider?
228
+ embedding_provider[:requires_host_provider] == true
229
+ end
230
+
231
+ def self.sanitize_provider(provider)
232
+ result = provider.dup
233
+ result[:requires_host_provider] = true unless BUILTIN_PROVIDERS.key?(result[:class])
234
+ if result[:host] && !safe_provider_host?(result[:host])
235
+ result.delete(:host)
236
+ result[:requires_host_provider] = true
237
+ end
238
+ result
239
+ end
240
+
209
241
  private
210
242
 
211
243
  def stringify_nested_keys(value)
@@ -289,7 +321,7 @@ module Woods
289
321
 
290
322
  def parse_provider(raw)
291
323
  data = normalize_keys(raw)
292
- {
324
+ parsed = {
293
325
  class: data[:class].to_s,
294
326
  model: data[:model].to_s,
295
327
  dimension: data[:dimension].to_i,
@@ -297,6 +329,19 @@ module Woods
297
329
  num_ctx: data[:num_ctx],
298
330
  read_timeout: data[:read_timeout]
299
331
  }.compact
332
+ parsed[:requested_dimensions] = data[:requested_dimensions] if data.key?(:requested_dimensions)
333
+ parsed[:requires_host_provider] = true if data[:requires_host_provider] == true
334
+ parsed
335
+ end
336
+
337
+ # Paths and URL metadata can contain credentials. Keep only plain origins;
338
+ # do not strip a prefix/query and silently reconstruct a different service.
339
+ def safe_provider_host?(host)
340
+ uri = URI.parse(host)
341
+ uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment &&
342
+ ['', '/'].include?(uri.path)
343
+ rescue URI::InvalidURIError, ArgumentError, TypeError
344
+ false
300
345
  end
301
346
 
302
347
  def parse_stores(raw)
@@ -338,8 +383,23 @@ module Woods
338
383
  when String then provider
339
384
  when Class then provider.name
340
385
  when nil then ''
341
- else provider.to_s
386
+ else resolve_provider_object_class(provider)
387
+ end
388
+ end
389
+
390
+ def resolve_provider_object_class(provider)
391
+ unwrap_provider(provider).class.name || '(anonymous provider)'
392
+ end
393
+
394
+ def unwrap_provider(provider)
395
+ seen = {}.compare_by_identity
396
+ while %w[Woods::Resilience::RetryableProvider Woods::Cache::CachedEmbeddingProvider].include?(provider.class.name)
397
+ raise Woods::MCP::ConfigMismatch, 'Embedding provider wrappers contain a cycle' if seen[provider]
398
+
399
+ seen[provider] = true
400
+ provider = provider.provider
342
401
  end
402
+ provider
343
403
  end
344
404
  end
345
405
  end
@@ -342,7 +342,7 @@ module Woods
342
342
 
343
343
  def append_compact_candidate(parts, sources, candidate, unit, budget, tokens_used)
344
344
  header = "## #{unit_field(unit, :identifier)} (#{unit_field(unit, :type)})\n" \
345
- "File: #{unit_field(unit, :file_path)}\n\n"
345
+ "#{SourceContributors.label(unit)}\n\n"
346
346
  remaining = budget - tokens_used
347
347
  return tokens_used unless remaining.positive?
348
348
 
@@ -365,12 +365,11 @@ module Woods
365
365
  def format_unit(unit, _candidate)
366
366
  identifier = unit_field(unit, :identifier)
367
367
  type = unit_field(unit, :type)
368
- file_path = unit_field(unit, :file_path)
369
368
  source = unit_field(unit, :source_code) || ''
370
369
 
371
370
  <<~UNIT.strip
372
371
  ## #{identifier} (#{type})
373
- File: #{file_path}
372
+ #{SourceContributors.label(unit)}
374
373
 
375
374
  #{source}
376
375
  UNIT
@@ -386,6 +385,7 @@ module Woods
386
385
  score: candidate.score,
387
386
  file_path: unit_field(unit, :file_path)
388
387
  }
388
+ attribution.merge!(SourceContributors.attribution(unit))
389
389
  attribution[:truncated] = true if truncated
390
390
  attribution
391
391
  end
@@ -23,7 +23,7 @@ module Woods
23
23
  sources = []
24
24
  candidates.each do |candidate|
25
25
  unit = candidate.metadata
26
- header = "\n\n## #{unit['identifier']} (#{unit['type']})\nFile: #{unit['file_path']}\n" \
26
+ header = "\n\n## #{unit['identifier']} (#{unit['type']})\n#{SourceContributors.label(unit)}\n" \
27
27
  "Matched: #{candidate.matched_fields.join(', ')}\n\n"
28
28
  remaining = (budget * 4) - context.length - header.length
29
29
  next unless remaining.positive?
@@ -37,7 +37,7 @@ module Woods
37
37
  context += header + selected.text
38
38
  sources << { identifier: unit['identifier'], type: unit['type'], file_path: unit['file_path'],
39
39
  score: candidate.score, matched_fields: candidate.matched_fields,
40
- evidence: selected.provenance }
40
+ evidence: selected.provenance }.merge(SourceContributors.attribution(unit))
41
41
  next
42
42
  end
43
43
 
@@ -49,6 +49,7 @@ module Woods
49
49
  context += header + (truncated ? source[0, remaining - marker.length] + marker : source)
50
50
  sources << { identifier: unit['identifier'], type: unit['type'], file_path: unit['file_path'],
51
51
  score: candidate.score, matched_fields: candidate.matched_fields, truncated: truncated }
52
+ .merge(SourceContributors.attribution(unit))
52
53
  break if truncated
53
54
  end
54
55
  body = context[notice.length..].to_s
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'set'
5
5
  require_relative '../storage_identity'
6
+ require_relative '../source_contributors'
6
7
  require_relative '../storage/metadata_store'
7
8
 
8
9
  module Woods
@@ -83,13 +84,28 @@ module Woods
83
84
  def validate_packages!(records)
84
85
  names = records.filter_map { |unit| unit['identifier'] if unit['type'] == 'package' }
85
86
  names.concat(records.filter_map { |unit| unit.dig('metadata', 'package') })
87
+ names.concat(records.flat_map do |unit|
88
+ SourceContributors.records(unit).filter_map do |record|
89
+ record['package']
90
+ end
91
+ end)
86
92
  unknown = packages - names
87
93
  raise InvalidScopeError, "unknown package scope: #{unknown.join(', ')}" unless unknown.empty?
88
94
  end
89
95
 
90
96
  def eligible?(unit, types, excluded)
91
- return false unless packages.empty? || packages.include?(unit.dig('metadata', 'package'))
92
- return false unless source_paths.empty? || path_match?(unit['file_path'])
97
+ contributors = SourceContributors.records(unit)
98
+ owners = if contributors.empty?
99
+ [unit.dig('metadata', 'package')]
100
+ else
101
+ contributors.map do |record|
102
+ record['package']
103
+ end
104
+ end
105
+ return false unless packages.empty? || owners.all? { |owner| packages.include?(owner) }
106
+
107
+ paths = SourceContributors.paths(unit)
108
+ return false unless source_paths.empty? || (paths.any? && paths.all? { |path| path_match?(path) })
93
109
  return Array(types).map(&:to_s).include?(unit['type']) if types && !types.empty?
94
110
 
95
111
  !Array(excluded).map(&:to_s).include?(unit['type'])
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_contributors'
4
+
3
5
  require 'digest'
4
6
  require 'json'
5
7
  require_relative '../ast/parser'
@@ -170,7 +172,7 @@ module Woods
170
172
 
171
173
  def format_evidence(mode, selected, metadata, total)
172
174
  parts = ["Evidence: #{mode}; published-unit coordinates (physical location unavailable).",
173
- "Unit: #{field(:type)}:#{field(:identifier)}; path: #{field(:file_path)}",
175
+ "Unit: #{field(:type)}:#{field(:identifier)}; #{SourceContributors.label(@unit)}",
174
176
  "Source SHA256: #{Digest::SHA256.hexdigest(@source)}",
175
177
  "Generation: #{@generation || 'unavailable (not recorded by this metadata store)'}"]
176
178
  selected.each do |span|
@@ -183,17 +185,27 @@ module Woods
183
185
  parts.join("\n\n")
184
186
  end
185
187
 
188
+ def span_provenance(span)
189
+ result = span.to_h.merge(sha256: Digest::SHA256.hexdigest(span_text(span)))
190
+ if SourceContributors.multiple?(@unit)
191
+ result[:physical_location] =
192
+ SourceContributors.physical_span(@unit, start_byte: span.start_byte, end_byte: span.end_byte)
193
+ end
194
+ result
195
+ end
196
+
186
197
  def provenance(mode, selected, metadata, all)
187
198
  { mode: mode, owner: { identifier: field(:identifier), type: field(:type) },
188
199
  coordinate_system: 'published_unit', source_sha256: Digest::SHA256.hexdigest(@source),
189
200
  source_path: field(:file_path), physical_location: nil,
190
201
  physical_location_reason: 'published unit may contain synthesized or inlined source',
191
202
  generation: @generation, generation_status: @generation ? 'recorded' : 'unavailable',
192
- spans: selected.map { |span| span.to_h.merge(sha256: Digest::SHA256.hexdigest(span_text(span))) },
203
+ spans: selected.map { |span| span_provenance(span) },
193
204
  omitted_spans: all.size - selected.size, runtime_fields: metadata.map(&:first),
194
205
  source_complete: false, full_evidence: { tool: 'lookup', evidence: 'full',
195
206
  identifier: field(:identifier), type: field(:type),
196
207
  source_sha256: Digest::SHA256.hexdigest(@source) } }
208
+ .merge(SourceContributors.attribution(@unit))
197
209
  end
198
210
  end
199
211
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Woods
6
+ # Checks the versioned contributor boundary before consumers use its paths or coordinates.
7
+ module SourceContributorValidation
8
+ class Invalid < ArgumentError; end
9
+
10
+ RANGE_KEYS = %w[source_start_line source_end_line published_start_byte published_end_byte
11
+ published_start_line published_end_line].freeze
12
+
13
+ module_function
14
+
15
+ def validate!(records, type:, version:, source:)
16
+ unless type.to_s == 'lib' && version == 1 && records.is_a?(Array) && records.size.between?(2, 100_000)
17
+ raise Invalid, 'Invalid library contributor version or records'
18
+ end
19
+
20
+ validate_records!(records)
21
+ records.each { |record| validate_source!(source, record) } if source.is_a?(String)
22
+ records
23
+ end
24
+
25
+ def validate_records!(records)
26
+ previous = 0
27
+ paths = records.map do |record|
28
+ unless valid_record?(record) && valid_ranges?(record, previous)
29
+ raise Invalid, 'Invalid library contributor path or range'
30
+ end
31
+
32
+ previous = record.fetch('published_end_byte')
33
+ record.fetch('file_path')
34
+ end
35
+ raise Invalid, 'Unordered or duplicate library contributor path' unless paths.uniq.sort == paths
36
+ end
37
+
38
+ def valid_record?(record)
39
+ record.is_a?(Hash) && record.keys.all?(String) && valid_path?(record['file_path']) &&
40
+ valid_hash?(record['source_sha256']) &&
41
+ record['facts'].is_a?(Hash) && RANGE_KEYS.all? { |key| record[key].is_a?(Integer) }
42
+ end
43
+
44
+ def valid_hash?(hash)
45
+ hash.is_a?(String) && hash.match?(/\A[0-9a-f]{64}\z/)
46
+ end
47
+
48
+ def valid_ranges?(record, previous)
49
+ record['source_start_line'] == 1 && record['source_end_line'].positive? &&
50
+ record['published_start_byte'] >= previous && record['published_end_byte'] > record['published_start_byte'] &&
51
+ record['published_start_line'].positive? && record['published_end_line'] >= record['published_start_line']
52
+ end
53
+
54
+ def valid_path?(path)
55
+ path.is_a?(String) && path.start_with?('lib/') && path.end_with?('.rb') && !path.include?("\0") &&
56
+ !path.include?('\\') && path.split('/', -1).none? { |part| ['', '.', '..'].include?(part) }
57
+ end
58
+
59
+ def validate_source!(source, record)
60
+ raise Invalid, 'Contributor range exceeds published source' if record['published_end_byte'] > source.bytesize
61
+
62
+ fragment = source.byteslice(record['published_start_byte']...record['published_end_byte'])
63
+ first = source.byteslice(0...record['published_start_byte']).count("\n") + 1
64
+ return if valid_fragment?(fragment, first, record)
65
+
66
+ raise Invalid, 'Library contributor bytes do not match published provenance'
67
+ end
68
+
69
+ def valid_fragment?(fragment, first, record)
70
+ Digest::SHA256.hexdigest(fragment) == record['source_sha256'] &&
71
+ fragment.lines.size == record['source_end_line'] && first == record['published_start_line'] &&
72
+ first + fragment.lines.size - 1 == record['published_end_line']
73
+ end
74
+ private_class_method :validate_records!, :valid_record?, :valid_hash?, :valid_ranges?, :valid_path?,
75
+ :validate_source!,
76
+ :valid_fragment?
77
+ end
78
+ end