woods 2.0.1 → 2.1.0

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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -7
  3. data/CONTRIBUTING.md +134 -19
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -25
  10. data/docs/CONSOLE_MCP_SETUP.md +82 -30
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +20 -15
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +153 -38
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  33. data/lib/tasks/woods.rake +23 -7
  34. data/lib/tasks/woods_checks.rake +2 -2
  35. data/lib/woods/agent_configuration/cli.rb +1 -1
  36. data/lib/woods/agent_configuration/layout.rb +16 -2
  37. data/lib/woods/agent_configuration/plan.rb +13 -3
  38. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  39. data/lib/woods/agent_configuration/preflight.rb +5 -3
  40. data/lib/woods/builder.rb +17 -57
  41. data/lib/woods/cache/cache_middleware.rb +56 -30
  42. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  43. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  44. data/lib/woods/console/connection_manager.rb +56 -3
  45. data/lib/woods/console/embedded_executor.rb +30 -5
  46. data/lib/woods/console/rack_middleware.rb +29 -1
  47. data/lib/woods/dependency_graph.rb +34 -10
  48. data/lib/woods/embedding/fake.rb +12 -0
  49. data/lib/woods/embedding/indexer.rb +195 -98
  50. data/lib/woods/embedding/input_budget.rb +67 -0
  51. data/lib/woods/embedding/openai.rb +70 -20
  52. data/lib/woods/embedding/provider.rb +37 -25
  53. data/lib/woods/embedding/text_preparer.rb +76 -32
  54. data/lib/woods/embedding/token_counter.rb +18 -81
  55. data/lib/woods/embedding/vector_configuration.rb +48 -0
  56. data/lib/woods/extraction_identities.rb +175 -0
  57. data/lib/woods/extractor.rb +304 -107
  58. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  59. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  60. data/lib/woods/extractors/class_declarations.rb +121 -0
  61. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  62. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  63. data/lib/woods/extractors/event_extractor.rb +8 -0
  64. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  65. data/lib/woods/extractors/job_extractor.rb +5 -1
  66. data/lib/woods/extractors/lib_extractor.rb +132 -15
  67. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  68. data/lib/woods/extractors/manager_extractor.rb +7 -21
  69. data/lib/woods/extractors/migration_declaration.rb +87 -0
  70. data/lib/woods/extractors/migration_extractor.rb +5 -39
  71. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  72. data/lib/woods/extractors/policy_extractor.rb +9 -5
  73. data/lib/woods/extractors/poro_extractor.rb +112 -53
  74. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  75. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  76. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  77. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  78. data/lib/woods/extractors/source_nesting.rb +142 -106
  79. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  80. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  81. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  82. data/lib/woods/flow_assembler.rb +4 -1
  83. data/lib/woods/generation.rb +25 -0
  84. data/lib/woods/hooks/context_hint.rb +7 -2
  85. data/lib/woods/mcp/bootstrapper.rb +33 -7
  86. data/lib/woods/mcp/config_resolver.rb +26 -7
  87. data/lib/woods/mcp/index_reader.rb +125 -24
  88. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  89. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  90. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  91. data/lib/woods/mcp/search_results.rb +7 -1
  92. data/lib/woods/mcp/server.rb +24 -4
  93. data/lib/woods/module_reconciliation.rb +151 -0
  94. data/lib/woods/path_dispatcher.rb +7 -2
  95. data/lib/woods/rake_helpers.rb +43 -11
  96. data/lib/woods/release.rb +1 -1
  97. data/lib/woods/resilience/index_validator.rb +8 -3
  98. data/lib/woods/resilience/retryable_provider.rb +18 -1
  99. data/lib/woods/resolved_config.rb +68 -8
  100. data/lib/woods/retrieval/context_assembler.rb +3 -3
  101. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  102. data/lib/woods/retrieval/scope.rb +18 -2
  103. data/lib/woods/retrieval/source_evidence.rb +14 -2
  104. data/lib/woods/source_contributor_validation.rb +78 -0
  105. data/lib/woods/source_contributors.rb +116 -0
  106. data/lib/woods/source_inputs/handoff.rb +37 -0
  107. data/lib/woods/source_inputs/launcher.rb +53 -13
  108. data/lib/woods/source_inputs/manifest.rb +84 -3
  109. data/lib/woods/source_inputs/private_key.rb +44 -12
  110. data/lib/woods/source_inputs/scanner.rb +98 -27
  111. data/lib/woods/source_inputs/scopes.rb +1 -1
  112. data/lib/woods/source_inputs/session.rb +147 -15
  113. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  114. data/lib/woods/source_inputs/status.rb +40 -8
  115. data/lib/woods/source_inputs/verifier.rb +28 -5
  116. data/lib/woods/source_path_encoding.rb +33 -0
  117. data/lib/woods/source_references/cache.rb +284 -0
  118. data/lib/woods/source_references/collector.rb +120 -0
  119. data/lib/woods/source_references/extraction.rb +185 -0
  120. data/lib/woods/source_references/inputs.rb +134 -0
  121. data/lib/woods/source_references/parser_adapter.rb +134 -0
  122. data/lib/woods/source_references/pass.rb +152 -0
  123. data/lib/woods/source_references/prism_adapter.rb +116 -0
  124. data/lib/woods/source_references/registry.rb +178 -0
  125. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  126. data/lib/woods/source_references/value_class.rb +82 -0
  127. data/lib/woods/storage/metadata_store.rb +4 -1
  128. data/lib/woods/storage/qdrant.rb +2 -2
  129. data/lib/woods/unblocked/client.rb +12 -7
  130. data/lib/woods/unblocked/document_builder.rb +4 -1
  131. data/lib/woods/unblocked/exporter.rb +127 -37
  132. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  133. data/lib/woods/unblocked/uri_migration.rb +105 -0
  134. data/lib/woods/util/host_guard.rb +3 -2
  135. data/lib/woods/version.rb +1 -1
  136. data/lib/woods/watch/catch_up.rb +138 -0
  137. data/lib/woods/watch/claim_lease.rb +150 -0
  138. data/lib/woods/watch/cli.rb +26 -2
  139. data/lib/woods/watch/daemon.rb +80 -59
  140. data/lib/woods/watch/installation/options.rb +1 -1
  141. data/lib/woods/watch/installation/receipt.rb +6 -1
  142. data/lib/woods/watch/managed_child.rb +1 -1
  143. data/lib/woods/watch/supervisor.rb +1 -1
  144. data/lib/woods/watch/tree_scan.rb +14 -2
  145. data/plugin/.claude-plugin/plugin.json +1 -1
  146. data/plugin/hooks/adapters/normalize.rb +3 -2
  147. data/plugin/hooks/woods-input-rules.sh +4 -0
  148. data/plugin/hooks/woods-refresh.sh +15 -7
  149. data/plugin/hooks/woods-session-start.sh +60 -3
  150. data/plugin/skills/woods-diagnose/SKILL.md +334 -11
  151. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  152. data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
  153. data/plugin/skills/woods-setup/SKILL.md +53 -6
  154. metadata +32 -5
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'find'
4
3
  require 'openssl'
