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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Hooks
|
|
8
|
+
# The plugin's independent process-group deadline also includes bundle/Ruby
|
|
9
|
+
# startup. This inner deadline keeps ordinary direct calls short as well.
|
|
10
|
+
class ContextCLI
|
|
11
|
+
MAX_INPUT_BYTES = 1_048_576
|
|
12
|
+
HELP = 'Usage: woods-hook-context SessionStart|PostToolUse < claude-event.json; ' \
|
|
13
|
+
'opt in with WOODS_HOOK_CONTEXT_ENABLED=1. Prefer the bounded plugin hook entry point.'
|
|
14
|
+
|
|
15
|
+
def self.run(kind, input: $stdin, output: $stdout)
|
|
16
|
+
return output.puts(HELP) || 0 if kind == '--help'
|
|
17
|
+
|
|
18
|
+
return 0 if ENV['WOODS_HOOKS_DISABLED'] == '1' || ENV['WOODS_HOOK_CONTEXT_ENABLED'] != '1'
|
|
19
|
+
return 0 unless %w[SessionStart PostToolUse].include?(kind)
|
|
20
|
+
|
|
21
|
+
Timeout.timeout(0.65) do
|
|
22
|
+
require 'woods'
|
|
23
|
+
require 'woods/hooks/context_hint'
|
|
24
|
+
require 'woods/hooks/context_state'
|
|
25
|
+
emit(kind, input, output)
|
|
26
|
+
end
|
|
27
|
+
0
|
|
28
|
+
rescue Timeout::Error, JSON::ParserError, ArgumentError, KeyError, TypeError, SystemCallError, IOError
|
|
29
|
+
# Missing/degraded/slow inputs are optional context, not permission to
|
|
30
|
+
# run extraction, read application data, or acknowledge queued edits.
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.emit(kind, input, output)
|
|
35
|
+
bytes = input.read(MAX_INPUT_BYTES + 1)
|
|
36
|
+
return if bytes.bytesize > MAX_INPUT_BYTES
|
|
37
|
+
|
|
38
|
+
event = JSON.parse(bytes)
|
|
39
|
+
return unless event.is_a?(Hash) && event['hook_event_name'] == kind
|
|
40
|
+
|
|
41
|
+
root = ENV['WOODS_HOOK_CONTEXT_ROOT'] || event.fetch('cwd')
|
|
42
|
+
directory = File.expand_path(ENV.fetch('WOODS_OUTPUT', 'tmp/woods'), root)
|
|
43
|
+
result = ContextHint.new(event: event, output_dir: directory, root: root).call
|
|
44
|
+
return unless result
|
|
45
|
+
|
|
46
|
+
ContextState.new(directory).emit(result) do
|
|
47
|
+
output.write(ContextOutput.new(kind).encode(result.fetch(:context)))
|
|
48
|
+
output.flush
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :emit
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'woods/input_rules'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Hooks
|
|
8
|
+
# Claude context events are deliberately separate from refresh queue records.
|
|
9
|
+
class ContextEvent
|
|
10
|
+
MAX_FILE_BYTES = 1_048_576
|
|
11
|
+
attr_reader :root, :path, :kind, :session
|
|
12
|
+
|
|
13
|
+
def initialize(input, root: nil)
|
|
14
|
+
validate_root!(input)
|
|
15
|
+
|
|
16
|
+
@logical_root = File.expand_path(input['cwd'])
|
|
17
|
+
@root = File.realpath(root || @logical_root)
|
|
18
|
+
@kind = input['hook_event_name']
|
|
19
|
+
@session = input['session_id'] if valid_string?(input['session_id'], 256)
|
|
20
|
+
return if kind == 'SessionStart'
|
|
21
|
+
|
|
22
|
+
raise ArgumentError unless kind == 'PostToolUse' && %w[Edit Write MultiEdit].include?(input['tool_name'])
|
|
23
|
+
|
|
24
|
+
@path = relative_path(input.fetch('tool_input').fetch('file_path'))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def eligible?
|
|
28
|
+
return true if kind == 'SessionStart'
|
|
29
|
+
|
|
30
|
+
require 'woods/extractor' unless defined?(Woods::Extractor::EXTRACTORS)
|
|
31
|
+
InputRules.new.action(path) != :ignore
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fingerprint
|
|
35
|
+
return 'orientation' if kind == 'SessionStart'
|
|
36
|
+
|
|
37
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
38
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
39
|
+
File.open(File.join(root, path), flags) do |file|
|
|
40
|
+
return nil unless file.stat.file? && file.stat.size <= MAX_FILE_BYTES
|
|
41
|
+
|
|
42
|
+
bytes = file.read(MAX_FILE_BYTES + 1)
|
|
43
|
+
return nil if bytes.bytesize > MAX_FILE_BYTES
|
|
44
|
+
|
|
45
|
+
Digest::SHA256.hexdigest(bytes)
|
|
46
|
+
end
|
|
47
|
+
rescue Errno::ENOENT
|
|
48
|
+
'missing'
|
|
49
|
+
rescue SystemCallError, IOError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def validate_root!(input)
|
|
56
|
+
raise ArgumentError unless input.is_a?(Hash) && valid_string?(input['cwd']) && input['cwd'].start_with?('/')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def valid_string?(value, limit = 4096)
|
|
60
|
+
value.is_a?(String) && !value.empty? && value.bytesize <= limit &&
|
|
61
|
+
value.valid_encoding? && !value.include?("\0")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def relative_path(value)
|
|
65
|
+
raise ArgumentError unless valid_string?(value)
|
|
66
|
+
|
|
67
|
+
prefix = [@logical_root, root].find { |base| value.start_with?("#{base}/") }
|
|
68
|
+
relative = prefix ? value.delete_prefix("#{prefix}/") : value
|
|
69
|
+
validate_parts!(relative)
|
|
70
|
+
validate_containment!(relative)
|
|
71
|
+
relative
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_parts!(relative)
|
|
75
|
+
parts = relative.split('/', -1)
|
|
76
|
+
raise ArgumentError if relative.start_with?('/') || parts.size > 64
|
|
77
|
+
raise ArgumentError if parts.any? { |part| ['', '.', '..'].include?(part) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_containment!(relative)
|
|
81
|
+
candidate = File.join(root, relative)
|
|
82
|
+
candidate = File.dirname(candidate) until File.exist?(candidate) || File.symlink?(candidate)
|
|
83
|
+
resolved = File.realpath(candidate)
|
|
84
|
+
raise ArgumentError unless resolved == root || resolved.start_with?("#{root}/")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'woods/mcp/index_reader'
|
|
4
|
+
require_relative 'context_event'
|
|
5
|
+
require_relative 'context_output'
|
|
6
|
+
require_relative 'context_impact'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module Hooks
|
|
10
|
+
# A bounded snapshot hint. The outer hook process owns the end-to-end deadline.
|
|
11
|
+
class ContextHint
|
|
12
|
+
MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
def initialize(event:, output_dir:, root: nil)
|
|
15
|
+
@event = ContextEvent.new(event, root: root)
|
|
16
|
+
@output = File.expand_path(output_dir, @event.root)
|
|
17
|
+
@renderer = ContextOutput.new(@event.kind)
|
|
18
|
+
rescue ArgumentError, KeyError, TypeError, SystemCallError
|
|
19
|
+
@event = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
return nil unless @event&.eligible?
|
|
24
|
+
|
|
25
|
+
@reader = MCP::IndexReader.new(@output)
|
|
26
|
+
@reader.with_pinned_generation do
|
|
27
|
+
@generation = @reader.generation_identity
|
|
28
|
+
raise ArgumentError if @reader.payload_dir.to_s == @output
|
|
29
|
+
|
|
30
|
+
check_artifacts!
|
|
31
|
+
freshness = source_freshness
|
|
32
|
+
text = if @event.kind == 'SessionStart'
|
|
33
|
+
orientation(freshness)
|
|
34
|
+
else
|
|
35
|
+
ContextImpact.new(@reader, @renderer).call(@event.path, @generation.first, freshness)
|
|
36
|
+
end
|
|
37
|
+
result(text)
|
|
38
|
+
end
|
|
39
|
+
rescue ArgumentError, KeyError, TypeError, NoMethodError, JSON::ParserError, SystemCallError, IOError
|
|
40
|
+
{ context: ContextOutput::UNAVAILABLE, identity: nil, session: nil }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def check_artifacts!
|
|
46
|
+
%w[manifest.json dependency_graph.json source_inputs.json].each do |name|
|
|
47
|
+
file = @reader.payload_dir.join(name)
|
|
48
|
+
next if name == 'source_inputs.json' && !file.exist?
|
|
49
|
+
|
|
50
|
+
raise ArgumentError unless file.file? && file.size <= MAX_ARTIFACT_BYTES
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def source_freshness
|
|
55
|
+
SourceInputs::Status.new(output_dir: @output, root: @event.root, payload_dir: @reader.payload_dir,
|
|
56
|
+
generation: @reader.loaded_generation).call.fetch('state')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def orientation(freshness)
|
|
60
|
+
@renderer.context("Woods index generation #{@generation.first}; source freshness: #{freshness}. " \
|
|
61
|
+
'Use woods_status, search and typed lookup; dependents explain:true suggests checks.')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def result(text)
|
|
65
|
+
fingerprint = @event.fingerprint
|
|
66
|
+
identity = if fingerprint && @event.session
|
|
67
|
+
Digest::SHA256.hexdigest(JSON.generate([@event.root, @event.path, @generation, fingerprint, text]))
|
|
68
|
+
end
|
|
69
|
+
{ context: text, identity: identity, session: @event.session, root: @event.root }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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
|
|
@@ -61,7 +54,8 @@ module Woods
|
|
|
61
54
|
|
|
62
55
|
expected = resolve_token
|
|
63
56
|
header = env['HTTP_AUTHORIZATION'].to_s
|
|
64
|
-
|
|
57
|
+
# Only the ASCII scheme changes case; do not normalize or trim the token.
|
|
58
|
+
presented = header.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header[7..] : nil
|
|
65
59
|
|
|
66
60
|
if expected && presented && Rack::Utils.secure_compare(expected, presented)
|
|
67
61
|
@app.call(env)
|
|
@@ -72,6 +66,21 @@ module Woods
|
|
|
72
66
|
|
|
73
67
|
private
|
|
74
68
|
|
|
69
|
+
def initialize_options(app, token:, path: nil, enabled: nil)
|
|
70
|
+
@app = app
|
|
71
|
+
@path = path
|
|
72
|
+
@enabled = enabled
|
|
73
|
+
@warned_unusable_token = false
|
|
74
|
+
|
|
75
|
+
if token.respond_to?(:call)
|
|
76
|
+
@token_source = token
|
|
77
|
+
else
|
|
78
|
+
validate_static_token!(token)
|
|
79
|
+
static_token = token.to_s
|
|
80
|
+
@token_source = -> { static_token }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
75
84
|
# @param env [Hash] Rack environment
|
|
76
85
|
# @return [Boolean] whether this request falls under the guard
|
|
77
86
|
def guard?(env)
|