woods 2.0.0.beta2 → 2.0.0.beta3
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 +262 -1
- data/CONTRIBUTING.md +173 -9
- data/README.md +7 -3
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +83 -4
- data/docs/AGENT_SETUP.md +82 -1
- data/docs/BACKEND_MATRIX.md +20 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +199 -14
- data/docs/CONSOLE_MCP_SETUP.md +35 -5
- data/docs/DOCKER_SETUP.md +21 -2
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +36 -5
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +117 -1
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- data/docs/MCP_SERVERS.md +221 -5
- data/docs/MCP_TOOL_COOKBOOK.md +33 -18
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +55 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +253 -11
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +117 -5
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +44 -22
- data/docs/WATCH_DAEMON.md +259 -59
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +133 -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 +59 -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 +14 -14
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/embedded_executor.rb +1 -1
- 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/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +90 -46
- 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 +232 -137
- 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/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 +8 -2
- 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 +3 -1
- data/lib/woods/extractors/mailer_extractor.rb +20 -5
- 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 +39 -33
- 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 +3 -1
- 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 +27 -15
- 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 +20 -12
- data/lib/woods/mcp/bootstrapper.rb +62 -0
- data/lib/woods/mcp/index_reader.rb +323 -160
- 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 +8 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +158 -37
- data/lib/woods/mcp/tool_contract.rb +2 -0
- data/lib/woods/mcp/tool_response_renderer.rb +25 -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/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 +7 -1
- data/lib/woods/payload_store.rb +27 -26
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +392 -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 +73 -0
- data/lib/woods/retrieval/lexical_index.rb +119 -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/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 +27 -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 +29 -8
- 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 +29 -8
- 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 +136 -28
- 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 +13 -0
- data/plugin/skills/woods-diagnose/SKILL.md +288 -1
- data/plugin/skills/woods-investigate/SKILL.md +106 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
- data/plugin/skills/woods-setup/SKILL.md +107 -6
- metadata +84 -5
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Hooks
|
|
5
|
+
# Resolve one unambiguous edited identity, then reuse the bounded graph walk.
|
|
6
|
+
class ContextImpact
|
|
7
|
+
MAX_NODES = 50_000
|
|
8
|
+
|
|
9
|
+
def initialize(reader, renderer)
|
|
10
|
+
@reader = reader
|
|
11
|
+
@renderer = renderer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(path, generation, freshness)
|
|
15
|
+
graph = @reader.raw_graph_data
|
|
16
|
+
roots = roots_for(graph, path)
|
|
17
|
+
header = "Woods candidates from generation #{generation} (pre-refresh snapshot; " \
|
|
18
|
+
"source freshness: #{freshness}). Changed path: #{JSON.generate(path)}."
|
|
19
|
+
return unresolved(header, roots) unless roots.size == 1
|
|
20
|
+
|
|
21
|
+
root = roots.first
|
|
22
|
+
traversal = @reader.traverse_dependents(root.fetch('identifier'), depth: 2, explain: true, max_nodes: 10,
|
|
23
|
+
max_edges: 100)
|
|
24
|
+
@renderer.context(header, rows_for(traversal), partial: !!traversal[:partial])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def rows_for(traversal)
|
|
30
|
+
rows = traversal.fetch(:explanation).fetch(:witnesses).filter_map do |_identifier, witness|
|
|
31
|
+
next if witness[:impact] == 'root'
|
|
32
|
+
|
|
33
|
+
edge = traversal[:explanation][:edges].fetch(witness.fetch(:edge_id))
|
|
34
|
+
evidence_row(edge, witness)
|
|
35
|
+
end
|
|
36
|
+
rows << 'No candidate found within this bounded snapshot; verify manually with dependents.' if rows.empty?
|
|
37
|
+
rows
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def roots_for(graph, path)
|
|
41
|
+
records = records_for(graph)
|
|
42
|
+
matching = records.select { |node| node['file_path'] == path }
|
|
43
|
+
identities = matching.map { |node| node.slice('identifier', 'type') }.uniq
|
|
44
|
+
return identities if identities.size != 1
|
|
45
|
+
|
|
46
|
+
# A recorded edge target does not identify which same-named type it means.
|
|
47
|
+
records.select { |node| node['identifier'] == identities.first['identifier'] }
|
|
48
|
+
.map { |node| node.slice('identifier', 'type') }.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unresolved(header, roots)
|
|
52
|
+
reason = roots.empty? ? 'unresolved changed path' : 'ambiguous changed identity'
|
|
53
|
+
rows = roots.first(10).map { |root| "#{root['identifier']} (#{root['type']})" }
|
|
54
|
+
@renderer.context("#{header} #{reason}; verify with search and typed lookup.", rows, partial: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def evidence_row(edge, witness)
|
|
58
|
+
source = edge.fetch(:source)
|
|
59
|
+
target = edge.fetch(:target)
|
|
60
|
+
target_type = target[:type] || "ambiguous/unknown #{Array(target[:candidate_types]).join(',')}"
|
|
61
|
+
test_hint = source[:type] == 'test_mapping' ? '; test suggestion' : ''
|
|
62
|
+
uncertain = witness[:typed_path_complete] ? '' : '; typed path incomplete'
|
|
63
|
+
"#{witness[:impact]} candidate: #{source[:identifier]} (#{source[:type]}) " \
|
|
64
|
+
"--via=#{edge[:via] || 'unknown'}--> #{target[:identifier]} (#{target_type})#{test_hint}#{uncertain}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def records_for(graph)
|
|
68
|
+
nodes = graph.fetch('nodes')
|
|
69
|
+
variants = graph.fetch('variants', [])
|
|
70
|
+
raise ArgumentError unless nodes.is_a?(Hash) && variants.is_a?(Array)
|
|
71
|
+
raise ArgumentError if nodes.size + variants.size > MAX_NODES
|
|
72
|
+
|
|
73
|
+
nodes.map { |identifier, node| node.merge('identifier' => identifier) } + variants
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Hooks
|
|
7
|
+
# Bounds the complete client JSON envelope, preserving whole evidence rows.
|
|
8
|
+
class ContextOutput
|
|
9
|
+
MAX_BYTES = 2048
|
|
10
|
+
UNAVAILABLE = 'Woods context unavailable or incomplete; inspect woods_status ' \
|
|
11
|
+
'and verify with search/lookup/dependents.'
|
|
12
|
+
|
|
13
|
+
def initialize(kind)
|
|
14
|
+
@kind = kind
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def context(header, rows = [], partial: false)
|
|
18
|
+
selected = []
|
|
19
|
+
rows.each do |row|
|
|
20
|
+
if envelope(([header] + selected + [row, footer(true)]).join("\n")).bytesize > MAX_BYTES
|
|
21
|
+
partial = true
|
|
22
|
+
break
|
|
23
|
+
end
|
|
24
|
+
selected << row
|
|
25
|
+
end
|
|
26
|
+
text = ([header] + selected + [footer(partial)]).join("\n")
|
|
27
|
+
envelope(text).bytesize <= MAX_BYTES ? text : UNAVAILABLE
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def encode(context)
|
|
31
|
+
result = envelope(context)
|
|
32
|
+
result.bytesize <= MAX_BYTES ? result : envelope(UNAVAILABLE)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def footer(partial)
|
|
38
|
+
"truncated: #{partial ? 'yes' : 'no'}; depth<=2, nodes<=10, edges<=100. " \
|
|
39
|
+
'Verify with dependents explain:true; candidates and test suggestions are not proof.'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def envelope(context)
|
|
43
|
+
JSON.generate(hookSpecificOutput: { hookEventName: @kind, additionalContext: context })
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module Hooks
|
|
9
|
+
# Separate, bounded emitted-hint history; never touches refresh queue/leases.
|
|
10
|
+
class ContextState
|
|
11
|
+
MAX_BYTES = 131_072
|
|
12
|
+
MAX_SESSIONS = 32
|
|
13
|
+
MAX_HINTS = 32
|
|
14
|
+
|
|
15
|
+
def initialize(output_dir)
|
|
16
|
+
@file = File.join(output_dir, 'hook-context-state.json')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def emit(result)
|
|
20
|
+
return yield unless suppressible?(result)
|
|
21
|
+
|
|
22
|
+
key = Digest::SHA256.hexdigest(JSON.generate([result[:root], result[:session]]))
|
|
23
|
+
open_regular("#{@file}.lock", File::RDWR | File::CREAT) do |lock|
|
|
24
|
+
return unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
25
|
+
|
|
26
|
+
state = read_state
|
|
27
|
+
history = state.delete(key) || []
|
|
28
|
+
return if history.include?(result[:identity])
|
|
29
|
+
|
|
30
|
+
yield
|
|
31
|
+
state[key] = (history + [result[:identity]]).last(MAX_HINTS)
|
|
32
|
+
state.shift while state.size > MAX_SESSIONS
|
|
33
|
+
write_state(state)
|
|
34
|
+
end
|
|
35
|
+
rescue SystemCallError, IOError
|
|
36
|
+
# State contention/unavailability may skip an optional hint, never refresh.
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def suppressible?(result)
|
|
43
|
+
result[:identity] && result[:session]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def open_regular(path, flags)
|
|
47
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
48
|
+
File.open(path, flags | File::NONBLOCK, 0o600) do |file|
|
|
49
|
+
raise IOError unless file.stat.file?
|
|
50
|
+
|
|
51
|
+
yield file
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read_state
|
|
56
|
+
open_regular(@file, File::RDONLY) do |file|
|
|
57
|
+
return {} if file.stat.size > MAX_BYTES
|
|
58
|
+
|
|
59
|
+
state = JSON.parse(file.read(MAX_BYTES + 1))
|
|
60
|
+
return {} unless valid_state?(state)
|
|
61
|
+
|
|
62
|
+
state
|
|
63
|
+
end
|
|
64
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def valid_state?(state)
|
|
69
|
+
state.is_a?(Hash) && state.size <= MAX_SESSIONS && state.all? do |key, history|
|
|
70
|
+
digest?(key) && valid_history?(history)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_history?(history)
|
|
75
|
+
history.is_a?(Array) && history.size <= MAX_HINTS && history.all? { |id| digest?(id) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def digest?(value)
|
|
79
|
+
value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def write_state(state)
|
|
83
|
+
temporary = "#{@file}.tmp-#{SecureRandom.hex(12)}"
|
|
84
|
+
owned = nil
|
|
85
|
+
open_regular(temporary, File::WRONLY | File::CREAT | File::EXCL) do |file|
|
|
86
|
+
owned = file.stat
|
|
87
|
+
file.write(JSON.generate(state))
|
|
88
|
+
end
|
|
89
|
+
File.rename(temporary, @file)
|
|
90
|
+
ensure
|
|
91
|
+
remove_owned_temporary(temporary, owned) if owned
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def remove_owned_temporary(path, owned)
|
|
95
|
+
current = File.lstat(path)
|
|
96
|
+
File.unlink(path) if current.dev == owned.dev && current.ino == owned.ino
|
|
97
|
+
rescue Errno::ENOENT
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'woods/input_rules'
|
|
6
|
+
require 'woods/rake_helpers'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module Hooks
|
|
10
|
+
# The JSON argument is portable across host/container command boundaries.
|
|
11
|
+
# Exit 75 explicitly defers work; only successful consumption acknowledges it.
|
|
12
|
+
class Refresh
|
|
13
|
+
MAX_BYTES = 128 * 1024
|
|
14
|
+
MAX_EVENTS = 1000
|
|
15
|
+
OPERATIONS = %w[add update delete move].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(encoded)
|
|
18
|
+
raise ArgumentError, 'hook batch too large' if encoded.to_s.bytesize > MAX_BYTES * 2
|
|
19
|
+
|
|
20
|
+
@batch = JSON.parse(Base64.strict_decode64(encoded.to_s))
|
|
21
|
+
validate!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
output = File.expand_path(@batch.fetch('output'), RakeHelpers.woods_task_root)
|
|
26
|
+
if RakeHelpers.woods_daemon_coverage(output) == :running
|
|
27
|
+
warn 'Woods hook deferred: watch daemon is active; this batch has not been acknowledged.'
|
|
28
|
+
exit 75
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Rake::Task[:environment].invoke
|
|
32
|
+
require 'woods/extractor'
|
|
33
|
+
actions, paths = extraction_work
|
|
34
|
+
return if paths.empty?
|
|
35
|
+
|
|
36
|
+
extractor = Extractor.new(output_dir: output)
|
|
37
|
+
RakeHelpers.woods_with_extraction_lock(output) do
|
|
38
|
+
actions.include?(:full) ? extractor.extract_all : extractor.extract_changed(paths)
|
|
39
|
+
extractor.raise_on_publication_failure!
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def extraction_work
|
|
46
|
+
rules = InputRules.new
|
|
47
|
+
events = @batch.fetch('events')
|
|
48
|
+
actions = events.map { |event| rules.action(event.fetch('path'), operation: event.fetch('operation')) }
|
|
49
|
+
paths = events.zip(actions).filter_map { |event, action| event.fetch('path') unless action == :ignore }.uniq
|
|
50
|
+
[actions, paths]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate!
|
|
54
|
+
raise ArgumentError, 'unsupported hook batch' unless @batch.is_a?(Hash) && @batch['version'] == 1
|
|
55
|
+
raise ArgumentError, 'invalid hook output' unless valid_string?(@batch['output'])
|
|
56
|
+
|
|
57
|
+
events = @batch['events']
|
|
58
|
+
raise ArgumentError, 'invalid hook events' unless valid_events?(events)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def valid_events?(events)
|
|
62
|
+
events.is_a?(Array) && events.size.between?(1, MAX_EVENTS) && events.all? { |event| valid_event?(event) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def valid_event?(event)
|
|
66
|
+
return false unless event.is_a?(Hash) && OPERATIONS.include?(event['operation'])
|
|
67
|
+
|
|
68
|
+
path = event['path']
|
|
69
|
+
valid_string?(path) && !path.start_with?('/') && path.split('/', -1).none? do |part|
|
|
70
|
+
['', '.', '..'].include?(part)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_string?(value)
|
|
75
|
+
value.is_a?(String) && !value.empty? && !value.include?("\0") && value.bytesize <= MAX_BYTES
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'woods/input_rules'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Hooks
|
|
8
|
+
# Generated Bash predicate: the plugin also runs on hosts with no Ruby/bundle.
|
|
9
|
+
# This is a projection of authoritative rules, never a second path inventory.
|
|
10
|
+
class RuleProjection
|
|
11
|
+
def render
|
|
12
|
+
lines = [
|
|
13
|
+
'# Generated by script/generate-hook-rules; do not edit.',
|
|
14
|
+
'woods_input_action() {',
|
|
15
|
+
' local path="$1" operation="${2:-update}"'
|
|
16
|
+
]
|
|
17
|
+
restart_conditions.each { |condition| lines << " if #{condition}; then printf full; return; fi" }
|
|
18
|
+
rules.each do |rule|
|
|
19
|
+
lines << " if #{condition_for(rule)}; then"
|
|
20
|
+
lines << ' case "$operation" in delete|move) printf full ;; *) printf incremental ;; esac'
|
|
21
|
+
lines << ' return'
|
|
22
|
+
lines << ' fi'
|
|
23
|
+
end
|
|
24
|
+
lines.push(' printf ignore', '}', '').join("\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def rules
|
|
30
|
+
PathDispatcher.runtime_rules +
|
|
31
|
+
PathDispatcher.file_rules + PathDispatcher.whole_app_rules
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def restart_conditions
|
|
35
|
+
policy = ReloadPolicy
|
|
36
|
+
conditions = policy::RESTART_PATHS.map { |path| "[[ \"$path\" == #{quote(path)} ]]" }
|
|
37
|
+
conditions += policy::RESTART_DIRECTORIES.map { |dir| "[[ \"$path\" == #{quote("#{dir}/")}* ]]" }
|
|
38
|
+
conditions + policy::RESTART_PATH_PATTERNS.map do |pattern|
|
|
39
|
+
regex = pattern.source.gsub('\\A', '^').gsub('\\z', '$')
|
|
40
|
+
"[[ \"$path\" =~ #{regex} ]]"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def condition_for(rule)
|
|
45
|
+
alternatives = rule.exact_paths.to_a.map { |path| "[[ \"$path\" == #{quote(path)} ]]" }
|
|
46
|
+
filters = rule.exclude.to_a.map { |part| "[[ \"$path\" != *#{quote(part)}* ]]" }
|
|
47
|
+
filters += if rule.basenames
|
|
48
|
+
[either(rule.basenames.map { |name| "[[ \"${path##*/}\" == #{quote(name)} ]]" })]
|
|
49
|
+
else
|
|
50
|
+
directory_filters(rule)
|
|
51
|
+
end
|
|
52
|
+
alternatives << "{ #{filters.join(' && ')}; }"
|
|
53
|
+
either(alternatives)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def directory_filters(rule)
|
|
57
|
+
filters = []
|
|
58
|
+
filters << either(rule.extensions.map { |ext| "[[ \"$path\" == *#{quote(ext)} ]]" }) if rule.extensions
|
|
59
|
+
filters << "[[ \"$path\" == *#{quote(rule.require_segment)}* ]]" if rule.require_segment
|
|
60
|
+
filters << either(rule.dirs.to_a.map { |dir| directory_condition(dir, rule.recursive) })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def directory_condition(dir, recursive)
|
|
64
|
+
variable = recursive == false ? '${path%/*}' : '$path'
|
|
65
|
+
suffix = recursive == false ? quote(dir) : "#{quote("#{dir}/")}*"
|
|
66
|
+
"[[ \"#{variable}\" == #{suffix} ]]"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def either(conditions)
|
|
70
|
+
conditions.empty? ? 'false' : "{ #{conditions.join(' || ')}; }"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def quote(value)
|
|
74
|
+
Shellwords.escape(value)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'woods/reload_policy'
|
|
4
|
+
require 'woods/path_dispatcher'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
# Shared fresh-process action contract. Loading this class does not boot Rails.
|
|
8
|
+
# Extractor constants are needed only when deriving the dispatcher projection.
|
|
9
|
+
class InputRules
|
|
10
|
+
def action(path, operation: 'update')
|
|
11
|
+
return :full if ReloadPolicy.new.classify(path) == :restart
|
|
12
|
+
return :ignore unless PathDispatcher.new.relevant?(path)
|
|
13
|
+
|
|
14
|
+
# A removed runtime class may no longer be discoverable in a fresh boot.
|
|
15
|
+
# Full extraction also handles the old side of a rename conservatively.
|
|
16
|
+
%w[delete move].include?(operation) ? :full : :incremental
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -35,19 +35,12 @@ module Woods
|
|
|
35
35
|
# @param enabled [#call, nil] Optional request-time predicate. When it
|
|
36
36
|
# returns falsy the request passes through unguarded. Nil (the
|
|
37
37
|
# default) means always guard.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@warned_unusable_token = false
|
|
38
|
+
# Rails 6.0 forwards middleware options as a positional hash on Ruby 3.
|
|
39
|
+
# Delegate to explicit keywords to preserve required/unknown option checks.
|
|
40
|
+
def initialize(app, options = {}, **keywords)
|
|
41
|
+
raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
@token_source = token
|
|
46
|
-
else
|
|
47
|
-
validate_static_token!(token)
|
|
48
|
-
static_token = token.to_s
|
|
49
|
-
@token_source = -> { static_token }
|
|
50
|
-
end
|
|
43
|
+
initialize_options(app, **options, **keywords)
|
|
51
44
|
end
|
|
52
45
|
|
|
53
46
|
# Rack entry point. Out-of-scope requests (non-matching `path:` prefix
|
|
@@ -72,6 +65,21 @@ module Woods
|
|
|
72
65
|
|
|
73
66
|
private
|
|
74
67
|
|
|
68
|
+
def initialize_options(app, token:, path: nil, enabled: nil)
|
|
69
|
+
@app = app
|
|
70
|
+
@path = path
|
|
71
|
+
@enabled = enabled
|
|
72
|
+
@warned_unusable_token = false
|
|
73
|
+
|
|
74
|
+
if token.respond_to?(:call)
|
|
75
|
+
@token_source = token
|
|
76
|
+
else
|
|
77
|
+
validate_static_token!(token)
|
|
78
|
+
static_token = token.to_s
|
|
79
|
+
@token_source = -> { static_token }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
75
83
|
# @param env [Hash] Rack environment
|
|
76
84
|
# @return [Boolean] whether this request falls under the guard
|
|
77
85
|
def guard?(env)
|
|
@@ -6,6 +6,7 @@ require_relative 'errors'
|
|
|
6
6
|
require_relative 'bootstrap_state'
|
|
7
7
|
require_relative 'config_resolver'
|
|
8
8
|
require_relative 'provider_probe'
|
|
9
|
+
require_relative 'published_lexical_retriever'
|
|
9
10
|
require_relative '../index_artifact'
|
|
10
11
|
require_relative '../builder'
|
|
11
12
|
require_relative '../resolved_config'
|
|
@@ -128,6 +129,12 @@ module Woods
|
|
|
128
129
|
# credentials, dimension mismatch, unsupported artifact, or a missing
|
|
129
130
|
# artifact under WOODS_REQUIRE_INDEX=1).
|
|
130
131
|
def self.build_retriever(index_dir: nil)
|
|
132
|
+
mode = ENV.fetch('WOODS_RETRIEVAL_MODE', Woods.configuration.retrieval_mode.to_s)
|
|
133
|
+
unless %w[semantic lexical].include?(mode)
|
|
134
|
+
raise BootstrapError, "WOODS_RETRIEVAL_MODE must be semantic or lexical, got #{mode.inspect}"
|
|
135
|
+
end
|
|
136
|
+
return build_lexical_retriever(index_dir) if mode == 'lexical'
|
|
137
|
+
|
|
131
138
|
state = BootstrapState.new
|
|
132
139
|
state.mark(:hydrating)
|
|
133
140
|
|
|
@@ -161,6 +168,20 @@ module Woods
|
|
|
161
168
|
[retriever, state]
|
|
162
169
|
end
|
|
163
170
|
|
|
171
|
+
def self.build_lexical_retriever(index_dir)
|
|
172
|
+
state = BootstrapState.new
|
|
173
|
+
state.mark(:hydrating)
|
|
174
|
+
retriever = PublishedLexicalRetriever.new(index_dir: index_dir || Woods.configuration.output_dir,
|
|
175
|
+
default_budget: Woods.configuration.max_context_tokens)
|
|
176
|
+
retriever.warmup!
|
|
177
|
+
state.mark(:hydrated)
|
|
178
|
+
warn '[woods-mcp] lexical retrieval: hydrated (published extraction units; no embeddings)'
|
|
179
|
+
[retriever, state]
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
raise BootstrapError, "lexical index could not be loaded: #{e.class}: #{e.message}"
|
|
182
|
+
end
|
|
183
|
+
private_class_method :build_lexical_retriever
|
|
184
|
+
|
|
164
185
|
def self.static_source_map_without_embeddings?(artifact)
|
|
165
186
|
return false unless artifact
|
|
166
187
|
|
|
@@ -248,6 +269,10 @@ module Woods
|
|
|
248
269
|
zero_counts = { vectors: 0, metadata: 0, graph: 0 }
|
|
249
270
|
return refresh_reader_only(reader, zero_counts) unless retriever
|
|
250
271
|
|
|
272
|
+
if retriever.is_a?(PublishedLexicalRetriever)
|
|
273
|
+
return reload_lexical_stores!(retriever, index_dir, reader || retriever.reader, state, hooks)
|
|
274
|
+
end
|
|
275
|
+
|
|
251
276
|
target = swap_target(retriever)
|
|
252
277
|
return refresh_reader_only(reader, zero_counts) unless target
|
|
253
278
|
|
|
@@ -312,6 +337,43 @@ module Woods
|
|
|
312
337
|
# Refresh the reader's cached index state and answer the zero-count
|
|
313
338
|
# no-op (MCP-2).
|
|
314
339
|
#
|
|
340
|
+
def self.reload_lexical_stores!(retriever, index_dir, reader, state, hooks)
|
|
341
|
+
generation = Woods::Generation.new(output_dir: index_dir)
|
|
342
|
+
captured = generation.current
|
|
343
|
+
candidate = PublishedLexicalRetriever.new(index_dir: index_dir).warmup!
|
|
344
|
+
run_hook(hooks, :after_candidates)
|
|
345
|
+
lock = reload_extraction_lock(index_dir)
|
|
346
|
+
unless acquire_writer_lock_briefly(lock)
|
|
347
|
+
raise ReloadDegraded.new('lexical reload could not acquire the extraction writer lock',
|
|
348
|
+
generation: reader.loaded_generation || 0, stores: ['metadata'])
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
begin
|
|
352
|
+
reader.with_exclusive_generation do
|
|
353
|
+
identity = candidate.snapshot.first
|
|
354
|
+
expected = [captured.number, captured.token, generation.payload_dir(captured).to_s]
|
|
355
|
+
unless same_generation_marker?(captured, generation.current) &&
|
|
356
|
+
[identity.first.to_i, *identity.drop(1)] == expected
|
|
357
|
+
raise ReloadGenerationMoved.new('generation moved during lexical reload; nothing was swapped',
|
|
358
|
+
generation: reader.loaded_generation || 0, stores: ['metadata'])
|
|
359
|
+
end
|
|
360
|
+
counts = { vectors: 0, metadata: candidate.metadata_store.count, graph: 0 }
|
|
361
|
+
reader.reload!
|
|
362
|
+
retriever.install_snapshot!(candidate.snapshot)
|
|
363
|
+
state&.clear_reload_failure!
|
|
364
|
+
counts
|
|
365
|
+
end
|
|
366
|
+
ensure
|
|
367
|
+
lock.release
|
|
368
|
+
end
|
|
369
|
+
rescue ReloadDegraded
|
|
370
|
+
raise
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
raise ReloadDegraded.new("lexical snapshot reload failed: #{e.class}: #{e.message}",
|
|
373
|
+
generation: reader.loaded_generation || 0, stores: ['metadata'])
|
|
374
|
+
end
|
|
375
|
+
private_class_method :reload_lexical_stores!
|
|
376
|
+
|
|
315
377
|
# A store no-op is not an index no-op: the caller invoked `reload`
|
|
316
378
|
# because something on disk moved, and on a flat (pre-2.0) index
|
|
317
379
|
# {IndexReader} never self-refreshes, so skipping this reports
|