woods 2.0.0 → 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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +135 -10
  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 -18
  10. data/docs/CONSOLE_MCP_SETUP.md +141 -12
  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 +59 -2
  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 +184 -9
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/exe/woods-mcp-http +16 -9
  33. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  34. data/lib/tasks/woods.rake +23 -7
  35. data/lib/tasks/woods_checks.rake +2 -2
  36. data/lib/woods/agent_configuration/cli.rb +1 -1
  37. data/lib/woods/agent_configuration/layout.rb +16 -2
  38. data/lib/woods/agent_configuration/plan.rb +13 -3
  39. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  40. data/lib/woods/agent_configuration/preflight.rb +5 -3
  41. data/lib/woods/builder.rb +17 -57
  42. data/lib/woods/cache/cache_middleware.rb +68 -41
  43. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  44. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  45. data/lib/woods/console/adapter_family.rb +39 -0
  46. data/lib/woods/console/connection_manager.rb +56 -3
  47. data/lib/woods/console/credential_index.rb +33 -3
  48. data/lib/woods/console/embedded_executor.rb +431 -48
  49. data/lib/woods/console/model_validator.rb +8 -0
  50. data/lib/woods/console/rack_middleware.rb +68 -11
  51. data/lib/woods/console/redactor.rb +24 -10
  52. data/lib/woods/console/safe_context.rb +44 -7
  53. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  54. data/lib/woods/console/sql_table_scanner.rb +45 -34
  55. data/lib/woods/console/sql_validator.rb +37 -2
  56. data/lib/woods/dependency_graph.rb +34 -10
  57. data/lib/woods/embedding/fake.rb +12 -0
  58. data/lib/woods/embedding/indexer.rb +195 -98
  59. data/lib/woods/embedding/input_budget.rb +67 -0
  60. data/lib/woods/embedding/openai.rb +70 -20
  61. data/lib/woods/embedding/provider.rb +37 -25
  62. data/lib/woods/embedding/text_preparer.rb +76 -32
  63. data/lib/woods/embedding/token_counter.rb +18 -81
  64. data/lib/woods/embedding/vector_configuration.rb +48 -0
  65. data/lib/woods/extraction_identities.rb +175 -0
  66. data/lib/woods/extractor.rb +304 -107
  67. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  68. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  69. data/lib/woods/extractors/class_declarations.rb +121 -0
  70. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  71. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  72. data/lib/woods/extractors/event_extractor.rb +8 -0
  73. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  74. data/lib/woods/extractors/job_extractor.rb +5 -1
  75. data/lib/woods/extractors/lib_extractor.rb +132 -15
  76. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  77. data/lib/woods/extractors/manager_extractor.rb +7 -21
  78. data/lib/woods/extractors/migration_declaration.rb +87 -0
  79. data/lib/woods/extractors/migration_extractor.rb +5 -39
  80. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  81. data/lib/woods/extractors/policy_extractor.rb +9 -5
  82. data/lib/woods/extractors/poro_extractor.rb +112 -53
  83. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  84. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  85. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  86. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  87. data/lib/woods/extractors/source_nesting.rb +142 -106
  88. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  89. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  90. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  91. data/lib/woods/flow_assembler.rb +4 -1
  92. data/lib/woods/generation.rb +25 -0
  93. data/lib/woods/hooks/context_hint.rb +7 -2
  94. data/lib/woods/mcp/bearer_auth.rb +1 -1
  95. data/lib/woods/mcp/bootstrapper.rb +33 -7
  96. data/lib/woods/mcp/config_resolver.rb +26 -7
  97. data/lib/woods/mcp/index_reader.rb +125 -24
  98. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  99. data/lib/woods/mcp/origin_guard.rb +24 -77
  100. data/lib/woods/mcp/origin_policy.rb +124 -0
  101. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  102. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  103. data/lib/woods/mcp/search_results.rb +7 -1
  104. data/lib/woods/mcp/server.rb +24 -4
  105. data/lib/woods/module_reconciliation.rb +151 -0
  106. data/lib/woods/path_dispatcher.rb +7 -2
  107. data/lib/woods/railtie_support.rb +8 -0
  108. data/lib/woods/rake_helpers.rb +43 -11
  109. data/lib/woods/release.rb +1 -1
  110. data/lib/woods/resilience/index_validator.rb +8 -3
  111. data/lib/woods/resilience/retryable_provider.rb +18 -1
  112. data/lib/woods/resolved_config.rb +68 -8
  113. data/lib/woods/retrieval/context_assembler.rb +3 -3
  114. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  115. data/lib/woods/retrieval/scope.rb +18 -2
  116. data/lib/woods/retrieval/source_evidence.rb +14 -2
  117. data/lib/woods/source_contributor_validation.rb +78 -0
  118. data/lib/woods/source_contributors.rb +116 -0
  119. data/lib/woods/source_inputs/handoff.rb +37 -0
  120. data/lib/woods/source_inputs/launcher.rb +53 -13
  121. data/lib/woods/source_inputs/manifest.rb +84 -3
  122. data/lib/woods/source_inputs/private_key.rb +44 -12
  123. data/lib/woods/source_inputs/scanner.rb +98 -27
  124. data/lib/woods/source_inputs/scopes.rb +1 -1
  125. data/lib/woods/source_inputs/session.rb +147 -15
  126. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  127. data/lib/woods/source_inputs/status.rb +40 -8
  128. data/lib/woods/source_inputs/verifier.rb +28 -5
  129. data/lib/woods/source_path_encoding.rb +33 -0
  130. data/lib/woods/source_references/cache.rb +284 -0
  131. data/lib/woods/source_references/collector.rb +120 -0
  132. data/lib/woods/source_references/extraction.rb +185 -0
  133. data/lib/woods/source_references/inputs.rb +134 -0
  134. data/lib/woods/source_references/parser_adapter.rb +134 -0
  135. data/lib/woods/source_references/pass.rb +152 -0
  136. data/lib/woods/source_references/prism_adapter.rb +116 -0
  137. data/lib/woods/source_references/registry.rb +178 -0
  138. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  139. data/lib/woods/source_references/value_class.rb +82 -0
  140. data/lib/woods/storage/metadata_store.rb +4 -1
  141. data/lib/woods/storage/qdrant.rb +2 -2
  142. data/lib/woods/unblocked/client.rb +12 -7
  143. data/lib/woods/unblocked/document_builder.rb +4 -1
  144. data/lib/woods/unblocked/exporter.rb +127 -37
  145. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  146. data/lib/woods/unblocked/uri_migration.rb +105 -0
  147. data/lib/woods/util/host_guard.rb +3 -2
  148. data/lib/woods/version.rb +1 -1
  149. data/lib/woods/watch/catch_up.rb +138 -0
  150. data/lib/woods/watch/claim_lease.rb +150 -0
  151. data/lib/woods/watch/cli.rb +26 -2
  152. data/lib/woods/watch/daemon.rb +80 -59
  153. data/lib/woods/watch/installation/options.rb +1 -1
  154. data/lib/woods/watch/installation/receipt.rb +6 -1
  155. data/lib/woods/watch/managed_child.rb +1 -1
  156. data/lib/woods/watch/supervisor.rb +1 -1
  157. data/lib/woods/watch/tree_scan.rb +14 -2
  158. data/plugin/.claude-plugin/plugin.json +1 -1
  159. data/plugin/hooks/adapters/normalize.rb +3 -2
  160. data/plugin/hooks/woods-input-rules.sh +4 -0
  161. data/plugin/hooks/woods-refresh.sh +15 -7
  162. data/plugin/hooks/woods-session-start.sh +60 -3
  163. data/plugin/skills/woods-diagnose/SKILL.md +336 -2
  164. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  165. data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
  166. data/plugin/skills/woods-setup/SKILL.md +56 -1
  167. metadata +34 -5