5
4
  require 'woods/source_inputs/private_key'
6
5
  require 'woods/source_inputs/scopes'
@@ -17,8 +16,8 @@ module Woods
17
16
  attr_reader :root, :scopes
18
17
 
19
18
  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)
19
+ @root = SourcePathEncoding.expand(root)
20
+ @output_dir = SourcePathEncoding.expand(output_dir)
22
21
  @key = key
23
22
  @scopes = scopes
24
23
  @limits = DEFAULT_LIMITS.merge(limits)
@@ -28,13 +27,16 @@ module Woods
28
27
  end
29
28
 
30
29
  def call
30
+ @captured_at = Time.now.to_f
31
31
  @started = monotonic
32
32
  @files = {}
33
33
  @scope_paths = {}
34
34
  @errors = []
35
35
  @bytes = 0
36
36
  @visited = 0
37
+ @directory_links = {}
37
38
  walk
39
+ verify_directory_links
38
40
  result
39
41
  rescue BudgetExceeded => e
40
42
  error(e.message)
@@ -42,6 +44,9 @@ module Woods
42
44
  rescue SystemCallError, IOError
43
45
  error('source_tree_unavailable')
44
46
  result
47
+ rescue EncodingError, SourcePathEncoding::Invalid
48
+ error('undecodable_source_path')
49
+ result
45
50
  end
46
51
 
