woods 2.0.0.beta2 → 2.0.0.beta4
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 +339 -1
- data/CONTRIBUTING.md +188 -12
- data/README.md +93 -174
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +109 -8
- data/docs/AGENT_SETUP.md +98 -7
- data/docs/BACKEND_MATRIX.md +25 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +267 -16
- data/docs/CONSOLE_MCP_SETUP.md +80 -7
- data/docs/DOCKER_SETUP.md +22 -3
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +45 -6
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +147 -7
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- data/docs/MCP_SERVERS.md +276 -5
- data/docs/MCP_TOOL_COOKBOOK.md +37 -22
- data/docs/MCP_WORKTREE_SETUP.md +43 -83
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +72 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +273 -12
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +129 -18
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +48 -22
- data/docs/WATCH_DAEMON.md +277 -67
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/exe/woods-mcp-start +14 -9
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +135 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +72 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +18 -17
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +32 -10
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +114 -60
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +277 -149
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/declared_parent.rb +55 -0
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +10 -13
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +13 -9
- data/lib/woods/extractors/mailer_extractor.rb +26 -15
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +26 -34
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +13 -9
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +48 -19
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +35 -6
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +22 -13
- data/lib/woods/mcp/bootstrapper.rb +79 -4
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +334 -162
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
- data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +178 -63
- data/lib/woods/mcp/tool_contract.rb +3 -1
- data/lib/woods/mcp/tool_response_renderer.rb +41 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/mcp/traversal_response.rb +22 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +13 -6
- data/lib/woods/payload_store.rb +27 -26
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +382 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +84 -0
- data/lib/woods/retrieval/lexical_index.rb +120 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
- data/lib/woods/session_tracer/file_store.rb +6 -1
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +31 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +35 -10
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +58 -9
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +154 -32
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +50 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
- data/plugin/skills/woods-diagnose/SKILL.md +319 -1
- data/plugin/skills/woods-investigate/SKILL.md +145 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
- data/plugin/skills/woods-setup/SKILL.md +110 -6
- metadata +87 -5
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require_relative 'plan'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module AgentConfiguration
|
|
9
|
+
module PlanDiff
|
|
10
|
+
def self.show(plan, output)
|
|
11
|
+
plan.data.fetch('changes').each do |change|
|
|
12
|
+
document = Document.new(change.fetch('path'))
|
|
13
|
+
unless document.fingerprint == change.fetch('before')
|
|
14
|
+
raise Conflict, "Changed since preview: #{document.path}; create a new plan before viewing its diff"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
output.write(render(document, Plan.after_content(change)))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.render(document, content)
|
|
22
|
+
Tempfile.create('woods-config-before') do |before|
|
|
23
|
+
Tempfile.create('woods-config-after') do |after|
|
|
24
|
+
before.write(document.content.to_s)
|
|
25
|
+
after.write(content.to_s)
|
|
26
|
+
before.flush
|
|
27
|
+
after.flush
|
|
28
|
+
diff, status = Open3.capture2('diff', '-u', '--label', document.path, '--label', document.path,
|
|
29
|
+
before.path, after.path)
|
|
30
|
+
raise Conflict, 'Cannot render plan diff' unless [0, 1].include?(status.exitstatus)
|
|
31
|
+
|
|
32
|
+
diff
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module AgentConfiguration
|
|
5
|
+
# Compute exact document replacements while tracking receipt-owned files.
|
|
6
|
+
module PlannedFiles
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def prepare_config
|
|
10
|
+
document = Document.new(@layout.config_path)
|
|
11
|
+
value = document.json
|
|
12
|
+
servers = value.fetch('mcpServers', {})
|
|
13
|
+
raise Conflict, "mcpServers must be an object: #{document.path}" unless servers.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
validate_server_ownership!(servers)
|
|
16
|
+
@created_files = @previous ? @previous.fetch('created_files').dup : []
|
|
17
|
+
track_created_file(document)
|
|
18
|
+
if @operation == 'remove'
|
|
19
|
+
servers.delete(@name)
|
|
20
|
+
else
|
|
21
|
+
servers[@name] = @entry
|
|
22
|
+
end
|
|
23
|
+
value['mcpServers'] = servers
|
|
24
|
+
@plan.add(document, config_content(document, value),
|
|
25
|
+
description: "#{@operation} owned Index MCP entry #{@name}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def config_content(document, value)
|
|
29
|
+
return if @operation == 'remove' && @created_files.include?(document.path) && value == { 'mcpServers' => {} }
|
|
30
|
+
|
|
31
|
+
document.encode_json(value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def prepare_sections
|
|
35
|
+
previous_sections = @previous ? @previous.fetch('sections') : {}
|
|
36
|
+
requested = @instructions || previous_sections.keys
|
|
37
|
+
requested = [] if @operation == 'remove'
|
|
38
|
+
validate_instruction_selection!(requested, previous_sections)
|
|
39
|
+
@sections = {}
|
|
40
|
+
(previous_sections.keys | requested).each do |name|
|
|
41
|
+
prepare_section(name, previous_sections[name], removing: !requested.include?(name))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def prepare_section(name, previous, removing:)
|
|
46
|
+
document = Document.new(@layout.instruction_path(name))
|
|
47
|
+
content, ownership = ManagedSection.change(document.content, previous: previous, remove: removing)
|
|
48
|
+
track_created_file(document)
|
|
49
|
+
content = nil if removing && content == '' && @created_files.include?(document.path)
|
|
50
|
+
@sections[name] = ownership if ownership
|
|
51
|
+
@plan.add(document, content, description: "#{removing ? 'remove' : 'write'} owned Woods instruction section")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def track_created_file(document)
|
|
55
|
+
return unless document.content.nil? && !@created_files.include?(document.path)
|
|
56
|
+
|
|
57
|
+
@created_files << document.path
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'plan'
|
|
4
|
+
require_relative 'managed_section'
|
|
5
|
+
require_relative 'planner_validation'
|
|
6
|
+
require_relative 'planned_files'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module AgentConfiguration
|
|
10
|
+
class Planner
|
|
11
|
+
include PlannerValidation
|
|
12
|
+
include PlannedFiles
|
|
13
|
+
|
|
14
|
+
TEMPLATE_VERSION = 1
|
|
15
|
+
|
|
16
|
+
def initialize(layout:, operation:, entry: nil, **options)
|
|
17
|
+
unknown = options.keys - %i[name instructions evidence intent]
|
|
18
|
+
raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
|
|
19
|
+
|
|
20
|
+
name = options.fetch(:name, 'woods')
|
|
21
|
+
raise Conflict, 'Supported operations: setup, update, remove' unless %w[setup update remove].include?(operation)
|
|
22
|
+
raise Conflict, 'Server name must be a simple nonempty name' unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
23
|
+
|
|
24
|
+
@layout = layout
|
|
25
|
+
@operation = operation
|
|
26
|
+
@entry = entry
|
|
27
|
+
@name = name
|
|
28
|
+
@instructions = options[:instructions]
|
|
29
|
+
@evidence = options.fetch(:evidence, {})
|
|
30
|
+
@intent = options.fetch(:intent, {})
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
@receipt_document = Document.new(@layout.receipt_path)
|
|
35
|
+
@previous = @receipt_document.content && @receipt_document.json
|
|
36
|
+
validate_receipt!
|
|
37
|
+
@plan = Plan.new(layout: @layout, operation: @operation, evidence: @evidence)
|
|
38
|
+
return @plan if @operation == 'remove' && @previous.nil?
|
|
39
|
+
|
|
40
|
+
prepare_config
|
|
41
|
+
prepare_sections
|
|
42
|
+
prepare_receipt
|
|
43
|
+
@plan
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def prepare_receipt
|
|
49
|
+
if @operation == 'remove'
|
|
50
|
+
@plan.add(@receipt_document, nil, description: 'remove installation receipt')
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
receipt = { 'schema_version' => 1, 'template_version' => TEMPLATE_VERSION, 'layout' => @layout.identity,
|
|
54
|
+
'server_name' => @name, 'entry' => @entry, 'sections' => @sections,
|
|
55
|
+
'created_files' => @created_files, 'intent' => @intent, 'evidence' => @evidence }
|
|
56
|
+
# A repeated setup never silently upgrades the installation's template
|
|
57
|
+
# or evidence version. The explicit update operation owns that change.
|
|
58
|
+
receipt = @previous if @operation == 'setup' && @previous && @plan.data.fetch('changes').empty?
|
|
59
|
+
@plan.add(@receipt_document, @receipt_document.encode_json(receipt), description: 'record owned installation')
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module AgentConfiguration
|
|
5
|
+
# Receipt and command validation shared by the planner's file preparations.
|
|
6
|
+
module PlannerValidation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def validate_receipt!
|
|
10
|
+
if @previous
|
|
11
|
+
validate_receipt_identity!
|
|
12
|
+
elsif @operation == 'update'
|
|
13
|
+
raise Conflict, 'No owned installation to update; run setup first'
|
|
14
|
+
end
|
|
15
|
+
return if @operation == 'remove'
|
|
16
|
+
|
|
17
|
+
validate_entry!
|
|
18
|
+
validate_setup_compatibility! if @operation == 'setup' && @previous
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_receipt_identity!
|
|
22
|
+
valid = @previous['schema_version'] == 1 && @previous['layout'] == @layout.identity &&
|
|
23
|
+
@previous['server_name'] == @name && @previous['sections'].is_a?(Hash) &&
|
|
24
|
+
@previous['created_files'].is_a?(Array)
|
|
25
|
+
raise Conflict, 'Installation receipt does not match this application/client/scope/server' unless valid
|
|
26
|
+
|
|
27
|
+
validate_receipt_content!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_receipt_content!
|
|
31
|
+
sections = @previous.fetch('sections')
|
|
32
|
+
valid_sections = sections.values.all? do |section|
|
|
33
|
+
section.is_a?(Hash) && section['owned_text'].is_a?(String) && section['prefix'].is_a?(String)
|
|
34
|
+
end
|
|
35
|
+
valid_files = (@previous.fetch('created_files') - @layout.allowed_paths).empty?
|
|
36
|
+
return if valid_sections && valid_files && @previous['entry'].is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
raise Conflict, 'Malformed ownership receipt; restore it before updating or removing configuration'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_entry!
|
|
42
|
+
return if @entry.is_a?(Hash) && @entry['command'].is_a?(String) && @entry['args'].is_a?(Array)
|
|
43
|
+
|
|
44
|
+
raise Conflict, 'A preflight-validated Index MCP command is required'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_setup_compatibility!
|
|
48
|
+
if @previous['entry'] != @entry
|
|
49
|
+
raise Conflict, 'Setup differs from the owned installation; preview an explicit update'
|
|
50
|
+
end
|
|
51
|
+
return if @previous['template_version'] == self.class::TEMPLATE_VERSION
|
|
52
|
+
|
|
53
|
+
raise Conflict, 'The instruction template changed; preview an explicit update'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_server_ownership!(servers)
|
|
57
|
+
if @previous
|
|
58
|
+
return if servers[@name] == @previous.fetch('entry')
|
|
59
|
+
|
|
60
|
+
raise Conflict, 'The owned MCP entry was edited or removed; restore it or resolve ownership manually'
|
|
61
|
+
end
|
|
62
|
+
return unless servers.key?(@name)
|
|
63
|
+
|
|
64
|
+
raise Conflict, "MCP entry #{@name.inspect} already exists without a Woods receipt; choose another name"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_instruction_selection!(requested, previous_sections)
|
|
68
|
+
unless requested.is_a?(Array) && requested.uniq == requested
|
|
69
|
+
raise Conflict, 'Instruction files must be a unique list'
|
|
70
|
+
end
|
|
71
|
+
return unless @operation == 'setup' && @previous && requested.sort != previous_sections.keys.sort
|
|
72
|
+
|
|
73
|
+
raise Conflict, 'Instruction selection differs; preview an explicit update'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require 'bundler'
|
|
7
|
+
require_relative 'error'
|
|
8
|
+
|
|
9
|
+
module Woods
|
|
10
|
+
module AgentConfiguration
|
|
11
|
+
class Preflight
|
|
12
|
+
REQUIRED_TOOLS = %w[woods_status search lookup dependencies dependents].freeze
|
|
13
|
+
SCRIPT = <<~'RUBY'
|
|
14
|
+
require 'timeout'
|
|
15
|
+
Timeout.timeout(25) do
|
|
16
|
+
require 'json'
|
|
17
|
+
require 'woods'
|
|
18
|
+
require 'woods/mcp/server'
|
|
19
|
+
require 'woods/resilience/index_validator'
|
|
20
|
+
root = File.realpath(ARGV.fetch(1))
|
|
21
|
+
index = File.realpath(ARGV.fetch(0))
|
|
22
|
+
spec = Gem.loaded_specs.fetch('woods')
|
|
23
|
+
abort 'Installed Woods is missing woods-mcp-start' unless spec.executables.include?('woods-mcp-start')
|
|
24
|
+
reader = Woods::MCP::IndexReader.new(index)
|
|
25
|
+
manifest = reader.manifest
|
|
26
|
+
abort 'Published extraction manifest missing or malformed' unless manifest.is_a?(Hash) && manifest['counts'].is_a?(Hash)
|
|
27
|
+
report = Woods::Resilience::IndexValidator.new(index_dir: index, app_root: root).validate
|
|
28
|
+
abort report.errors.join("\n") unless report.valid?
|
|
29
|
+
tools = Woods::MCP::Server.build(index_dir: index, warmup: false).tools.keys
|
|
30
|
+
puts JSON.generate(version: Woods::VERSION, tools: tools.sort, root: root, index: index,
|
|
31
|
+
generation: reader.loaded_generation, warnings: report.warnings)
|
|
32
|
+
end
|
|
33
|
+
RUBY
|
|
34
|
+
|
|
35
|
+
def initialize(timeout: 30)
|
|
36
|
+
@timeout = timeout
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(launcher)
|
|
40
|
+
output = run(launcher)
|
|
41
|
+
evidence = JSON.parse(output)
|
|
42
|
+
raise Conflict, 'Installed Woods preflight expected a JSON object' unless evidence.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
missing = REQUIRED_TOOLS - Array(evidence['tools'])
|
|
45
|
+
raise Conflict, "Installed Woods lacks required Index capabilities: #{missing.join(', ')}" unless missing.empty?
|
|
46
|
+
raise Conflict, 'Installed Woods did not report its version' unless evidence['version'].is_a?(String)
|
|
47
|
+
|
|
48
|
+
evidence
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
raise Conflict, 'Installed Woods preflight did not return valid JSON; inspect application bundle and index'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def run(launcher)
|
|
56
|
+
command = launcher.probe_command(SCRIPT)
|
|
57
|
+
environment = Bundler.unbundled_env.merge(launcher.probe_environment)
|
|
58
|
+
Open3.popen3(environment, *command, chdir: launcher.root, unsetenv_others: true,
|
|
59
|
+
pgroup: true) do |stdin, out, err, wait|
|
|
60
|
+
stdin.close
|
|
61
|
+
collect(out, err, wait)
|
|
62
|
+
end
|
|
63
|
+
rescue Errno::ENOENT => e
|
|
64
|
+
raise Conflict, "Launch command unavailable: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect(stdout, stderr, wait)
|
|
68
|
+
readers = [stdout, stderr].map { |stream| Thread.new { bounded_read(stream) } }
|
|
69
|
+
Timeout.timeout(@timeout) do
|
|
70
|
+
output, errors = readers.map(&:value)
|
|
71
|
+
raise Conflict, "Installed Woods preflight failed: #{errors.strip}" unless wait.value.success?
|
|
72
|
+
|
|
73
|
+
output
|
|
74
|
+
end
|
|
75
|
+
rescue Timeout::Error
|
|
76
|
+
raise Conflict, "Installed Woods preflight exceeded #{@timeout}s; verify the selected command and container"
|
|
77
|
+
ensure
|
|
78
|
+
terminate(wait.pid)
|
|
79
|
+
readers&.each(&:kill)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def bounded_read(stream)
|
|
83
|
+
output = String.new
|
|
84
|
+
loop do
|
|
85
|
+
chunk = stream.readpartial(16_384)
|
|
86
|
+
output << chunk
|
|
87
|
+
raise Conflict, 'Installed Woods preflight output exceeded 1 MiB' if output.bytesize > 1_048_576
|
|
88
|
+
end
|
|
89
|
+
rescue EOFError
|
|
90
|
+
output
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def terminate(pid)
|
|
94
|
+
Process.kill('KILL', -pid)
|
|
95
|
+
rescue Errno::ESRCH
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module AgentConfiguration
|
|
5
|
+
# Validate every journal snapshot before restoring any managed file.
|
|
6
|
+
module Recovery
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def recover_journal(journal)
|
|
10
|
+
plan = Plan.allocate
|
|
11
|
+
plan.instance_variable_set(:@data, journal.fetch('plan'))
|
|
12
|
+
plan.validate!(@layout)
|
|
13
|
+
changes = plan.data.fetch('changes')
|
|
14
|
+
originals = journal.fetch('originals')
|
|
15
|
+
validate_originals!(originals, changes)
|
|
16
|
+
changes.each { |change| validate_recovery_snapshot!(change) }
|
|
17
|
+
originals.zip(changes).reverse_each { |original, change| restore_original(original, change) }
|
|
18
|
+
File.unlink(journal_path)
|
|
19
|
+
rescue KeyError, TypeError, ArgumentError => e
|
|
20
|
+
raise Conflict, "Invalid recovery journal; retained for manual recovery: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_originals!(originals, changes)
|
|
24
|
+
paths = changes.map { |entry| entry['path'] }
|
|
25
|
+
return if originals.is_a?(Array) && originals.all?(Hash) && originals.map { |entry| entry['path'] } == paths
|
|
26
|
+
|
|
27
|
+
raise Conflict, "Invalid recovery journal; retain it for manual recovery: #{journal_path}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_recovery_snapshot!(change)
|
|
31
|
+
return if [change.fetch('before'), Plan.after_fingerprint(change)].include?(current(change))
|
|
32
|
+
|
|
33
|
+
raise Conflict,
|
|
34
|
+
"Concurrent edit prevents recovery of #{change.fetch('path')}; journal retained: #{journal_path}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def restore_original(original, change)
|
|
38
|
+
return if current(change) == change.fetch('before')
|
|
39
|
+
|
|
40
|
+
content = original['content'] && Base64.strict_decode64(original['content']).force_encoding(Encoding::UTF_8)
|
|
41
|
+
expected = { 'sha256' => content && Digest::SHA256.hexdigest(content),
|
|
42
|
+
'mode' => original.fetch('mode'), 'exists' => !content.nil? }
|
|
43
|
+
assert_snapshot!(expected, change.fetch('before'), original.fetch('path'))
|
|
44
|
+
assert_snapshot!(current(change), Plan.after_fingerprint(change), original.fetch('path'))
|
|
45
|
+
replace(original.fetch('path'), content, original.fetch('mode'))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/woods/ast/node.rb
CHANGED
|
@@ -26,6 +26,8 @@ module Woods
|
|
|
26
26
|
:arguments, # Array<String>: argument representations (for :send)
|
|
27
27
|
:source, # String | nil: raw source text of this node
|
|
28
28
|
:end_line, # Integer | nil: 1-based end line number (when available)
|
|
29
|
+
:start_byte, # Integer | nil: inclusive byte offset into the parsed source
|
|
30
|
+
:end_byte, # Integer | nil: exclusive byte offset into the parsed source
|
|
29
31
|
keyword_init: true
|
|
30
32
|
) do
|
|
31
33
|
# Find all descendant nodes matching a type.
|
data/lib/woods/ast/parser.rb
CHANGED
|
@@ -32,6 +32,21 @@ module Woods
|
|
|
32
32
|
raise Woods::ExtractionError, "Failed to parse source: #{e.message}"
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Validate a candidate excerpt without logging expected syntax failures
|
|
36
|
+
# (for example a heredoc body outside a method's syntactic AST range).
|
|
37
|
+
# The parser instance owns its diagnostics; no global stderr redirection.
|
|
38
|
+
def valid_fragment?(source)
|
|
39
|
+
return Prism.parse(source).success? if prism_available?
|
|
40
|
+
|
|
41
|
+
require 'parser/current' unless defined?(::Parser::CurrentRuby)
|
|
42
|
+
parser = ::Parser::CurrentRuby.default_parser
|
|
43
|
+
parser.diagnostics.consumer = nil
|
|
44
|
+
buffer = ::Parser::Source::Buffer.new('(fragment)', source: source)
|
|
45
|
+
!parser.parse(buffer).nil?
|
|
46
|
+
rescue StandardError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
35
50
|
# Check if Prism is available.
|
|
36
51
|
#
|
|
37
52
|
# @return [Boolean]
|
|
@@ -262,7 +277,9 @@ module Woods
|
|
|
262
277
|
end_line: end_line_for_prism(prism_node),
|
|
263
278
|
method_name: prism_node.name.to_s,
|
|
264
279
|
receiver: receiver_text,
|
|
265
|
-
source: extract_prism_source_span(prism_node, source)
|
|
280
|
+
source: extract_prism_source_span(prism_node, source),
|
|
281
|
+
start_byte: prism_node.location.start_offset,
|
|
282
|
+
end_byte: prism_node.location.end_offset
|
|
266
283
|
)
|
|
267
284
|
end
|
|
268
285
|
|
|
@@ -524,12 +541,16 @@ module Woods
|
|
|
524
541
|
line: parser_node.loc.line,
|
|
525
542
|
end_line: parser_node.loc.expression.last_line,
|
|
526
543
|
method_name: parser_node.children[0].to_s,
|
|
527
|
-
source: extract_parser_source_span(parser_node, source)
|
|
544
|
+
source: extract_parser_source_span(parser_node, source),
|
|
545
|
+
start_byte: original_byte_offset(source, parser_node.loc.expression.line,
|
|
546
|
+
parser_node.loc.expression.column),
|
|
547
|
+
end_byte: original_byte_offset(source, parser_node.loc.expression.last_line,
|
|
548
|
+
parser_node.loc.expression.last_column)
|
|
528
549
|
)
|
|
529
550
|
when :defs
|
|
530
551
|
body = parser_node.children[3] ? convert_parser_node(parser_node.children[3], source) : nil
|
|
531
552
|
body_children = parser_body_children(body)
|
|
532
|
-
receiver = parser_node.children[0]
|
|
553
|
+
receiver = extract_parser_receiver_text(parser_node.children[0], source)
|
|
533
554
|
Node.new(
|
|
534
555
|
type: :defs,
|
|
535
556
|
children: body_children,
|
|
@@ -537,7 +558,11 @@ module Woods
|
|
|
537
558
|
end_line: parser_node.loc.expression.last_line,
|
|
538
559
|
method_name: parser_node.children[1].to_s,
|
|
539
560
|
receiver: receiver,
|
|
540
|
-
source: extract_parser_source_span(parser_node, source)
|
|
561
|
+
source: extract_parser_source_span(parser_node, source),
|
|
562
|
+
start_byte: original_byte_offset(source, parser_node.loc.expression.line,
|
|
563
|
+
parser_node.loc.expression.column),
|
|
564
|
+
end_byte: original_byte_offset(source, parser_node.loc.expression.last_line,
|
|
565
|
+
parser_node.loc.expression.last_column)
|
|
541
566
|
)
|
|
542
567
|
when :send
|
|
543
568
|
receiver_text = parser_node.children[0] ? extract_parser_receiver_text(parser_node.children[0], source) : nil
|
|
@@ -611,8 +636,16 @@ module Woods
|
|
|
611
636
|
end
|
|
612
637
|
end
|
|
613
638
|
|
|
639
|
+
# parser gem normalizes CRLF in its source buffer. Its line/character
|
|
640
|
+
# columns still identify the original source, but absolute positions do
|
|
641
|
+
# not. Map through original lines to preserve both CRLF and Unicode bytes.
|
|
642
|
+
def original_byte_offset(source, line, column)
|
|
643
|
+
lines = source.lines
|
|
644
|
+
lines.take(line - 1).sum(&:bytesize) + lines.fetch(line - 1)[0, column].bytesize
|
|
645
|
+
end
|
|
646
|
+
|
|
614
647
|
def parser_body_children(body_node)
|
|
615
|
-
body_node
|
|
648
|
+
body_node.is_a?(Node) && body_node.type == :begin ? body_node.children : [body_node].compact
|
|
616
649
|
end
|
|
617
650
|
|
|
618
651
|
def extract_parser_source_span(node, source)
|
data/lib/woods/builder.rb
CHANGED
|
@@ -110,6 +110,11 @@ module Woods
|
|
|
110
110
|
# on disk and passes the populated store here.
|
|
111
111
|
# @return [Retriever, Cache::CachedRetriever] A fully wired retriever
|
|
112
112
|
def build_retriever(vector_store: nil, metadata_store: nil, graph_store: nil)
|
|
113
|
+
if @config.retrieval_mode == :lexical
|
|
114
|
+
return build_lexical_retriever(metadata_store: metadata_store,
|
|
115
|
+
graph_store: graph_store)
|
|
116
|
+
end
|
|
117
|
+
|
|
113
118
|
provider = build_resilient_embedding_provider
|
|
114
119
|
cache = build_cache_store
|
|
115
120
|
|
|
@@ -119,12 +124,21 @@ module Woods
|
|
|
119
124
|
vector_store: vector_store || build_vector_store(dimensions: vector_dimensions(provider)),
|
|
120
125
|
metadata_store: metadata_store || build_metadata_store,
|
|
121
126
|
graph_store: graph_store || build_graph_store,
|
|
122
|
-
embedding_provider: provider
|
|
127
|
+
embedding_provider: provider, default_budget: @config.max_context_tokens
|
|
123
128
|
)
|
|
124
129
|
|
|
125
130
|
cache ? wrap_with_retriever_cache(retriever, cache) : retriever
|
|
126
131
|
end
|
|
127
132
|
|
|
133
|
+
def build_lexical_retriever(metadata_store:, graph_store:)
|
|
134
|
+
retriever = Retriever.new(vector_store: nil, metadata_store: metadata_store || build_metadata_store,
|
|
135
|
+
graph_store: graph_store, embedding_provider: nil, mode: :lexical,
|
|
136
|
+
default_budget: @config.max_context_tokens)
|
|
137
|
+
cache = build_cache_store
|
|
138
|
+
cache ? wrap_with_retriever_cache(retriever, cache) : retriever
|
|
139
|
+
end
|
|
140
|
+
private :build_lexical_retriever
|
|
141
|
+
|
|
128
142
|
# Instantiate the vector store adapter specified by the configuration.
|
|
129
143
|
#
|
|
130
144
|
# The :pgvector branch also ensures the adapter's own schema exists —
|
|
@@ -482,19 +496,21 @@ module Woods
|
|
|
482
496
|
|
|
483
497
|
# Instantiate the metadata store adapter specified by the configuration.
|
|
484
498
|
#
|
|
499
|
+
# @param output_dir [String, Pathname] Default SQLite database directory; an
|
|
500
|
+
# explicit metadata_store_options[:database] still takes precedence.
|
|
485
501
|
# @return [Storage::MetadataStore::Interface] Metadata store adapter instance
|
|
486
502
|
# @raise [ArgumentError] if the configured type is not recognized
|
|
487
|
-
def build_metadata_store
|
|
503
|
+
def build_metadata_store(output_dir: @config.output_dir)
|
|
488
504
|
case @config.metadata_store
|
|
489
505
|
when :in_memory then Storage::MetadataStore::InMemory.new
|
|
490
|
-
when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options)
|
|
506
|
+
when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options(output_dir))
|
|
491
507
|
else raise ArgumentError, "Unknown metadata_store: #{@config.metadata_store}"
|
|
492
508
|
end
|
|
493
509
|
end
|
|
494
510
|
|
|
495
|
-
def sqlite_metadata_options
|
|
511
|
+
def sqlite_metadata_options(output_dir)
|
|
496
512
|
opts = (@config.metadata_store_options || {}).transform_keys(&:to_sym)
|
|
497
|
-
opts[:database] ||= File.join(
|
|
513
|
+
opts[:database] ||= File.join(output_dir.to_s, 'metadata.sqlite3')
|
|
498
514
|
opts
|
|
499
515
|
end
|
|
500
516
|
private :sqlite_metadata_options
|
|
@@ -418,6 +418,8 @@ module Woods
|
|
|
418
418
|
def vector_store = @retriever.vector_store
|
|
419
419
|
def metadata_store = @retriever.metadata_store
|
|
420
420
|
def graph_store = @retriever.graph_store
|
|
421
|
+
def mode = @retriever.respond_to?(:mode) ? @retriever.mode : :semantic
|
|
422
|
+
def default_budget = @retriever.respond_to?(:default_budget) ? @retriever.default_budget : 8000
|
|
421
423
|
|
|
422
424
|
# Invalidate every cached context result. Called from the MCP +reload+
|
|
423
425
|
# tool after the retriever's stores have been re-hydrated from a fresh
|
|
@@ -449,12 +451,17 @@ module Woods
|
|
|
449
451
|
# @param types [Array<String, Symbol>, nil] Include-only filter
|
|
450
452
|
# @param exclude_types [Array<String, Symbol>, nil] Additional exclusions
|
|
451
453
|
# @return [Retriever::RetrievalResult]
|
|
452
|
-
def retrieve(query, budget:
|
|
453
|
-
|
|
454
|
+
def retrieve(query, budget: default_budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil,
|
|
455
|
+
evidence: 'full') # rubocop:disable Metrics/ParameterLists
|
|
456
|
+
Retrieval::SourceEvidence.validate_mode!(evidence)
|
|
457
|
+
scoped = Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
|
|
458
|
+
scope_options = scoped ? { packages: packages, source_paths: source_paths } : {}
|
|
459
|
+
scope_options[:evidence] = evidence unless evidence == 'full'
|
|
460
|
+
key = context_key(query, budget, types: types, exclude_types: exclude_types, **scope_options)
|
|
454
461
|
cached = @cache_store.read(key)
|
|
455
462
|
return rehydrate_cached(cached, budget) if cached
|
|
456
463
|
|
|
457
|
-
result = @retriever.retrieve(query, budget: budget, types: types, exclude_types: exclude_types)
|
|
464
|
+
result = @retriever.retrieve(query, budget: budget, types: types, exclude_types: exclude_types, **scope_options)
|
|
458
465
|
|
|
459
466
|
begin
|
|
460
467
|
@cache_store.write(key, serialize_result(result), ttl: @context_ttl)
|
|
@@ -478,8 +485,14 @@ module Woods
|
|
|
478
485
|
# @param types [Array<String, Symbol>, nil]
|
|
479
486
|
# @param exclude_types [Array<String, Symbol>, nil]
|
|
480
487
|
# @return [String]
|
|
481
|
-
def context_key(query, budget, types: nil, exclude_types: nil)
|
|
482
|
-
|
|
488
|
+
def context_key(query, budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil, evidence: 'full') # rubocop:disable Metrics/ParameterLists
|
|
489
|
+
parts = [query, budget.to_s, fingerprint(types), fingerprint(exclude_types)]
|
|
490
|
+
parts << 'lexical' if mode == :lexical
|
|
491
|
+
parts << JSON.generate(evidence: evidence) unless evidence == 'full'
|
|
492
|
+
if Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
|
|
493
|
+
parts << JSON.generate(packages: packages, source_paths: source_paths)
|
|
494
|
+
end
|
|
495
|
+
Cache.cache_key(:context, *parts)
|
|
483
496
|
end
|
|
484
497
|
|
|
485
498
|
def fingerprint(types)
|
|
@@ -504,7 +517,8 @@ module Woods
|
|
|
504
517
|
tokens_used: cached['tokens_used'],
|
|
505
518
|
budget: budget,
|
|
506
519
|
trace: nil,
|
|
507
|
-
type_rank_context: rehydrate_type_rank_context(cached['type_rank_context'])
|
|
520
|
+
type_rank_context: rehydrate_type_rank_context(cached['type_rank_context']),
|
|
521
|
+
applied_scope: rehydrate_scope(cached['applied_scope'])
|
|
508
522
|
)
|
|
509
523
|
end
|
|
510
524
|
|
|
@@ -514,10 +528,17 @@ module Woods
|
|
|
514
528
|
'sources' => result.sources,
|
|
515
529
|
'strategy' => result.strategy&.to_s,
|
|
516
530
|
'tokens_used' => result.tokens_used,
|
|
517
|
-
'type_rank_context' => serialize_type_rank_context(result.type_rank_context)
|
|
531
|
+
'type_rank_context' => serialize_type_rank_context(result.type_rank_context),
|
|
532
|
+
'applied_scope' => result.applied_scope
|
|
518
533
|
}
|
|
519
534
|
end
|
|
520
535
|
|
|
536
|
+
def rehydrate_scope(scope)
|
|
537
|
+
return nil unless scope
|
|
538
|
+
|
|
539
|
+
scope.transform_keys(&:to_sym).tap { |value| value[:outcome] = value[:outcome]&.to_sym }
|
|
540
|
+
end
|
|
541
|
+
|
|
521
542
|
# type_rank_context is a Hash<String => Hash<Symbol, ...>> with
|
|
522
543
|
# :source carrying a Symbol value. JSON-backed caches (Redis,
|
|
523
544
|
# SolidCache) collapse both to strings on the round-trip, so we
|
|
@@ -25,11 +25,10 @@ module Woods
|
|
|
25
25
|
# @param parts [Array<String>] Key components (will be SHA256-hashed if long)
|
|
26
26
|
# @return [String] Namespaced key
|
|
27
27
|
def self.cache_key(domain, *parts)
|
|
28
|
-
raw =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
end
|
|
28
|
+
raw = parts.map do |part|
|
|
29
|
+
value = part.to_s
|
|
30
|
+
"#{value.bytesize}:#{value}"
|
|
31
|
+
end.join
|
|
33
32
|
suffix = raw.length > 64 ? Digest::SHA256.hexdigest(raw) : raw
|
|
34
33
|
"woods:cache:#{domain}:#{suffix}"
|
|
35
34
|
end
|
data/lib/woods/change_set.rb
CHANGED
|
@@ -15,7 +15,8 @@ module Woods
|
|
|
15
15
|
#
|
|
16
16
|
# Paths are held absolute (matching how {DependencyGraph} registers file
|
|
17
17
|
# paths) and exposed relative on demand (matching how {PathDispatcher}
|
|
18
|
-
# rules are written).
|
|
18
|
+
# rules are written). Normalization is lexical, so vanished files remain
|
|
19
|
+
# representable without resolving symlink targets or consulting the filesystem.
|
|
19
20
|
#
|
|
20
21
|
# @example
|
|
21
22
|
# cs = Woods::ChangeSet.new(paths: %w[app/models/user.rb app/models/gone.rb], root: Rails.root)
|
|
@@ -29,7 +30,7 @@ module Woods
|
|
|
29
30
|
# @param paths [Array<String>] changed paths, absolute or root-relative
|
|
30
31
|
# @param root [String, Pathname] application root (usually Rails.root)
|
|
31
32
|
def initialize(paths:, root:)
|
|
32
|
-
@root = Pathname.new(root.to_s)
|
|
33
|
+
@root = Pathname.new(root.to_s).expand_path
|
|
33
34
|
@absolute_paths = Array(paths).filter_map { |p| absolutize(p) }.uniq.freeze
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -72,7 +73,7 @@ module Woods
|
|
|
72
73
|
# @param path [String] absolute path
|
|
73
74
|
# @return [String] root-relative path, or the input if outside {#root}
|
|
74
75
|
def relativize(path)
|
|
75
|
-
prefix =
|
|
76
|
+
prefix = File.join(@root.to_s, '')
|
|
76
77
|
path.start_with?(prefix) ? path.delete_prefix(prefix) : path
|
|
77
78
|
end
|
|
78
79
|
|
|
@@ -82,7 +83,7 @@ module Woods
|
|
|
82
83
|
str = path.to_s.strip
|
|
83
84
|
return nil if str.empty?
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
@root.join(str).cleanpath.to_s
|
|
86
87
|
end
|
|
87
88
|
end
|
|
88
89
|
end
|