@@ -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
@@ -12,6 +12,13 @@ module Woods
12
12
  BUDGETS = { 'quick' => 0.25, 'deep' => 5.0 }.freeze
13
13
 
14
14
  def self.from_transport(encoded)
15
+ options = transport_options(encoded)
16
+ new(output_dir: options.fetch('output', ENV.fetch('WOODS_OUTPUT', 'tmp/woods')),
17
+ root: options.fetch('root', Dir.pwd), mode: options.fetch('mode', 'quick')).call
18
+ .merge('root_source' => options.key?('root') ? 'explicit' : 'working_directory')
19
+ end
20
+
21
+ def self.transport_options(encoded)
15
22
  raise ArgumentError, 'source status options are too large' if encoded.to_s.bytesize > 16_384
16
23
 
17
24
  options = encoded.nil? ? {} : JSON.parse(Base64.strict_decode64(encoded))
@@ -19,20 +26,21 @@ module Woods
19
26
  raise ArgumentError, 'source status options must contain only output, root and mode strings'
20
27
  end
21
28
 
22
- new(output_dir: options.fetch('output', ENV.fetch('WOODS_OUTPUT', 'tmp/woods')),
23
- root: options['root'], mode: options.fetch('mode', 'quick')).call
29
+ options
24
30
  rescue JSON::ParserError