47
52
  private
@@ -49,25 +54,91 @@ module Woods
49
54
  def walk
50
55
  raise Errno::ENOENT, @root unless File.directory?(@root)
51
56
 
52
- @resolved_root = File.realpath(@root)
53
- Find.find(@root, ignore_error: false) do |path|
54
- next if path == @root
57
+ @resolved_root = SourcePathEncoding.utf8!(File.realpath(@root))
58
+ @resolved_output = File.directory?(@output_dir) ? resolved_path(@output_dir) : nil
59
+ pending = children(@root).map { |path| [path, [@resolved_root]] }
60
+ until pending.empty?
61
+ path, ancestors = pending.pop
62
+ scan_entry(path, pending, ancestors)
63
+ end
64
+ end
65
+
66
+ def scan_entry(path, pending, ancestors)
67
+ check_budget!
68
+ path = decoded_entry(path)
69
+ return unless path
55
70
 
71
+ relative = path.delete_prefix("#{@root}/")
72
+ return if ignored_directory?(path, relative)
73
+
74
+ stat = File.lstat(path)
75
+ if directory_entry?(path, stat)
76
+ @visited += 1 if stat.symlink?
56
77
  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?
78
+ descend(path, relative, pending, ancestors, symlink: stat.symlink?)
79
+ return
80
+ end
64
81
 
65
- @visited += 1
82
+ @visited += 1
83
+ check_budget!
84
+ visit(path, relative)
85
+ rescue SystemCallError, IOError
86
+ error('source_tree_unavailable', relative)
87
+ end
88
+
89
+ def directory_entry?(path, stat)
90
+ stat.directory? || (stat.symlink? && File.directory?(path))
91
+ end
92
+
93
+ # Scope each logical descendant independently. Directory names do not
94
+ # establish whether their children are inputs (package.yml can occur
95
+ # outside app/lib). External entries may be enumerated, but visit refuses
96
+ # any consumed file outside the root before opening its contents.
97
+ def descend(path, relative, pending, ancestors, symlink:)
98
+ real = SourcePathEncoding.utf8!(File.realpath(path))
99
+ return if @resolved_output && (real == @resolved_output || real.start_with?("#{@resolved_output}/"))
100
+
101
+ if ancestors.include?(real)
102
+ error('source_directory_cycle', relative)
103
+ return
104
+ end
105
+
106
+ @directory_links[path] = real if symlink
107
+ branch = ancestors + [real]
108
+ pending.concat(children(path).map { |child| [child, branch] })
109
+ end
110
+
111
+ def verify_directory_links
112
+ @directory_links.each do |path, target|
66
113
  check_budget!
67
- visit(path, relative, stat)
114
+ next if File.symlink?(path) && File.directory?(path) && resolved_path(path) == target
115
+
116
+ error('source_changed_during_read', path.delete_prefix("#{@root}/"))
117
+ rescue SystemCallError, IOError
118
+ error('source_changed_during_read', path.delete_prefix("#{@root}/"))
68
119
  end
69
120
  end
70
121
 
122
+ # An unreadable entry cannot certify completeness, but must not hide
123
+ # accessible siblings. Keep traversal order deterministic and bounded.
124
+ def children(path)
125
+ Dir.children(path, encoding: Encoding::BINARY).sort.reverse.map { |name| File.join(path.b, name) }
126
+ rescue SystemCallError, IOError
127
+ error('source_tree_unavailable', path == @root ? nil : path.delete_prefix("#{@root}/"))
128
+ []
129
+ end
130
+
131
+ def decoded_entry(path)
132
+ decoded = SourcePathEncoding.utf8(path)
133
+ return decoded if decoded
134
+
135
+ @visited += 1
136
+ check_budget!
137
+ relative = path.b.delete_prefix("#{@root}/".b)
138
+ error('undecodable_source_path', SourcePathEncoding.diagnostic(relative))
139
+ nil
140
+ end
141
+
71
142
  def ignored_directory?(path, relative)
72
143
  return true if path == @output_dir || path.start_with?("#{@output_dir}/")
73
144
  return false if declared_path?(relative)
@@ -82,17 +153,12 @@ module Woods
82
153
  end
83
154
  end
84
155
 
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
156
+ # Refuse external paths and unreadable inputs without hashing their bytes.
157
+ def visit(path, relative)
92
158
  consumers = @scopes.for_path(relative)
