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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module SourceInputs
7
+ # Compact wire format: consumers refer to a shared identity table. Internal
8
+ # mutation uses expanded digests so table indices never leak across runs.
9
+ class Manifest
10
+ FILE_NAME = 'source_inputs.json'
11
+ VERSION = 1
12
+ MAX_BYTES = 16 * 1024 * 1024
13
+ class Invalid < StandardError; end
14
+
15
+ attr_reader :data
16
+
17
+ def self.parse(bytes)
18
+ raise Invalid, 'source_manifest_too_large' if bytes.bytesize > MAX_BYTES
19
+
20
+ new(JSON.parse(bytes))
21
+ rescue JSON::ParserError
22
+ raise Invalid, 'invalid_source_manifest'
23
+ end
24
+
25
+ def initialize(data)
26
+ @data = data
27
+ validate!
28
+ end
29
+
30
+ def expanded
31
+ identities = @data.fetch('identities')
32
+ @data.fetch('scopes').transform_values do |paths|
33
+ paths.transform_values { |index| identities.fetch(index) }
34
+ end
35
+ end
36
+
37
+ # Explicit wire fields keep incomplete coverage separate from boot provenance.
38
+ # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
39
+ def self.build(snapshot:, scopes:, boot_verified:, generation:, errors: [], unverified_scopes: [])
40
+ identities = scopes.values.flat_map(&:values).uniq.sort
41
+ indices = identities.each_with_index.to_h
42
+ new('version' => VERSION, 'generation' => generation, 'root' => snapshot.fetch('root'),
43
+ 'key_id' => snapshot.fetch('key_id'), 'rules' => snapshot.fetch('rules'),
44
+ 'extra_roots' => snapshot.fetch('extra_roots'), 'boot_verified' => boot_verified,
45
+ 'complete' => snapshot.fetch('complete'), 'errors' => (snapshot.fetch('errors') + errors).uniq,
46
+ 'unverified_scopes' => unverified_scopes.uniq.sort,
47
+ 'identities' => identities, 'metrics' => snapshot.fetch('metrics'),
48
+ 'scopes' => scopes.sort.to_h.transform_values do |paths|
49
+ paths.sort.to_h.transform_values { |digest| indices.fetch(digest) }
50
+ end)
51
+ end
52
+
53
+ # rubocop:enable Metrics/AbcSize, Metrics/ParameterLists
54
+
55
+ private
56
+
57
+ def validate!
58
+ raise Invalid, 'unsupported_source_manifest' unless supported_version?
59
+ raise Invalid, 'invalid_source_manifest_header' unless valid_header?
60
+
61
+ validate_tables!
62
+ return if @data['extra_roots'].all? { |path| valid_path?(path) }
63
+
64
+ raise Invalid, 'invalid_source_roots'
65
+ end
66
+
67
+ def validate_tables!
68
+ identities = @data['identities']
69
+ scopes = @data['scopes']
70
+ unless identities.is_a?(Array) && identities.all? { |digest| digest?(digest) } && scopes.is_a?(Hash)
71
+ raise Invalid, 'invalid_source_identity_table'
72
+ end
73
+
74
+ scopes.each { |scope, paths| validate_scope!(scope, paths, identities) }
75
+ raise Invalid, 'invalid_source_manifest_coverage' unless valid_coverage?
76
+ end
77
+
78
+ def validate_scope!(scope, paths, identities)
79
+ return if scope.is_a?(String) && paths.is_a?(Hash) &&
80
+ paths.all? { |path, index| valid_entry?(path, index, identities) }
81
+
82
+ raise Invalid, 'invalid_source_scope'
83
+ end
84
+
85
+ def supported_version?
86
+ @data.is_a?(Hash) && @data['version'] == VERSION &&
87
+ @data['generation'].is_a?(Integer) && @data['generation'].positive?
88
+ end
89
+
90
+ def valid_header?
91
+ valid_root? && %w[key_id rules].all? { |key| digest?(@data[key]) } &&
92
+ %w[boot_verified complete].all? { |key| [true, false].include?(@data[key]) }
93
+ end
94
+
95
+ def valid_root?
96
+ root = @data['root']
97
+ root.is_a?(String) && root.start_with?('/') && !root.include?("\0")
98
+ end
99
+
100
+ def valid_coverage?
101
+ @data['errors'].is_a?(Array) && @data['errors'].all? { |error| valid_error?(error) } &&
102
+ %w[extra_roots unverified_scopes].all? { |key| @data[key].is_a?(Array) && @data[key].all?(String) }
103
+ end
104
+
105
+ def valid_error?(error)
106
+ error.is_a?(Hash) && error['reason'].is_a?(String) &&
107
+ (error['path'].nil? || error['path'].is_a?(String))
108
+ end
109
+
110
+ def valid_entry?(path, index, identities)
111
+ valid_path?(path) && index.is_a?(Integer) && index >= 0 && index < identities.size
112
+ end
113
+
114
+ def valid_path?(path)
115
+ path.is_a?(String) && !path.empty? && !path.start_with?('/') && !path.include?("\0") &&
116
+ path.split('/', -1).none? { |part| ['', '.', '..'].include?(part) }
117
+ end
118
+
119
+ def digest?(value)
120
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+ require 'fileutils'
6
+
7
+ module Woods
8
+ module SourceInputs
9
+ # Never copied into a published generation. Public identities are keyed even
10
+ # for ordinary source, so an unrecognized secret-bearing file stays protected.
11
+ class PrivateKey
12
+ FILE_NAME = '.source-inputs.key'
13
+ class Unavailable < StandardError; end
14
+
15
+ attr_reader :bytes, :identifier
16
+
17
+ def initialize(output_dir:, create: false)
18
+ path = File.join(output_dir.to_s, FILE_NAME)
19
+ create_key(path) if create
20
+ flags = File::RDONLY | File::NONBLOCK
21
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
22
+ File.open(path, flags) do |file|
23
+ stat = file.stat
24
+ raise Unavailable, 'insecure_identity_key' unless private_regular?(stat)
25
+
26
+ @bytes = file.read(33)
27
+ end
28
+ raise Unavailable, 'invalid_identity_key' unless @bytes&.bytesize == 32
29
+
30
+ @identifier = Digest::SHA256.hexdigest(@bytes)
31
+ rescue SystemCallError, IOError
32
+ raise Unavailable, 'identity_key_unavailable'
33
+ end
34
+
35
+ private
36
+
37
+ def private_regular?(stat)
38
+ stat.file? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
39
+ end
40
+
41
+ def create_key(path)
42
+ FileUtils.mkdir_p(File.dirname(path))
43
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
44
+ file.binmode
45
+ file.write(SecureRandom.random_bytes(32))
46
+ file.flush
47
+ file.fsync
48
+ end
49
+ rescue Errno::EEXIST
50
+ # Existing permissions/content are validated, never repaired implicitly.
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
@@ -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