25
31
  raise ArgumentError, 'invalid source status options'
26
32
  end
27
33
 
34
+ private_class_method :transport_options
35
+
28
36
  def initialize(output_dir:, payload_dir: nil, generation: nil, root: nil, mode: 'quick')
29
37
  raise ArgumentError, 'source status mode must be quick or deep' unless BUDGETS.key?(mode)
30
38
 
31
- @output = File.expand_path(output_dir.to_s)
32
- @root = root
39
+ @output = SourcePathEncoding.expand(output_dir)
40
+ @root = SourcePathEncoding.expand(root) if root
33
41
  @mode = mode
34
42
  @generation = generation
35
- @payload = payload_dir
43
+ @payload = SourcePathEncoding.utf8!(payload_dir) if payload_dir
36
44
  end
37
45
 
38
46
  def call
@@ -46,16 +54,38 @@ module Woods
46
54
 
47
55
  Manifest.parse(file.read(Manifest::MAX_BYTES + 1))
48
56
  end
49
- Verifier.new(manifest: manifest, output_dir: @output, root: @root,
50
- generation: @generation, max_seconds: BUDGETS.fetch(@mode)).call.merge('check' => @mode)
57
+ result = Verifier.new(manifest: manifest, output_dir: @output, root: @root,
58
+ generation: @generation, max_seconds: BUDGETS.fetch(@mode)).call
59
+ result.merge('check' => @mode, 'recommendations' => recommendations(result, manifest))
51
60
  rescue Errno::ENOENT
52
61
  unknown('source_manifest_unavailable')
53
62
  rescue Manifest::Invalid, SystemCallError, IOError
54
63
  unknown('invalid_source_manifest')
64
+ rescue EncodingError, SourcePathEncoding::Invalid
65
+ unknown('undecodable_source_path')
55
66
  end
56
67
 
57
68
  private
58
69
 
70
+ def recommendations(result, manifest)
71
+ return ['inspect_source_limits'] if result['state'] == 'unavailable'
72
+
73
+ advice = []
74
+ reader_reasons = result.fetch('verification_reasons', result.fetch('reasons'))
75
+ if @mode == 'quick' && reader_reasons.include?('scan_time_budget')
76
+ advice << 'deep_check'
77
+ reader_reasons -= ['scan_time_budget']
78
+ end
79
+ advice << 'inspect_source_scan' unless reader_reasons.empty?
80
+ advice << 'fresh_capture' if incomplete_capture?(manifest)
81
+ advice
82
+ end
83
+
84
+ def incomplete_capture?(manifest)
85
+ !manifest.comparison_complete? || !manifest.data['boot_verified'] ||
86
+ !manifest.data.fetch('errors').empty? || !manifest.data.fetch('unverified_scopes').empty?
87
+ end
88
+
59
89
  def resolve_payload