93
159
  return if consumers.empty?
94
160
 
95
- real = File.realpath(path)
161
+ real = SourcePathEncoding.utf8!(File.realpath(path))
96
162
  unless real.start_with?("#{@resolved_root}/")
97
163
  error('external_source_path', relative)
98
164
  return
@@ -104,16 +170,16 @@ module Woods
104
170
  consumers.each { |scope| (@scope_paths[scope] ||= []) << relative }
105
171
  rescue SystemCallError, IOError
106
172
  error('source_file_unreadable', relative)
173
+ rescue EncodingError, SourcePathEncoding::Invalid
174
+ error('undecodable_source_path', SourcePathEncoding.diagnostic(relative))
107
175
  end
108
176
 
109
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
110
-
111
177
  def read_identity(path, resolved, expected) # rubocop:disable Metrics/CyclomaticComplexity -- stable descriptor and original resolution checks
112
178
  flags = File::RDONLY | File::NONBLOCK
113
179
  flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
114
180
  File.open(resolved, flags) do |file|
115
181
  before = stamp(file.stat)
116
- unless before == expected && File.realpath(path) == resolved
182
+ unless before == expected && resolved_path(path) == resolved
117
183
  error('source_changed_during_read', path.delete_prefix("#{@root}/"))
118
184
  return nil
119
185
  end
@@ -122,7 +188,7 @@ module Woods
122
188
  return nil
123
189
  end
124
190
  identity = hash_stream(file)
125
- unless before == stamp(file.stat) && before == stamp(File.stat(resolved)) && File.realpath(path) == resolved
191
+ unless before == stamp(file.stat) && before == stamp(File.stat(resolved)) && resolved_path(path) == resolved
126
192
  error('source_changed_during_read', path.delete_prefix("#{@root}/"))
127
193
  return nil
128
194
  end
@@ -130,6 +196,10 @@ module Woods
130
196
  end
131
197
  end
132
198
 
199
+ def resolved_path(path)
200
+ SourcePathEncoding.utf8!(File.realpath(path))
201
+ end
202
+
133
203
  def hash_stream(file)
134
204
  digest = OpenSSL::HMAC.new(@key.bytes, OpenSSL::Digest.new('SHA256'))
135
205
  while (chunk = file.read(16_384))
@@ -156,6 +226,7 @@ module Woods
156
226
 
157
227
  def result
158
228
  { 'root' => @root, 'key_id' => @key.identifier, 'rules' => @scopes.fingerprint,
229
+ 'captured_at' => @captured_at,
159
230
  'extra_roots' => @scopes.extra_roots, 'files' => @files.sort.to_h,
160
231
  'scope_paths' => @scope_paths.transform_values(&:sort).sort.to_h,
161
232
  'errors' => @errors, 'complete' => @errors.empty?,
@@ -50,7 +50,7 @@ module Woods
50
50
  PathDispatcher.runtime_rules.map(&:to_h),
51
51
  ReloadPolicy.constants(false).sort.to_h { |name| [name, ReloadPolicy.const_get(name)] },
52
52
  Watch::Watcher::DEFAULT_IGNORED_DIRECTORIES, Watch::TreeScan::NOT_IGNORED_DOTFILES,
53
- Watch::TreeScan::NOT_IGNORED_DOTFILE_PREFIXES, 'bounded_no_symlink_dirs_v1',
53
+ Watch::TreeScan::NOT_IGNORED_DOTFILE_PREFIXES, 'bounded_logical_directory_links_v2',
54
54
  'config_ruby_and_root_gemspecs_v1', BOOT_FILES, @extra_roots]
55
55
  Digest::SHA256.hexdigest(JSON.generate(data))
56
56
  end
@@ -1,22 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_contributors'
4
+
3
5
  require 'set'
4
6
  require 'woods/source_inputs/scanner'
5
7
  require 'woods/source_inputs/manifest'
6
8
  require 'woods/source_inputs/handoff'
7
9
  require 'woods/source_inputs/consumer_errors'
10
+ require 'woods/source_inputs/stable_reader'
8
11
 
9
12
  module Woods
10
13
  module SourceInputs
11
14
  # Records successful consumers, never merely paths found by a final scan.
