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
data/lib/woods/rake_helpers.rb
CHANGED
|
@@ -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)
|
|
239
|
-
#
|
|
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]
|
|
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(',')
|
|
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
|
-
|
|
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
|
-
"#{
|
|
320
|
-
elsif
|
|
351
|
+
"#{before}..#{current.empty? ? 'HEAD' : current}"
|
|
352
|
+
elsif !base.empty?
|
|
321
353
|
# GitHub Actions PR
|
|
322
|
-
"origin/#{
|
|
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:
|
|
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
|
|
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
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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']})\
|
|
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
|
-
|
|
92
|
-
|
|
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)};
|
|
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|
|
|
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
|