60
90
  generation = Generation.new(output_dir: @output)
61
91
  marker = if @payload
@@ -77,7 +107,9 @@ module Woods
77
107
 
78
108
  def unknown(reason)
79
109
  { 'state' => 'unknown', 'mode' => 'content', 'check' => @mode, 'generation' => @generation,
80
- 'checked_at' => Time.now.utc.iso8601, 'complete' => false, 'reasons' => [reason] }
110
+ 'checked_at' => Time.now.utc.iso8601, 'complete' => false, 'reasons' => [reason],
111
+ 'recorded_root' => nil, 'checked_root' => @root, 'root_source' => @root ? 'explicit' : 'recorded',
112
+ 'recommendations' => ['fresh_capture'] }
81
113
  end
82
114
  end
83
115
  end
@@ -16,14 +16,27 @@ module Woods
16
16
  def initialize(manifest:, output_dir:, root: nil, generation: nil, max_seconds: DEFAULT_SECONDS, **limits)
17
17
  @manifest = manifest.is_a?(Manifest) ? manifest : Manifest.new(manifest)
18
18
  @output_dir = output_dir
19
- @root = root || @manifest.data.fetch('root')
19
+ @root = SourcePathEncoding.expand(root || @manifest.data.fetch('root'))
20
+ @root_source = root ? 'explicit' : 'recorded'
20
21
  @generation = generation
21
22
  @limits = limits.merge(max_seconds: max_seconds)
22
23
  end
23
24
 
24
25
  # rubocop:enable Metrics/ParameterLists
25
26
 
26
- def call # rubocop:disable Metrics/AbcSize -- cheap refusal checks precede the only source scan
27
+ def call
28
+ result = unavailable_generation? ? unavailable : verify
29
+ result.merge('recorded_root' => @manifest.data.fetch('root'), 'checked_root' => @root,
30
+ 'root_source' => @root_source)
31
+ end
32
+
33
+ private
34
+
35
+ def unavailable_generation?
36
+ @manifest.unavailable? && (!@generation || @generation == @manifest.data['generation'])
37
+ end
38
+
39
+ def verify # rubocop:disable Metrics/AbcSize -- cheap refusal checks precede the only source scan
27
40
  return unknown('generation_mismatch') if @generation && @generation != @manifest.data['generation']
28
41
  return unknown('source_root_unavailable') unless File.directory?(@root)
29
42
 
@@ -39,10 +52,10 @@ module Woods
39
52
  unknown(e.message)
40
53
  rescue SystemCallError, IOError
41
54
  unknown('source_root_unavailable')
55
+ rescue EncodingError, SourcePathEncoding::Invalid
56
+ unknown('undecodable_source_path')
42
57
  end
43
58
 
44
- private
45
-
46
59
  def compare(current)
47
60
  previous = @manifest.expanded
48
61
  changes = { 'added' => [], 'changed' => [], 'removed' => [] }
@@ -53,7 +66,7 @@ module Woods
53
66
  end
54
67
 
55
68
  def complete?(current)
56
- @manifest.data['complete'] && current['complete']
69
+ @manifest.comparison_complete? && current['complete']
57
70
  end
58
71
 
59
72
  def compare_scope(scope, paths, changes, current)
@@ -76,6 +89,7 @@ module Woods
76
89
  reasons = (@manifest.data.fetch('errors') + current.fetch('errors')).map { |error| error['reason'] }.compact
77
90
  reasons << 'incomplete_capture' unless @manifest.data['complete']
78
91
  reasons << 'incomplete_verification' unless current['complete']