12
15
  # An incremental run starts from the preceding generation's scope ledger.
13
16
  class Session # rubocop:disable Metrics/ClassLength -- per-run consumer ledger and publication coverage
17
+ MAX_ERRORS = 30
18
+
14
19
  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)
20
+ @root = SourcePathEncoding.expand(root)
21
+ @output = SourcePathEncoding.expand(output_dir)
17
22
  @operation = operation.to_s
18
23
  @errors = []
19
24
  @unverified = []
25
+ @capture_unavailable = Handoff.unavailable_evidence
20
26
  @baseline = read_baseline(baseline_path)
21
27
  @rules = Scopes.new(extra_roots: declared_roots)
22
28
  @key = PrivateKey.new(output_dir: @output, create: true)
@@ -31,6 +37,48 @@ module Woods
31
37
  @verified_boot = false
32
38
  end
33
39
 
40
+ # Read original captured bytes, without certifying runtime consumption.
41
+ #
42
+ # @param path [String] root-relative or absolute application source path
43
+ # @param limits [Hash] StableReader byte/time budget overrides
44
+ # @return [Hash] original source bytes, relative path and captured HMAC
45
+ # @raise [StableReader::Error] when source cannot be read as captured
46
+ def read_source(path, **limits)
47
+ raise StableReader::Error, 'source_identity_unavailable' unless @key
48
+
49
+ identity = source_identity(path)
50
+ raise StableReader::Error, 'uncaptured_source_path' unless identity
51
+
52
+ StableReader.new(root: @root, key: @key).read(path, identity: identity, **limits)
53
+ end
54
+
55
+ # @param path [String] root-relative or absolute application source path
56
+ # @return [String, nil] captured private HMAC, never a public raw content hash
57
+ def source_identity(path)
58
+ @snapshot.fetch('files')[relative_path(path)]&.dup&.freeze
59
+ end
60
+
61
+ # A fresh read cannot establish which bytes produced retained runtime facts.
62
+ # This checks only explicit unit consumption against this session's capture.
63
+ #
64
+ # @param key [String, Symbol] extractor ledger key
65
+ # @param path [String] root-relative or absolute application source path
66
+ # @return [Boolean] whether that unit consumer acknowledged captured bytes
67
+ def consumed_source?(key, path)
68
+ identity = source_identity(path)
69
+ !identity.nil? && @identities.fetch("unit:#{key}", {})[relative_path(path)] == identity
70
+ end
71
+
72
+ # An oversized baseline can omit the comparison ledger while retaining a
73
+ # cryptographic binding to consumed reference evidence in the same payload.
74
+ # The caller must still prove exact typed ownership and unchanged HMACs.
75
+ def trusted_reference_baseline?(cache)
76
+ return false unless cache && compatible_baseline? && @baseline.unavailable?
77
+
78
+ expected = @baseline.data['reference_cache_sha256']
79
+ expected && expected == Digest::SHA256.hexdigest(JSON.generate(cache))
80
+ end
81
+
34
82
  def consume_file(key, path)
35
83
  consume_path("file:#{key}", path)
36
84
  consume_path("unit:#{key}", path) if @identities.fetch("unit:#{key}", {}).key?(relative_path(path))
@@ -51,7 +99,7 @@ module Woods
51
99
  def consume_extractor(key, units)
52
100
  ["file:#{key}", "whole:#{key}"].each { |scope| replace_scope(scope) }
53
101
  @identities["unit:#{key}"] = {}
54
- Array(units).each { |unit| consume_unit(key, unit.file_path) }
102
+ Array(units).each { |unit| SourceContributors.paths(unit).each { |path| consume_unit(key, path) } }
55
103
  end
56
104
 
57
105
  def unverified(scope)
@@ -62,6 +110,16 @@ module Woods
62
110
  results.each do |key, units|
63
111
  if ConsumerErrors.failed?(consumers[key])
64
112
  unverified("extractor:#{key}")
113
+ # ModelExtractor's per-model rescue returns nil. Every returned
114
+ # model crossed that boundary successfully, even if a sibling did
115
+ # not. Other extractors may return partial results after a rescue.
116
+ if key == :models
117
+ Array(units).each do |unit|
118
+ SourceContributors.paths(unit).each do |path|
119
+ consume_unit(key, path)
120
+ end
121
+ end
122
+ end
65
123
  else
