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.
Files changed (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -1
  3. data/CONTRIBUTING.md +173 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +199 -14
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +117 -1
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +55 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/builder.rb +21 -5
  56. data/lib/woods/cache/cache_middleware.rb +28 -7
  57. data/lib/woods/cache/cache_store.rb +4 -5
  58. data/lib/woods/change_set.rb +5 -4
  59. data/lib/woods/console/credential_index.rb +20 -2
  60. data/lib/woods/console/credential_scanner.rb +14 -14
  61. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  62. data/lib/woods/console/embedded_executor.rb +1 -1
  63. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  64. data/lib/woods/console/rack_middleware.rb +22 -13
  65. data/lib/woods/console/server.rb +18 -16
  66. data/lib/woods/dependency_graph.rb +65 -13
  67. data/lib/woods/embedding/corpus.rb +94 -0
  68. data/lib/woods/embedding/indexer.rb +90 -46
  69. data/lib/woods/embedding/openai.rb +17 -6
  70. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  71. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  72. data/lib/woods/export/typed_reader.rb +56 -0
  73. data/lib/woods/extractor.rb +232 -137
  74. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  75. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  76. data/lib/woods/extractors/caching_extractor.rb +3 -1
  77. data/lib/woods/extractors/concern_extractor.rb +64 -6
  78. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  79. data/lib/woods/extractors/controller_extractor.rb +13 -4
  80. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  81. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  82. data/lib/woods/extractors/engine_extractor.rb +3 -1
  83. data/lib/woods/extractors/event_extractor.rb +4 -2
  84. data/lib/woods/extractors/factory_extractor.rb +3 -1
  85. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  86. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  87. data/lib/woods/extractors/job_extractor.rb +6 -19
  88. data/lib/woods/extractors/lib_extractor.rb +3 -1
  89. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  90. data/lib/woods/extractors/manager_extractor.rb +3 -1
  91. data/lib/woods/extractors/method_parameters.rb +53 -0
  92. data/lib/woods/extractors/middleware_argument.rb +65 -0
  93. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  94. data/lib/woods/extractors/migration_extractor.rb +3 -1
  95. data/lib/woods/extractors/model_extractor.rb +39 -33
  96. data/lib/woods/extractors/package_extractor.rb +24 -4
  97. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  98. data/lib/woods/extractors/policy_extractor.rb +3 -1
  99. data/lib/woods/extractors/poro_extractor.rb +3 -1
  100. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  101. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  102. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  103. data/lib/woods/extractors/route_extractor.rb +3 -1
  104. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  106. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  107. data/lib/woods/extractors/service_extractor.rb +3 -1
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  109. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  110. data/lib/woods/extractors/source_nesting.rb +1 -1
  111. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  112. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  113. data/lib/woods/extractors/validator_extractor.rb +3 -1
  114. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  116. data/lib/woods/gem_mapper.rb +2 -0
  117. data/lib/woods/git_history.rb +116 -0
  118. data/lib/woods/graph_analyzer.rb +35 -6
  119. data/lib/woods/hooks/context_cli.rb +54 -0
  120. data/lib/woods/hooks/context_event.rb +88 -0
  121. data/lib/woods/hooks/context_hint.rb +73 -0
  122. data/lib/woods/hooks/context_impact.rb +77 -0
  123. data/lib/woods/hooks/context_output.rb +47 -0
  124. data/lib/woods/hooks/context_state.rb +102 -0
  125. data/lib/woods/hooks/refresh.rb +79 -0
  126. data/lib/woods/hooks/rule_projection.rb +78 -0
  127. data/lib/woods/input_rules.rb +19 -0
  128. data/lib/woods/mcp/bearer_auth.rb +20 -12
  129. data/lib/woods/mcp/bootstrapper.rb +62 -0
  130. data/lib/woods/mcp/index_reader.rb +323 -160
  131. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  132. data/lib/woods/mcp/origin_guard.rb +17 -9
  133. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  134. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  135. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  136. data/lib/woods/mcp/search_results.rb +74 -0
  137. data/lib/woods/mcp/server.rb +158 -37
  138. data/lib/woods/mcp/tool_contract.rb +2 -0
  139. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  140. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  141. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  142. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  143. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  144. data/lib/woods/notion/exporter.rb +56 -17
  145. data/lib/woods/obsidian/destination_plan.rb +98 -0
  146. data/lib/woods/obsidian/name_mapper.rb +19 -3
  147. data/lib/woods/obsidian/note_builder.rb +19 -10
  148. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  149. data/lib/woods/operator/pipeline_guard.rb +18 -13
  150. data/lib/woods/path_dispatcher.rb +7 -1
  151. data/lib/woods/payload_store.rb +27 -26
  152. data/lib/woods/railtie.rb +3 -3
  153. data/lib/woods/railtie_support.rb +12 -12
  154. data/lib/woods/rake_helpers.rb +392 -0
  155. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  156. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  157. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  158. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  159. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  160. data/lib/woods/resilience/index_validator.rb +112 -23
  161. data/lib/woods/retrieval/context_assembler.rb +50 -15
  162. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  163. data/lib/woods/retrieval/lexical_index.rb +119 -0
  164. data/lib/woods/retrieval/ranker.rb +4 -2
  165. data/lib/woods/retrieval/scope.rb +108 -0
  166. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  167. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  168. data/lib/woods/retrieval/search_executor.rb +86 -27
  169. data/lib/woods/retrieval/source_evidence.rb +200 -0
  170. data/lib/woods/retriever.rb +98 -22
  171. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  172. data/lib/woods/session_tracer/middleware.rb +10 -12
  173. data/lib/woods/session_tracer/redis_store.rb +22 -6
  174. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  175. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  176. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  177. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  178. data/lib/woods/source_inputs/handoff.rb +102 -0
  179. data/lib/woods/source_inputs/launcher.rb +157 -0
  180. data/lib/woods/source_inputs/manifest.rb +124 -0
  181. data/lib/woods/source_inputs/private_key.rb +55 -0
  182. data/lib/woods/source_inputs/scanner.rb +171 -0
  183. data/lib/woods/source_inputs/scopes.rb +71 -0
  184. data/lib/woods/source_inputs/session.rb +214 -0
  185. data/lib/woods/source_inputs/status.rb +84 -0
  186. data/lib/woods/source_inputs/verifier.rb +107 -0
  187. data/lib/woods/storage/metadata_store.rb +25 -25
  188. data/lib/woods/storage/pgvector.rb +29 -8
  189. data/lib/woods/storage/qdrant.rb +17 -7
  190. data/lib/woods/storage/vector_store.rb +18 -6
  191. data/lib/woods/tasks.rb +3 -2
  192. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  193. data/lib/woods/unblocked/exporter.rb +59 -70
  194. data/lib/woods/version.rb +1 -1
  195. data/lib/woods/watch/boot_snapshot.rb +52 -0
  196. data/lib/woods/watch/daemon.rb +136 -28
  197. data/lib/woods/watch/listen_watcher.rb +4 -0
  198. data/lib/woods/watch/polling_watcher.rb +5 -1
  199. data/lib/woods/watch/status.rb +20 -15
  200. data/lib/woods/watch/tree_scan.rb +21 -13
  201. data/lib/woods/watch/watcher.rb +4 -1
  202. data/lib/woods.rb +50 -11
  203. data/plugin/.claude-plugin/plugin.json +1 -1
  204. data/plugin/hooks/adapters/normalize.jq +15 -0
  205. data/plugin/hooks/adapters/normalize.rb +63 -0
  206. data/plugin/hooks/hooks.json +20 -0
  207. data/plugin/hooks/woods-context.sh +50 -0
  208. data/plugin/hooks/woods-input-rules.sh +159 -0
  209. data/plugin/hooks/woods-opencode.mjs +65 -0
  210. data/plugin/hooks/woods-post-edit.sh +2 -225
  211. data/plugin/hooks/woods-refresh.sh +260 -0
  212. data/plugin/hooks/woods-session-start.sh +47 -55
  213. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  214. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  215. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  216. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  217. data/plugin/skills/woods-setup/SKILL.md +107 -6
  218. metadata +84 -5
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+ require 'openssl'
5
+ require 'woods/source_inputs/private_key'
6
+ require 'woods/source_inputs/scopes'
7
+ require 'woods/watch/watcher'
8
+
9
+ module Woods
10
+ module SourceInputs
11
+ # Unlike the watcher's best-effort walk, every incomplete read is evidence
12
+ # against claiming complete source coverage. No Rails application is booted.
13
+ class Scanner # rubocop:disable Metrics/ClassLength -- one bounded traversal owns its counters and read checks
14
+ DEFAULT_LIMITS = { max_files: 50_000, max_bytes: 128 * 1024 * 1024, max_seconds: 10.0 }.freeze
15
+ class BudgetExceeded < StandardError; end
16
+
17
+ attr_reader :root, :scopes
18
+
19
+ def initialize(root:, output_dir:, key:, scopes: Scopes.new, **limits)
20
+ @root = File.expand_path(root.to_s)
21
+ @output_dir = File.expand_path(output_dir.to_s)
22
+ @key = key
23
+ @scopes = scopes
24
+ @limits = DEFAULT_LIMITS.merge(limits)
25
+ return if @limits.values.all? { |value| value.is_a?(Numeric) && value.finite? && value.positive? }
26
+
27
+ raise ArgumentError, 'source scan limits must be finite and positive'
28
+ end
29
+
30
+ def call
31
+ @started = monotonic
32
+ @files = {}
33
+ @scope_paths = {}
34
+ @errors = []
35
+ @bytes = 0
36
+ @visited = 0
37
+ walk
38
+ result
39
+ rescue BudgetExceeded => e
40
+ error(e.message)
41
+ result
42
+ rescue SystemCallError, IOError
43
+ error('source_tree_unavailable')
44
+ result
45
+ end
46
+
47
+ private
48
+
49
+ def walk
50
+ raise Errno::ENOENT, @root unless File.directory?(@root)
51
+
52
+ @resolved_root = File.realpath(@root)
53
+ Find.find(@root, ignore_error: false) do |path|
54
+ next if path == @root
55
+
56
+ check_budget!
57
+ relative = path.delete_prefix("#{@root}/")
58
+ if ignored_directory?(path, relative)
59
+ Find.prune if File.directory?(path)
60
+ next
61
+ end
62
+ stat = File.lstat(path)
63
+ next if stat.directory?
64
+
65
+ @visited += 1
66
+ check_budget!
67
+ visit(path, relative, stat)
68
+ end
69
+ end
70
+
71
+ def ignored_directory?(path, relative)
72
+ return true if path == @output_dir || path.start_with?("#{@output_dir}/")
73
+ return false if declared_path?(relative)
74
+
75
+ Watch::TreeScan.hidden?(relative) ||
76
+ Watch::TreeScan.ignored?(relative, Watch::Watcher::DEFAULT_IGNORED_DIRECTORIES)
77
+ end
78
+
79
+ def declared_path?(relative)
80
+ @scopes.extra_roots.any? do |root|
81
+ relative == root || relative.start_with?("#{root}/") || root.start_with?("#{relative}/")
82
+ end
83
+ end
84
+
85
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
86
+ # Refuse opaque directories, external paths and unreadable inputs separately.
87
+ def visit(path, relative, stat)
88
+ if stat.symlink? && File.directory?(path)
89
+ error('unverified_symlink_directory', relative)
90
+ return
91
+ end
92
+ consumers = @scopes.for_path(relative)
93
+ return if consumers.empty?
94
+
95
+ real = File.realpath(path)
96
+ unless real.start_with?("#{@resolved_root}/")
97
+ error('external_source_path', relative)
98
+ return
99
+ end
100
+ identity = read_identity(path, real, stamp(File.stat(real)))
101
+ return unless identity
102
+
103
+ @files[relative] = identity
104
+ consumers.each { |scope| (@scope_paths[scope] ||= []) << relative }
105
+ rescue SystemCallError, IOError
106
+ error('source_file_unreadable', relative)
107
+ end
108
+
109
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
110
+
111
+ def read_identity(path, resolved, expected) # rubocop:disable Metrics/CyclomaticComplexity -- stable descriptor and original resolution checks
112
+ flags = File::RDONLY | File::NONBLOCK
113
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
114
+ File.open(resolved, flags) do |file|
115
+ before = stamp(file.stat)
116
+ unless before == expected && File.realpath(path) == resolved
117
+ error('source_changed_during_read', path.delete_prefix("#{@root}/"))
118
+ return nil
119
+ end
120
+ unless file.stat.file?
121
+ error('nonregular_source', path.delete_prefix("#{@root}/"))
122
+ return nil
123
+ end
124
+ identity = hash_stream(file)
125
+ unless before == stamp(file.stat) && before == stamp(File.stat(resolved)) && File.realpath(path) == resolved
126
+ error('source_changed_during_read', path.delete_prefix("#{@root}/"))
127
+ return nil
128
+ end
129
+ identity
130
+ end
131
+ end
132
+
133
+ def hash_stream(file)
134
+ digest = OpenSSL::HMAC.new(@key.bytes, OpenSSL::Digest.new('SHA256'))
135
+ while (chunk = file.read(16_384))
136
+ @bytes += chunk.bytesize
137
+ check_budget!
138
+ digest.update(chunk)
139
+ end
140
+ digest.hexdigest
141
+ end
142
+
143
+ def stamp(stat)
144
+ [stat.dev, stat.ino, stat.size, stat.mtime.to_r, stat.ctime.to_r]
145
+ end
146
+
147
+ def check_budget!
148
+ raise BudgetExceeded, 'scan_time_budget' if monotonic - @started > @limits[:max_seconds]
149
+ raise BudgetExceeded, 'scan_file_budget' if @visited > @limits[:max_files]
150
+ raise BudgetExceeded, 'scan_byte_budget' if @bytes > @limits[:max_bytes]
151
+ end
152
+
153
+ def error(reason, path = nil)
154
+ @errors << { 'reason' => reason, 'path' => path }.compact if @errors.size < 20
155
+ end
156
+
157
+ def result
158
+ { 'root' => @root, 'key_id' => @key.identifier, 'rules' => @scopes.fingerprint,
159
+ 'extra_roots' => @scopes.extra_roots, 'files' => @files.sort.to_h,
160
+ 'scope_paths' => @scope_paths.transform_values(&:sort).sort.to_h,
161
+ 'errors' => @errors, 'complete' => @errors.empty?,
162
+ 'metrics' => { 'visited_files' => @visited, 'hashed_files' => @files.size,
163
+ 'bytes' => @bytes, 'elapsed_ms' => ((monotonic - @started) * 1000).round(3) } }
164
+ end
165
+
166
+ def monotonic
167
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'woods/input_rules'
6
+ require 'woods/watch/watcher'
7
+ require 'woods/watch/tree_scan'
8
+
9
+ module Woods
10
+ module SourceInputs
11
+ # Keep each consumer's provenance separate. A whole-app event scan must not
12
+ # bless retained service units that happen to share the same source file.
13
+ class Scopes
14
+ BOOT_FILES = %w[Rakefile config.ru].freeze
15
+
16
+ attr_reader :extra_roots
17
+
18
+ def initialize(extra_roots: [])
19
+ require 'woods/extractor' unless defined?(Woods::Extractor::EXTRACTORS)
20
+ @extra_roots = Array(extra_roots).map { |path| validate_root(path) }.uniq.sort
21
+ @file_rules = PathDispatcher.file_rules
22
+ @whole_rules = PathDispatcher.whole_app_rules
23
+ @policy = ReloadPolicy.new
24
+ end
25
+
26
+ def for_path(path) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- each independent consumer contributes a scope
27
+ scopes = []
28
+ action = @policy.classify(path)
29
+ scopes << 'boot' if boot_path?(path, action)
30
+ scopes << 'runtime' if runtime_path?(path, action)
31
+ @file_rules.each { |rule| scopes << "file:#{rule.extractor_key}" if rule.matches?(path) }
32
+ @whole_rules.each { |rule| scopes << "whole:#{rule.extractor_key}" if rule.matches?(path) }
33
+ scopes << 'declared' if @extra_roots.any? { |dir| path.start_with?("#{dir}/") }
34
+ scopes.uniq.sort
35
+ end
36
+
37
+ def boot_path?(path, action)
38
+ return true if action == :restart || BOOT_FILES.include?(path)
39
+ return false unless action == :ignore
40
+
41
+ path.match?(%r{\Aconfig/.+\.rb\z}) || path.match?(%r{\A[^/]+\.gemspec\z})
42
+ end
43
+
44
+ def runtime_path?(path, action)
45
+ action == :reload && path.end_with?('.rb') && path.match?(%r{\A(?:app|lib)/})
46
+ end
47
+
48
+ def fingerprint
49
+ data = [@file_rules.map(&:to_h), @whole_rules.map(&:to_h),
50
+ PathDispatcher.runtime_rules.map(&:to_h),
51
+ ReloadPolicy.constants(false).sort.to_h { |name| [name, ReloadPolicy.const_get(name)] },
52
+ Watch::Watcher::DEFAULT_IGNORED_DIRECTORIES, Watch::TreeScan::NOT_IGNORED_DOTFILES,
53
+ Watch::TreeScan::NOT_IGNORED_DOTFILE_PREFIXES, 'bounded_no_symlink_dirs_v1',
54
+ 'config_ruby_and_root_gemspecs_v1', BOOT_FILES, @extra_roots]
55
+ Digest::SHA256.hexdigest(JSON.generate(data))
56
+ end
57
+
58
+ private
59
+
60
+ def validate_root(value)
61
+ path = value.to_s.delete_suffix('/')
62
+ if path.empty? || path.start_with?('/') || path.include?("\0") ||
63
+ path.split('/', -1).any? { |part| ['', '.', '..'].include?(part) }
64
+ raise ArgumentError, 'source roots must be relative directories under the application root'
65
+ end
66
+
67
+ path
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'woods/source_inputs/scanner'
5
+ require 'woods/source_inputs/manifest'
6
+ require 'woods/source_inputs/handoff'
7
+ require 'woods/source_inputs/consumer_errors'
8
+
9
+ module Woods
10
+ module SourceInputs
11
+ # Records successful consumers, never merely paths found by a final scan.
12
+ # An incremental run starts from the preceding generation's scope ledger.
13
+ class Session # rubocop:disable Metrics/ClassLength -- per-run consumer ledger and publication coverage
14
+ def initialize(root:, output_dir:, baseline_path:, operation:)
15
+ @root = File.expand_path(root.to_s)
16
+ @output = File.expand_path(output_dir.to_s)
17
+ @operation = operation.to_s
18
+ @errors = []
19
+ @unverified = []
20
+ @baseline = read_baseline(baseline_path)
21
+ @rules = Scopes.new(extra_roots: declared_roots)
22
+ @key = PrivateKey.new(output_dir: @output, create: true)
23
+ handoff = Handoff.read(root: @root, output_dir: @output, operation: @operation, rules: @rules.fingerprint,
24
+ key_id: @key.identifier)
25
+ @snapshot = handoff || scan
26
+ @verified_boot = !handoff.nil?
27
+ @identities = initial_identities
28
+ rescue PrivateKey::Unavailable => e
29
+ @snapshot = unavailable_snapshot(e.message)
30
+ @identities = {}
31
+ @verified_boot = false
32
+ end
33
+
34
+ def consume_file(key, path)
35
+ consume_path("file:#{key}", path)
36
+ consume_path("unit:#{key}", path) if @identities.fetch("unit:#{key}", {}).key?(relative_path(path))
37
+ end
38
+
39
+ def consume_unit(key, path)
40
+ consume_path("unit:#{key}", path)
41
+ end
42
+
43
+ def consume_deleted(path) # rubocop:disable Metrics/CyclomaticComplexity -- deletion preserves boot provenance
44
+ relative = relative_path(path)
45
+ return if relative.nil? || @snapshot.fetch('files').key?(relative)
46
+
47
+ @rules.for_path(relative).each { |scope| @identities[scope]&.delete(relative) unless scope == 'boot' }
48
+ @identities.each { |scope, paths| paths.delete(relative) if scope.start_with?('unit:') }
49
+ end
50
+
51
+ def consume_extractor(key, units)
52
+ ["file:#{key}", "whole:#{key}"].each { |scope| replace_scope(scope) }
53
+ @identities["unit:#{key}"] = {}
54
+ Array(units).each { |unit| consume_unit(key, unit.file_path) }
55
+ end
56
+
57
+ def unverified(scope)
58
+ @unverified << scope.to_s
59
+ end
60
+
61
+ def full_units(results, consumers: {})
62
+ results.each do |key, units|
63
+ if ConsumerErrors.failed?(consumers[key])
64
+ unverified("extractor:#{key}")
65
+ else
66
+ consume_extractor(key, units)
67
+ end
68
+ end
69
+ end
70
+
71
+ def finish(generation:, eager_load_complete:)
72
+ verify_stability
73
+ verify_loaded_source_coverage
74
+ if @operation != 'full'
75
+ preserve_baseline_coverage
76
+ # Fresh discovery is not proof that every retained runtime fact was
77
+ # serialized again. Keep this limitation explicit even when bytes match.
78
+ @unverified << 'runtime_consumption' if runtime_inputs_changed?
79
+ replace_scope('runtime') if @verified_boot && eager_load_complete
80
+ end
81
+ @unverified << 'incomplete_eager_load' unless eager_load_complete
82
+ Manifest.build(snapshot: @snapshot, scopes: @identities, boot_verified: verified_coverage?,
83
+ generation: generation, errors: @errors, unverified_scopes: @unverified)
84
+ end
85
+
86
+ private
87
+
88
+ def declared_roots
89
+ roots = Handoff.extra_roots
90
+ roots.empty? && @baseline ? @baseline.data.fetch('extra_roots') : roots
91
+ end
92
+
93
+ def read_baseline(path)
94
+ return nil if path.nil? || !File.file?(path)
95
+
96
+ File.open(path, File::RDONLY | File::NONBLOCK) do |file|
97
+ raise Manifest::Invalid, 'source_manifest_too_large' if file.stat.size > Manifest::MAX_BYTES
98
+
99
+ Manifest.parse(file.read(Manifest::MAX_BYTES + 1))
100
+ end
101
+ rescue Manifest::Invalid, SystemCallError, IOError
102
+ @errors << { 'reason' => 'invalid_source_baseline' } unless @operation == 'full'
103
+ nil
104
+ end
105
+
106
+ def initial_identities
107
+ return snapshot_identities if @operation == 'full'
108
+ return @baseline.expanded if compatible_baseline?
109
+
110
+ @errors << { 'reason' => 'missing_or_incompatible_source_baseline' }
111
+ {}
112
+ end
113
+
114
+ def compatible_baseline?
115
+ @baseline && @baseline.data['key_id'] == @snapshot['key_id'] &&
116
+ @baseline.data['rules'] == @snapshot['rules'] && @baseline.data['root'] == @root
117
+ end
118
+
119
+ def snapshot_identities
120
+ @snapshot.fetch('scope_paths').transform_values do |paths|
121
+ paths.to_h { |path| [path, @snapshot.fetch('files').fetch(path)] }
122
+ end
123
+ end
124
+
125
+ def consume_path(scope, path)
126
+ relative = relative_path(path)
127
+ return if relative.nil?
128
+
129
+ identity = @snapshot.fetch('files')[relative]
130
+ if identity
131
+ (@identities[scope] ||= {})[relative] = identity
132
+ elsif File.exist?(File.join(@root, relative))
133
+ @errors << { 'reason' => 'uncaptured_source_path', 'path' => relative }
134
+ else
135
+ @identities[scope]&.delete(relative)
136
+ end
137
+ end
138
+
139
+ def relative_path(path)
140
+ return nil if path.nil? || path.to_s.empty?
141
+
142
+ absolute = File.expand_path(path.to_s, @root)
143
+ # Installed gem/framework source is outside application-source coverage.
144
+ return nil unless absolute.start_with?("#{@root}/")
145
+
146
+ absolute.delete_prefix("#{@root}/")
147
+ end
148
+
149
+ def replace_scope(scope)
150
+ paths = @snapshot.fetch('scope_paths').fetch(scope, [])
151
+ @identities[scope] = paths.to_h { |path| [path, @snapshot.fetch('files').fetch(path)] }
152
+ end
153
+
154
+ def runtime_inputs_changed?
155
+ return true unless compatible_baseline?
156
+
157
+ before = @baseline.expanded.fetch('runtime', {})
158
+ now = snapshot_identities.fetch('runtime', {})
159
+ before != now
160
+ end
161
+
162
+ def verified_coverage?
163
+ return @verified_boot if @operation == 'full'
164
+
165
+ !!(@verified_boot && compatible_baseline? && @baseline.data['boot_verified'])
166
+ end
167
+
168
+ def preserve_baseline_coverage
169
+ return unless @baseline
170
+
171
+ @errors.concat(@baseline.data.fetch('errors'))
172
+ @unverified.concat(@baseline.data.fetch('unverified_scopes'))
173
+ @errors << { 'reason' => 'incomplete_source_baseline' } unless @baseline.data['complete']
174
+ end
175
+
176
+ def verify_stability
177
+ return unless @key
178
+
179
+ after = scan
180
+ @errors.concat(after.fetch('errors'))
181
+ before_files = @snapshot.fetch('files')
182
+ after_files = after.fetch('files')
183
+ changed = (before_files.keys | after_files.keys).reject { |path| before_files[path] == after_files[path] }
184
+ changed.first(30).each { |path| @errors << { 'reason' => 'source_changed_during_extraction', 'path' => path } }
185
+ end
186
+
187
+ # A custom loader can consume Ruby outside the standard dispatch roots.
188
+ # Loaded-feature evidence can disprove coverage, never expand a capture
189
+ # after boot and retroactively claim those bytes were consumed.
190
+ def verify_loaded_source_coverage
191
+ paths = $LOADED_FEATURES.filter_map do |path|
192
+ # Ruby 3 also lists built-in pseudo features, such as thread.rb.
193
+ next unless path.start_with?('/') || File.file?(File.expand_path(path, @root))
194
+
195
+ relative_path(path)
196
+ end.uniq
197
+ missing = paths - @snapshot.fetch('files').keys
198
+ missing.first(30).each do |path|
199
+ @errors << { 'reason' => 'loaded_source_outside_coverage', 'path' => path }
200
+ end
201
+ end
202
+
203
+ def scan
204
+ Scanner.new(root: @root, output_dir: @output, key: @key, scopes: @rules).call
205
+ end
206
+
207
+ def unavailable_snapshot(reason)
208
+ { 'root' => @root, 'key_id' => '0' * 64, 'rules' => @rules.fingerprint,
209
+ 'extra_roots' => @rules.extra_roots, 'files' => {}, 'scope_paths' => {}, 'complete' => false,
210
+ 'errors' => [{ 'reason' => reason }], 'metrics' => {} }
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'woods/generation'
5
+ require 'woods/source_inputs/verifier'
6
+
7
+ module Woods
8
+ module SourceInputs
9
+ # Shared CLI/hook/MCP reader. A caller serving an older immutable payload
10
+ # passes its own directory and generation, never the newest marker's proof.
11
+ class Status
12
+ BUDGETS = { 'quick' => 0.25, 'deep' => 5.0 }.freeze
13
+
14
+ def self.from_transport(encoded)
15
+ raise ArgumentError, 'source status options are too large' if encoded.to_s.bytesize > 16_384
16
+
17
+ options = encoded.nil? ? {} : JSON.parse(Base64.strict_decode64(encoded))
18
+ unless options.is_a?(Hash) && (options.keys - %w[output root mode]).empty? && options.values.all?(String)
19
+ raise ArgumentError, 'source status options must contain only output, root and mode strings'
20
+ end
21
+
22
+ new(output_dir: options.fetch('output', ENV.fetch('WOODS_OUTPUT', 'tmp/woods')),
23
+ root: options['root'], mode: options.fetch('mode', 'quick')).call
24
+ rescue JSON::ParserError
25
+ raise ArgumentError, 'invalid source status options'
26
+ end
27
+
28
+ def initialize(output_dir:, payload_dir: nil, generation: nil, root: nil, mode: 'quick')
29
+ raise ArgumentError, 'source status mode must be quick or deep' unless BUDGETS.key?(mode)
30
+
31
+ @output = File.expand_path(output_dir.to_s)
32
+ @root = root
33
+ @mode = mode
34
+ @generation = generation
35
+ @payload = payload_dir
36
+ end
37
+
38
+ def call
39
+ return unknown(@resolution_error || 'atomic_source_manifest_unavailable') unless resolve_payload
40
+
41
+ flags = File::RDONLY | File::NONBLOCK
42
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
43
+ manifest = File.open(File.join(@payload, Manifest::FILE_NAME), flags) do |file|
44
+ return unknown('invalid_source_manifest') unless file.stat.file?
45
+ return unknown('source_manifest_too_large') if file.stat.size > Manifest::MAX_BYTES
46
+
47
+ Manifest.parse(file.read(Manifest::MAX_BYTES + 1))
48
+ end
49
+ Verifier.new(manifest: manifest, output_dir: @output, root: @root,
50
+ generation: @generation, max_seconds: BUDGETS.fetch(@mode)).call.merge('check' => @mode)
51
+ rescue Errno::ENOENT
52
+ unknown('source_manifest_unavailable')
53
+ rescue Manifest::Invalid, SystemCallError, IOError
54
+ unknown('invalid_source_manifest')
55
+ end
56
+
57
+ private
58
+
59
+ def resolve_payload
60
+ generation = Generation.new(output_dir: @output)
61
+ marker = if @payload
62
+ Generation::Marker.new(payload: @payload.to_s)
63
+ else
64
+ generation.current.tap { |current| @generation = current.number }
65
+ end
66
+ return false unless marker.payload.is_a?(String)
67
+
68
+ resolved = generation.payload_dir(marker)
69
+ return false if resolved == generation.root
70
+
71
+ @payload = resolved
72
+ true
73
+ rescue TypeError, NoMethodError
74
+ @resolution_error = 'invalid_generation'
75
+ false
76
+ end
77
+
78
+ def unknown(reason)
79
+ { 'state' => 'unknown', 'mode' => 'content', 'check' => @mode, 'generation' => @generation,
80
+ 'checked_at' => Time.now.utc.iso8601, 'complete' => false, 'reasons' => [reason] }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'woods/source_inputs/scanner'
5
+ require 'woods/source_inputs/manifest'
6
+
7
+ module Woods
8
+ module SourceInputs
9
+ # Bounded, byte-verifying source evidence. No stat-only result is called
10
+ # current: the same-size/same-mtime case is deliberately read and hashed.
11
+ class Verifier
12
+ DEFAULT_SECONDS = 0.25
13
+ SUMMARY_LIMIT = 30
14
+
15
+ # rubocop:disable Metrics/ParameterLists -- caller chooses identity, served generation and bounded scan
16
+ def initialize(manifest:, output_dir:, root: nil, generation: nil, max_seconds: DEFAULT_SECONDS, **limits)
17
+ @manifest = manifest.is_a?(Manifest) ? manifest : Manifest.new(manifest)
18
+ @output_dir = output_dir
19
+ @root = root || @manifest.data.fetch('root')
20
+ @generation = generation
21
+ @limits = limits.merge(max_seconds: max_seconds)
22
+ end
23
+
24
+ # rubocop:enable Metrics/ParameterLists
25
+
26
+ def call # rubocop:disable Metrics/AbcSize -- cheap refusal checks precede the only source scan
27
+ return unknown('generation_mismatch') if @generation && @generation != @manifest.data['generation']
28
+ return unknown('source_root_unavailable') unless File.directory?(@root)
29
+
30
+ key = PrivateKey.new(output_dir: @output_dir)
31
+ return unknown('identity_key_mismatch') unless key.identifier == @manifest.data.fetch('key_id')
32
+
33
+ scopes = Scopes.new(extra_roots: @manifest.data.fetch('extra_roots'))
34
+ return unknown('input_rules_changed') unless scopes.fingerprint == @manifest.data.fetch('rules')
35
+
36
+ current = Scanner.new(root: @root, output_dir: @output_dir, key: key, scopes: scopes, **@limits).call
37
+ compare(current)
38
+ rescue PrivateKey::Unavailable => e
39
+ unknown(e.message)
40
+ rescue SystemCallError, IOError
41
+ unknown('source_root_unavailable')
42
+ end
43
+
44
+ private
45
+
46
+ def compare(current)
47
+ previous = @manifest.expanded
48
+ changes = { 'added' => [], 'changed' => [], 'removed' => [] }
49
+ previous.each { |scope, paths| compare_scope(scope, paths, changes, current) }
50
+ add_new_scopes(previous, changes, current) if complete?(current)
51
+ reasons = coverage_reasons(current)
52
+ summarized(changes, reasons, current)
53
+ end
54
+
55
+ def complete?(current)
56
+ @manifest.data['complete'] && current['complete']
57
+ end
58
+
59
+ def compare_scope(scope, paths, changes, current)
60
+ paths.each do |path, identity|
61
+ now = current.fetch('files')[path]
62
+ changes['changed'] << path if now && now != identity
63
+ changes['removed'] << path if now.nil? && current['complete']
64
+ end
65
+ return unless complete?(current)
66
+
67
+ changes['added'].concat(current.fetch('scope_paths').fetch(scope, []) - paths.keys)
68
+ end
69
+
70
+ def add_new_scopes(previous, changes, current)
71
+ new_scopes = current.fetch('scope_paths').keys - previous.keys
72
+ new_scopes.each { |scope| changes['added'].concat(current.fetch('scope_paths').fetch(scope)) }
73
+ end
74
+
75
+ def coverage_reasons(current)
76
+ reasons = (@manifest.data.fetch('errors') + current.fetch('errors')).map { |error| error['reason'] }.compact
77
+ reasons << 'incomplete_capture' unless @manifest.data['complete']
78
+ reasons << 'incomplete_verification' unless current['complete']
79
+ reasons << 'unverified_boot_boundary' unless @manifest.data['boot_verified']
80
+ reasons << 'unverified_consumption_scopes' unless @manifest.data.fetch('unverified_scopes').empty?
81
+ reasons.uniq
82
+ end
83
+
84
+ def summarized(changes, reasons, current)
85
+ changes.transform_values! { |paths| paths.uniq.sort }
86
+ counts = changes.transform_values(&:size)
87
+ { 'state' => state_for(counts, reasons), 'mode' => 'content', 'generation' => @manifest.data['generation'],
88
+ 'checked_at' => Time.now.utc.iso8601, 'reasons' => reasons,
89
+ 'complete' => current['complete'] && @manifest.data['complete'],
90
+ 'counts' => counts, 'truncated' => counts.values.any? { |count| count > SUMMARY_LIMIT },
91
+ 'changes' => changes.transform_values { |paths| paths.first(SUMMARY_LIMIT) },
92
+ 'metrics' => current.fetch('metrics') }
93
+ end
94
+
95
+ def state_for(counts, reasons)
96
+ return 'drifted' if counts.values.any?(&:positive?)
97
+
98
+ reasons.empty? ? 'current' : 'unknown'
99
+ end
100
+
101
+ def unknown(reason)
102
+ { 'state' => 'unknown', 'mode' => 'content', 'generation' => @manifest.data['generation'],
103
+ 'checked_at' => Time.now.utc.iso8601, 'reasons' => [reason], 'complete' => false }
104
+ end
105
+ end
106
+ end
107
+ end