92
+ reasons << 'incomplete_comparison_baseline' unless @manifest.comparison_complete?
79
93
  reasons << 'unverified_boot_boundary' unless @manifest.data['boot_verified']
80
94
  reasons << 'unverified_consumption_scopes' unless @manifest.data.fetch('unverified_scopes').empty?
81
95
  reasons.uniq
@@ -86,7 +100,9 @@ module Woods
86
100
  counts = changes.transform_values(&:size)
87
101
  { 'state' => state_for(counts, reasons), 'mode' => 'content', 'generation' => @manifest.data['generation'],
88
102
  'checked_at' => Time.now.utc.iso8601, 'reasons' => reasons,
103
+ 'verification_reasons' => current.fetch('errors').map { |error| error['reason'] }.uniq,
89
104
  'complete' => current['complete'] && @manifest.data['complete'],
105
+ 'comparison_complete' => @manifest.comparison_complete?,
90
106
  'counts' => counts, 'truncated' => counts.values.any? { |count| count > SUMMARY_LIMIT },
91
107
  'changes' => changes.transform_values { |paths| paths.first(SUMMARY_LIMIT) },
92
108
  'metrics' => current.fetch('metrics') }
@@ -98,6 +114,13 @@ module Woods
98
114
  reasons.empty? ? 'current' : 'unknown'
99
115
  end
100
116
 
117
+ def unavailable
118
+ diagnostic = @manifest.data.fetch('unavailable')
119
+ { 'state' => 'unavailable', 'mode' => 'content', 'generation' => @manifest.data['generation'],
120
+ 'checked_at' => Time.now.utc.iso8601, 'reasons' => [diagnostic.fetch('reason')],
121
+ 'complete' => false, 'comparison_complete' => false, 'unavailable' => diagnostic }
122
+ end
123
+
101
124
  def unknown(reason)
102
125
  { 'state' => 'unknown', 'mode' => 'content', 'generation' => @manifest.data['generation'],
103
126
  'checked_at' => Time.now.utc.iso8601, 'reasons' => [reason], 'complete' => false }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ # Filesystem encoding tags follow the locale; published source paths use