66
124
  consume_extractor(key, units)
67
125
  end
@@ -80,11 +138,28 @@ module Woods
80
138
  end
81
139
  @unverified << 'incomplete_eager_load' unless eager_load_complete
82
140
  Manifest.build(snapshot: @snapshot, scopes: @identities, boot_verified: verified_coverage?,
83
- generation: generation, errors: @errors, unverified_scopes: @unverified)
141
+ generation: generation, errors: @errors, unverified_scopes: @unverified,
142
+ comparison_complete: comparison_complete?,
143
+ unavailable: unavailable_evidence)
84
144
  end
85
145
 
86
146
  private
87
147
 
148
+ def record_error(error)
149
+ # Bound path samples independently so noisy unit diagnostics cannot
150
+ # hide a later instability reason or its watcher catch-up paths.
151
+ count = @errors.count { |existing| existing['reason'] == error['reason'] }
152
+ @errors << error if count < MAX_ERRORS && !@errors.include?(error)
153
+ end
154
+
155
+ def unavailable_evidence
156
+ @capture_unavailable || (@baseline.data['unavailable'] if @operation != 'full' && @baseline&.unavailable?)
157
+ end
158
+
159
+ def comparison_complete?
160
+ @operation == 'full' || !!(compatible_baseline? && @baseline.comparison_complete?)
161
+ end
162
+
88
163
  def declared_roots
89
164
  roots = Handoff.extra_roots
90
165
  roots.empty? && @baseline ? @baseline.data.fetch('extra_roots') : roots
@@ -99,7 +174,7 @@ module Woods
99
174
  Manifest.parse(file.read(Manifest::MAX_BYTES + 1))
100
175
  end
101
176
  rescue Manifest::Invalid, SystemCallError, IOError
102
- @errors << { 'reason' => 'invalid_source_baseline' } unless @operation == 'full'
177
+ record_error('reason' => 'invalid_source_baseline') unless @operation == 'full'
103
178
  nil
104
179
  end
105
180
 
@@ -107,7 +182,7 @@ module Woods
107
182
  return snapshot_identities if @operation == 'full'
108
183
  return @baseline.expanded if compatible_baseline?
109
184
 
110
- @errors << { 'reason' => 'missing_or_incompatible_source_baseline' }
185
+ record_error('reason' => 'missing_or_incompatible_source_baseline')
111
186
  {}
112
187
  end
113
188
 
@@ -130,7 +205,7 @@ module Woods
130
205
  if identity
131
206
  (@identities[scope] ||= {})[relative] = identity
132
207
  elsif File.exist?(File.join(@root, relative))
133
- @errors << { 'reason' => 'uncaptured_source_path', 'path' => relative }
208
+ record_error('reason' => 'uncaptured_source_path', 'path' => relative)
134
209
  else
135
210
  @identities[scope]&.delete(relative)
136
211
  end
@@ -139,11 +214,60 @@ module Woods
139
214
  def relative_path(path)
140
215
  return nil if path.nil? || path.to_s.empty?
141
216
 
142
- absolute = File.expand_path(path.to_s, @root)
217
+ decoded = SourcePathEncoding.utf8(path)
218
+ unless decoded
219
+ record_error('reason' => 'undecodable_source_path', 'path' => SourcePathEncoding.diagnostic(path))
220
+ return nil
221
+ end
222
+
223
+ application_source_path(File.expand_path(decoded, @root))
224
+ end
225
+
226
+ def application_source_path(absolute)
143
227
  # Installed gem/framework source is outside application-source coverage.
144
228
  return nil unless absolute.start_with?("#{@root}/")
145
229
 
