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.
Files changed (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. 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
- def initialize(app, token:, path: nil, enabled: nil)
39
- @app = app
40
- @path = path
41
- @enabled = enabled
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
- if token.respond_to?(:call)
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
- presented = header.start_with?('Bearer ') ? header.sub(/\ABearer /, '') : nil
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)