5
+ # UTF-8. Validate the original bytes without transcoding or replacing them.
6
+ module SourcePathEncoding
7
+ DIAGNOSTIC_BYTES = 256
8
+ class Invalid < ArgumentError; end
9
+
10
+ def self.utf8(path)
11
+ string = path.to_s
12
+ string = string.dup.force_encoding(Encoding::UTF_8) unless string.encoding == Encoding::UTF_8
13
+ string if string.valid_encoding?
14
+ end
15
+
16
+ def self.utf8!(path)
17
+ utf8(path) || raise(Invalid, 'source paths must contain valid UTF-8 bytes')
18
+ end
19
+
20
+ def self.expand(path)
21
+ utf8!(File.expand_path(utf8!(path)))
22
+ end
23
+
24
+ # An escaped byte label, never a substituted filesystem path. Bound the
25
+ # original bytes before escaping, so even malformed deep paths stay small.
26
+ def self.diagnostic(path)
27
+ bytes = path.to_s.b
28
+ label = bytes.byteslice(0, DIAGNOSTIC_BYTES).inspect
29
+ label += '...' if bytes.bytesize > DIAGNOSTIC_BYTES
30
+ label
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../atomic_file'
5
+ require_relative 'registry'
6
+
7
+ module Woods
8
+ module SourceReferences
9
+ # Generation-local candidate evidence and the edges this pass added. Source
10
+ # bytes never belong here. Replacing the file must not mutate a hardlinked
11
+ # seed belonging to the previously published generation.
12
+ # Keep the closed schema beside its I/O boundary: neither half may accept a
13
+ # cache that the other half cannot safely publish or replay.
14
+ class Cache # rubocop:disable Metrics/ClassLength
15
+ class Invalid < StandardError; end
16
+
17
+ # Older JSON parsers pass pairs through #[]=; newer ones must also use
18
+ # allow_duplicate_key: false before constructing custom objects. A plain
19
+ # builder avoids optimized Hash writes and returns ordinary consumer data.
20
+ class UniqueObject
21
+ attr_reader :data
22
+
23
+ def initialize
24
+ @data = {}
25
+ end
26
+
27
+ def []=(key, value)
28
+ raise Invalid, 'Source reference cache contains a duplicate JSON key' if @data.key?(key)
29
+
30
+ @data[key] = unwrap(value)
31
+ end
32
+
33
+ def unwrap(value)
34
+ case value
35
+ when UniqueObject then value.data
36
+ when Array then value.map { |item| unwrap(item) }
37
+ else value
38
+ end
39
+ end
40
+ end
41
+ private_constant :UniqueObject
42
+
43
+ FILE_NAME = 'source_references.json'
44
+ VERSION = 3
45
+ MAX_BYTES = 64 * 1024 * 1024
46
+ MAX_FILES = 100_000
47
+ MAX_OWNERS = 200_000
48
+ MAX_RECORDS = 1_000_000
49
+ MAX_NESTING = 128
50
+ MAX_STRING_BYTES = 4096
51
+ MAX_LINE = 1_000_000_000
52
+ IDENTITY = /\A[0-9a-f]{64}\z/
53
+ TYPES = Registry::TYPES.map(&:to_s).freeze
54
+ SKIP_REASONS = %w[dynamic_declaration dynamic_superclass dynamic_constant_path unowned_reference
55
+ dynamic_singleton_scope dynamic_method_owner].freeze
56
+
57
+ class << self
58
+ # @param path [String, Pathname] cache in one generation payload
59
+ # @return [Hash, nil] validated evidence, or nil only when absent
60
+ # @raise [Invalid] unreadable, unsupported or invalid cache
61
+ def read(path)
62
+ return unless regular_file?(path)
63
+
64
+ data = JSON.parse(read_bytes(path), object_class: UniqueObject, allow_duplicate_key: false)
65
+ data = data.data if data.is_a?(UniqueObject)
66
+ validate!(data)
67
+ data
68
+ rescue JSON::ParserError, EncodingError, SystemCallError, IOError => e
69
+ raise Invalid, "Cannot read source reference cache: #{e.class}"
70
+ end
71
+
72
+ # @param path [String, Pathname] destination within the new payload
73
+ # @param data [Hash] versioned candidate and pass-owned edge evidence
74
+ # @return [void]
75
+ # @raise [Invalid] unsupported or invalid cache; previous bytes survive
76
+ def write(path, data)
77
+ validate!(data)
78
+ bytes = JSON.generate(data)
79
+ raise Invalid, 'Source reference cache exceeds size limit' if bytes.bytesize > MAX_BYTES
80
+
81
+ regular_file?(path)
82
+ AtomicFile.write(path, bytes)
83
+ rescue JSON::GeneratorError, EncodingError => e
84
+ raise Invalid, "Cannot encode source reference cache: #{e.class}"
85
+ end
86
+
87
+ private
88
+
89
+ def regular_file?(path)
90
+ stat = File.lstat(path)
91
+ raise Invalid, 'Source reference cache must be a regular file, not a symlink' unless stat.file?
92
+
93
+ true
94
+ rescue Errno::ENOENT
95
+ false
96
+ end
97
+
98
+ def read_bytes(path)
99
+ flags = File::RDONLY | File::NONBLOCK
100
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
101
+ File.open(path, flags) do |file|
102
+ raise Invalid, 'Source reference cache must be a regular file' unless file.stat.file?
103
+ raise Invalid, 'Source reference cache exceeds size limit' if file.stat.size > MAX_BYTES
104
+
105
+ bytes = (file.read(MAX_BYTES + 1) || '').force_encoding(Encoding::UTF_8)
106
+ raise Invalid, 'Source reference cache exceeds size limit' if bytes.bytesize > MAX_BYTES
107
+ raise Invalid, 'Source reference cache is not UTF-8' unless bytes.valid_encoding?
108
+
109
+ bytes
110
+ end
111
+ end
112
+
113
+ def validate!(data)
114
+ shape!(data, %w[version files owners])
115
+ require!(data['version'].instance_of?(Integer) && data['version'] == VERSION, 'unsupported version')
116
+ files = data['files']
117
+ require!(files.is_a?(Hash) && files.size <= MAX_FILES, 'invalid files or file limit')
118
+ records = 0
119
+ files.each { |path, record| records = bounded_count(records + file!(path, record)) }
120
+ owners!(data['owners'], files, records)
121
+ end
122
+
123
+ def file!(path, record)
124
+ path!(path)
125
+ shape!(record, %w[identity analysis])
126
+ require!(record['identity'].is_a?(String) && IDENTITY.match?(record['identity']), 'invalid identity')
127
+ analysis!(record['analysis'])
128
+ end
129
+
130
+ def analysis!(analysis)
131
+ shape!(analysis, %w[declarations references skipped parse_error])
132
+ declarations = records!(analysis['declarations'])
133
+ references = records!(analysis['references'])
134
+ skipped = records!(analysis['skipped'])
135
+ total = declarations.size + references.size + skipped.size
136
+ require!(total <= MAX_RECORDS, 'candidate record limit exceeded')
137
+ declarations.each { |record| declaration!(record) }
138
+ references.each { |record| reference!(record) }
139
+ skipped.each { |record| skipped!(record) }
140
+ parse_error!(analysis['parse_error'], total)
141
+ total
142
+ end
143
+
144
+ def declaration!(record)
145
+ shape!(record, %w[owner name kind nesting enclosing_nesting line end_line], %w[singleton_depth constructor])
146
+ lexical_record!(record)
147
+ if record.key?('constructor')
148
+ require!(record['kind'] == 'class' && %w[Struct ::Struct Data ::Data].include?(record['constructor']),
149
+ 'invalid value-class constructor')
150
+ end
151
+ require!(%w[class module].include?(record['kind']), 'invalid declaration kind')
152
+ nesting!(record['enclosing_nesting'])
153
+ line!(record['end_line'])
154
+ require!(record['end_line'] >= record['line'], 'invalid declaration range')
155
+ end
156
+
157
+ def reference!(record)
158
+ shape!(record, %w[owner name nesting line], %w[singleton_depth])
159
+ lexical_record!(record)
160
+ end
161
+
162
+ def lexical_record!(record)
163
+ constant!(record['owner'])
164
+ constant!(record['name'], rooted: true)
165
+ nesting!(record['nesting'])
166
+ require!(record['nesting'].first == record['owner'], 'owner disagrees with lexical nesting')
167
+ line!(record['line'])
168
+ return unless record.key?('singleton_depth')
169
+
170
+ depth = record['singleton_depth']
171
+ require!(depth.instance_of?(Integer) && depth.between?(0, MAX_NESTING), 'invalid singleton depth')
172
+ end
173
+
174
+ def skipped!(record)
175
+ shape!(record, %w[reason owner line])
176
+ require!(SKIP_REASONS.include?(record['reason']), 'invalid skip reason')
177
+ constant!(record['owner']) unless record['owner'].nil?
178
+ line!(record['line'])
179
+ end
180
+
181
+ def parse_error!(error, total)
182
+ return if error.nil?
183
+
184
+ shape!(error, %w[message line])
185
+ string!(error['message'])
186
+ line!(error['line'])
187
+ require!(total.zero?, 'parse failure contains partial records')
188
+ end
189
+
190
+ def owners!(owners, files, count)
191
+ require!(owners.is_a?(Array) && owners.size <= MAX_OWNERS, 'invalid owners or owner limit')
192
+ seen = {}
193
+ owners.each do |owner|
194
+ count = bounded_count(count + owner!(owner, files))
195
+ identity = owner.values_at('type', 'identifier')
196
+ require!(!seen.key?(identity), 'duplicate owner identity')
197
+ seen[identity] = true
198
+ end
199
+ end
200
+
201
+ def owner!(owner, files)
202
+ shape!(owner, %w[type identifier file_path added], %w[file_paths])
203
+ type!(owner['type'])
204
+ constant!(owner['identifier'])
205
+ path!(owner['file_path'])
206
+ require!(files.key?(owner['file_path']), 'owner source is absent from files')
207
+ contributor_paths!(owner, files) if owner.key?('file_paths')
208
+ edges = records!(owner['added'])
209
+ edges.each { |edge| edge!(edge) }
210
+ require!(edges.uniq.size == edges.size, 'duplicate pass-owned edge')
211
+ edges.size
212
+ end
213
+
214
+ def contributor_paths!(owner, files)
215
+ paths = owner['file_paths']
216
+ require!(paths.is_a?(Array) && paths.size > 1 && paths.uniq == paths &&
217
+ paths.first == owner['file_path'], 'invalid owner contributors')
218
+ paths.each do |path|
219
+ path!(path)
220
+ require!(files.key?(path), 'owner contributor is absent from files')
221
+ end
222
+ end
223
+
224
+ def edge!(edge)
225
+ shape!(edge, %w[type target via])
226
+ type!(edge['type'])
227
+ constant!(edge['target'])
228
+ require!(edge['via'] == 'code_reference', 'invalid pass-owned edge label')
229
+ end
230
+
231
+ def type!(type)
232
+ require!(TYPES.include?(type), 'invalid constant-owned unit type')
233
+ end
234
+
235
+ def shape!(value, required, optional = [])
236
+ require!(value.is_a?(Hash), 'invalid record shape')
237
+ require!((required - value.keys).empty? && (value.keys - required - optional).empty?, 'invalid record keys')
238
+ end
239
+
240
+ def records!(value)
241
+ require!(value.is_a?(Array) && value.size <= MAX_RECORDS, 'invalid candidate array or record limit')
242
+ value
243
+ end
244
+
245
+ def bounded_count(count)
246
+ require!(count <= MAX_RECORDS, 'candidate record limit exceeded')
247
+ count
248
+ end
249
+
250
+ def nesting!(value)
251
+ require!(value.is_a?(Array) && value.size <= MAX_NESTING, 'invalid lexical nesting')
252
+ value.each { |name| constant!(name) }
253
+ end
254
+
255
+ def constant!(value, rooted: false)
256
+ string!(value)
257
+ valid = RuntimeLookup::CONSTANT.match?(value) && (rooted || !value.start_with?('::'))
258
+ require!(valid, 'invalid constant name')
259
+ end
260
+
261
+ def path!(value)
262
+ string!(value)
263
+ segments = value.split('/', -1)
264
+ valid = %w[app lib].include?(segments.first) && segments.size >= 2 && value.end_with?('.rb') &&
265
+ !value.include?('\\') && segments.none? { |part| part.empty? || %w[. ..].include?(part) }
266
+ require!(valid, 'unsafe source path')
267
+ end
268
+
269
+ def string!(value)
270
+ require!(value.is_a?(String) && value.valid_encoding? && !value.empty? &&
271
+ value.bytesize <= MAX_STRING_BYTES && !value.match?(/[\x00-\x1f\x7f]/), 'invalid evidence string')
272
+ end
273
+
274
+ def line!(value)
275
+ require!(value.instance_of?(Integer) && value.between?(1, MAX_LINE), 'invalid source line')
276
+ end
277
+
278
+ def require!(condition, message)
279
+ raise Invalid, "Invalid source reference cache: #{message}" unless condition
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end