146
- absolute.delete_prefix("#{@root}/")
230
+ relative = absolute.delete_prefix("#{@root}/")
231
+ return relative if @rules.extra_roots.any? { |root| relative.start_with?("#{root}/") }
232
+ return nil if installed_gem_roots.any? { |root| absolute.start_with?("#{root}/") }
233
+
234
+ relative
235
+ end
236
+
237
+ # A vendor-shaped path alone is not ownership evidence. Local path gems
238
+ # have their gemspec in the checkout, not in an installation's metadata.
239
+ def installed_gem_roots
240
+ @installed_gem_roots ||= Gem.loaded_specs.values.filter_map { |spec| installed_gem_root(spec) }
241
+ end
242
+
243
+ def installed_gem_root(spec)
244
+ return unless spec.loaded_from && File.file?(spec.loaded_from)
245
+
246
+ base = File.expand_path(spec.base_dir)
247
+ path = File.expand_path(spec.full_gem_path)
248
+ return path if installed_git_gem?(spec, path)
249
+ return unless File.expand_path(spec.loaded_from).start_with?("#{base}/specifications/") &&
250
+ path.start_with?("#{base}/gems/") && File.directory?(path)
251
+
252
+ path
253
+ end
254
+
255
+ # Bundler git installations keep their gemspec beside the source rather
256
+ # than in RubyGems' specifications directory. A local override remains
257
+ # application source even when it retains a Git source object.
258
+ def installed_git_gem?(spec, path)
259
+ return false unless defined?(Bundler::Source::Git) && spec.respond_to?(:source)
260
+
261
+ source = spec.source
262
+ return false unless source.is_a?(Bundler::Source::Git) && !source.local?
263
+
264
+ installed = File.expand_path(source.path.to_s)
265
+ within_installed_source?(path, installed) &&
266
+ File.expand_path(spec.loaded_from).start_with?("#{path}/") && File.directory?(path)
267
+ end
268
+
269
+ def within_installed_source?(path, installed)
270
+ path == installed || path.start_with?("#{installed}/")
147
271
  end
148
272
 
149
273
  def replace_scope(scope)
@@ -168,20 +292,28 @@ module Woods
168
292
  def preserve_baseline_coverage
169
293
  return unless @baseline
170
294
 
171
- @errors.concat(@baseline.data.fetch('errors'))
295
+ @baseline.data.fetch('errors').each { |error| record_error(error) }
172
296
  @unverified.concat(@baseline.data.fetch('unverified_scopes'))
173
- @errors << { 'reason' => 'incomplete_source_baseline' } unless @baseline.data['complete']
297
+ record_error('reason' => 'incomplete_source_baseline') unless @baseline.data['complete']
174
298
  end
175
299
 
176
300
  def verify_stability
177
301
  return unless @key
178
302
 
179
303
  after = scan
180
- @errors.concat(after.fetch('errors'))
304
+ after.fetch('errors').each { |error| record_error(error) }
305
+ changed_paths(after).first(MAX_ERRORS).each do |path|
306
+ record_error('reason' => 'source_changed_during_extraction', 'path' => path)
307
+ end
308
+ end
309
+
310
+ def changed_paths(after)
181
311
  before_files = @snapshot.fetch('files')
182
312
  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 } }
313
+ changed = (before_files.keys & after_files.keys).reject { |path| before_files[path] == after_files[path] }
314
+ changed.concat(after_files.keys - before_files.keys) if @snapshot['complete']
315
+ changed.concat(before_files.keys - after_files.keys) if after['complete']
316
+ changed
185
317
  end
186
318
 
187
319
  # A custom loader can consume Ruby outside the standard dispatch roots.
@@ -196,7 +328,7 @@ module Woods
196
328
  end.uniq
197
329
  missing = paths - @snapshot.fetch('files').keys
198
330
  missing.first(30).each do |path|
199
- @errors << { 'reason' => 'loaded_source_outside_coverage', 'path' => path }
331
+ record_error('reason' => 'loaded_source_outside_coverage', 'path' => path)
200
332
  end
201
333
  end
202
334
 
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Woods
6
+ module SourceInputs
7
+ # Reads bytes only when their descriptor and captured keyed identity agree.
8
+ # Paths may be captured internal file symlinks, but their resolution must
9
+ # remain stable and inside the real application root throughout the read.
10
+ class StableReader
11
+ DEFAULT_LIMITS = { max_bytes: 8 * 1024 * 1024, max_seconds: 2.0 }.freeze
12
+
13
+ class Error < StandardError
14
+ attr_reader :reason
15
+
16
+ def initialize(reason)
17
+ @reason = reason
18
+ super
19
+ end
20
+ end
21
+
22
+ # @param root [String] application root
23
+ # @param key [PrivateKey] private identity key used by the source snapshot
24
+ def initialize(root:, key:)
25
+ @root = File.expand_path(root.to_s)
26
+ @key = key
27
+ end
28
+
29
+ # Read original bytes without advancing any source-consumption ledger.
30
+ # The time budget is cooperative between bounded regular-file reads.
31
+ #
32
+ # @param path [String] root-relative or absolute application path
33
+ # @param identity [String] HMAC from the session snapshot
34
+ # @param max_bytes [Numeric] maximum bytes retained in memory
35
+ # @param max_seconds [Numeric] maximum elapsed read time
36
+ # @return [Hash] frozen path, binary source bytes and captured HMAC
37
+ # @raise [Error] if containment, stability, identity or bounds cannot be proven
38
+ def read(path, identity:, max_bytes: DEFAULT_LIMITS[:max_bytes], max_seconds: DEFAULT_LIMITS[:max_seconds])
39
+ @limits = { max_bytes: max_bytes, max_seconds: max_seconds }
40
+ validate_limits!
41
+ @started = monotonic
42
+ absolute = File.expand_path(path.to_s, @root)
43
+ resolved = resolve(absolute)
44
+ expected = stamp(File.stat(resolved))
45
+ source = read_stable(absolute, resolved, expected)
46
+ actual = OpenSSL::HMAC.hexdigest('SHA256', @key.bytes, source)
47
+ check_time!
48
+ raise Error, 'source_snapshot_mismatch' unless actual == identity
49
+
50
+ { 'path' => absolute.delete_prefix("#{@root}/").freeze, 'source' => source.freeze,
51
+ 'identity' => identity.dup.freeze }
52
+ rescue SystemCallError, IOError
53
+ raise Error, 'source_file_unreadable'
54
+ end
55
+
56
+ private
57
+
58
+ def validate_limits!
59
+ return if @limits.values.all? { |value| value.is_a?(Numeric) && value.finite? && value.positive? }
60
+
61
+ raise ArgumentError, 'source read limits must be finite and positive'
62
+ end
63
+
64
+ def resolve(path)
65
+ raise Error, 'source_outside_root' unless path.start_with?("#{@root}/")
66
+
67
+ @resolved_root = File.realpath(@root)
68
+ resolved = File.realpath(path)
69
+ raise Error, 'source_outside_root' unless resolved.start_with?("#{@resolved_root}/")
70
+
71
+ resolved
72
+ end
73
+
74
+ def read_stable(path, resolved, expected)
75
+ flags = File::RDONLY | File::NONBLOCK
76
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
77
+ File.open(resolved, flags) do |file|
78
+ file.binmode
79
+ before = stamp(file.stat)
80
+ raise Error, 'nonregular_source' unless file.stat.file?
81
+ raise Error, 'source_changed_during_read' unless before == expected && stable_path?(path, resolved)
82
+ raise Error, 'source_read_byte_budget' if file.stat.size > @limits[:max_bytes]
83
+
84
+ source = read_bytes(file)
85
+ verify_after!(file, before, path, resolved)
86
+ source
87
+ end
88
+ end
89
+
90
+ def verify_after!(file, before, path, resolved)
91
+ return if before == stamp(file.stat) && before == stamp(File.stat(resolved)) && stable_path?(path, resolved)
92
+
93
+ raise Error, 'source_changed_during_read'
94
+ end
95
+
96
+ def read_bytes(file)
97
+ source = String.new(encoding: Encoding::BINARY)
98
+ loop do
99
+ check_time!
100
+ chunk = file.read(16_384)
101
+ break unless chunk
102
+ raise Error, 'source_read_byte_budget' if source.bytesize + chunk.bytesize > @limits[:max_bytes]
103
+
104
+ source << chunk
105
+ end
106
+ check_time!
107
+ source
108
+ end
109
+
110
+ def stable_path?(path, resolved)
111
+ File.realpath(@root) == @resolved_root && File.realpath(path) == resolved
112
+ end
113
+
114
+ def stamp(stat)
115
+ [stat.dev, stat.ino, stat.size, stat.mtime.to_r, stat.ctime.to_r]
116
+ end
117
+
118
+ def check_time!
119
+ raise Error, 'source_read_time_budget' if monotonic - @started > @limits[:max_seconds]
120
+ end
121
+
122
+ def monotonic
123
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ end
125
+ end
126
+ end
127
+ end