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
@@ -46,12 +46,15 @@ module Woods
46
46
 
47
47
  # Most output directories contain one unit type. RailsSourceExtractor
48
48
  # emits gem_source units in rails_source/, while GraphQL publishes four
49
- # subtypes in graphql/. Directory-filtered search retains its historical
50
- # family labels while artifact readers preserve the actual unit type.
49
+ # subtypes in graphql/. Search accepts directory-family aliases and
50
+ # concrete types, and returns each unit's actual public type.
51
51
  UNIT_TYPES_BY_DIR = DIR_TO_TYPE.transform_values { |type| [type].freeze }
52
52
  .merge('rails_source' => %w[rails_source gem_source].freeze,
53
53
  'graphql' => %w[graphql_type graphql_mutation graphql_resolver graphql_query].freeze)
54
54
  .freeze
55
+ UNIT_TYPE_TO_DIR = UNIT_TYPES_BY_DIR.each_with_object({}) do |(directory, types), result|
56
+ types.each { |type| result[type] = directory }
57
+ end.freeze
55
58
 
56
59
  # Maximum number of loaded unit files to cache in memory.
57
60
  MAX_UNIT_CACHE = 50
@@ -375,14 +378,14 @@ module Woods
375
378
  def find_unit(identifier, type: nil)
376
379
  if type
377
380
  return with_pinned_generation do
378
- dir = UNIT_TYPES_BY_DIR.find { |_, types| types.include?(type) }&.first
381
+ dir = UNIT_TYPE_TO_DIR[type] || TYPE_TO_DIR[type]
379
382
  next nil unless dir
380
383
  raise IOError, "symlink unit directory: #{dir}" if current_payload_dir.join(dir).symlink?
381
384
 
382
385
  next nil unless search_index_entries(dir).any? { |entry| entry['identifier'] == identifier }
383
386
 
384
387
  unit = read_published_unit(dir, identifier)
385
- unit if unit['type'] == type
388
+ unit if unit['type'] == type || !UNIT_TYPE_TO_DIR.key?(type)
386
389
  end
387
390
  end
388
391
  ensure_fresh!
@@ -489,6 +492,7 @@ module Woods
489
492
  # @api private
490
493
  def search_within_pin(query = nil, types: nil, fields: %w[identifier], limit: 20,
491
494
  exact_prefix: nil, exact_suffix: nil, packages: nil, source_paths: nil)
495
+ types = normalize_search_types(types)
492
496
  scope = search_scope(packages, source_paths, types)
493
497
  prefix = exact_prefix.blank? ? nil : exact_prefix.downcase
494
498
  suffix = exact_suffix.blank? ? nil : exact_suffix.downcase
@@ -508,7 +512,7 @@ module Woods
508
512
  phase2_scanned = 0
509
513
 
510
514
  begin
511
- dirs = scope || !types ? TYPE_DIRS : types.filter_map { |type| TYPE_TO_DIR[type] }.uniq
515
+ dirs = types ? types.map { |type| UNIT_TYPE_TO_DIR.fetch(type) }.uniq : TYPE_DIRS
512
516
  # Identifier matches retain priority. Deep candidates are interleaved
513
517
  # across types so an early large directory cannot consume their budget.
514
518
  phase2_queues = {}
@@ -516,7 +520,13 @@ module Woods
516
520
  dirs.each do |dir|
517
521
  type_name = DIR_TO_TYPE[dir]
518
522
  entries = search_index_entries(dir)
519
- entries = scoped_search_entries(entries, dir, scope) if scope
523
+ entries = if scope
524
+ scoped_search_entries(entries, dir, scope)
525
+ elsif types
526
+ typed_search_entries(entries, dir, types, results)
527
+ else
528
+ entries
529
+ end
520
530
  if entries.size > 1
521
531
  matching_count = entries.count do |entry|
522
532
  identifier_passes_filters?(entry['identifier'], pattern, prefix, suffix)
@@ -527,11 +537,13 @@ module Woods
527
537
  end
528
538
 
529
539
  entries.each do |entry|
530
- type_name = entry.fetch('scope_type', DIR_TO_TYPE[dir])
531
540
  id = entry['identifier']
532
541
  next unless identifier_passes_prefix_suffix?(id, prefix, suffix)
533
542
 
534
543
  if fields.include?('identifier') && pattern.match?(id)
544
+ type_name = search_entry_type(entry, dir, results)
545
+ next unless type_name
546
+
535
547
  results.add(identifier: id, type: type_name, match_field: 'identifier')
536
548
  throw :search_done if results.result_limit_reached?
537
549
 
@@ -539,7 +551,7 @@ module Woods
539
551
  end
540
552
  next unless fields.include?('metadata') || fields.include?('source_code')
541
553
 
542
- (phase2_queues[dir] ||= []) << [type_name, id]
554
+ (phase2_queues[dir] ||= []) << [entry, dir]
543
555
  end
544
556
  end
545
557
 
@@ -553,10 +565,21 @@ module Woods
553
565
  results.stop('scan_budget')
554
566
  throw :search_done
555
567
  end
556
- type_name, id = queue.shift
568
+ entry, dir = queue.shift
569
+ id = entry['identifier']
557
570
  progressed = true
558
571
  phase2_scanned += 1
559
- unit = scope ? scope.metadata_store.find(StorageIdentity.key(id, type_name)) : load_search_unit(type_name, id)
572
+ type_name = search_entry_type(entry, dir, results)
573
+ next unless type_name
574
+
575
+ unit = if scope
576
+ scope.metadata_store.find(StorageIdentity.key(id, type_name))
577
+ else
578
+ readable_search_unit(type_name, id, results)
579
+ end
580
+ next unless unit
581
+
582
+ type_name = unit['type']
560
583
  field = if fields.include?('source_code') && unit['source_code'] && pattern.match?(unit['source_code'])
561
584
  'source_code'
562
585
  elsif fields.include?('metadata') && unit['metadata'] && pattern.match?(unit['metadata'].to_json)
@@ -593,6 +616,53 @@ module Woods
593
616
  end
594
617
  private :search_scope
595
618
 
619
+ def normalize_search_types(types)
620
+ return nil if types.nil? || types == []
621
+ unless types.is_a?(Array) && types.all?(String)
622
+ raise ArgumentError, 'search types must be an array of type names'
623
+ end
624
+
625
+ types.flat_map do |type|
626
+ if TYPE_TO_DIR.key?(type)
627
+ UNIT_TYPES_BY_DIR.fetch(TYPE_TO_DIR.fetch(type))
628
+ elsif UNIT_TYPE_TO_DIR.key?(type)
629
+ [type]
630
+ else
631
+ raise ArgumentError, "unknown search type: #{type}"
632
+ end
633
+ end.uniq
634
+ end
635
+ private :normalize_search_types
636
+
637
+ # Modern summaries carry their public type. Resolve legacy family types
638
+ # only when a type filter or matching candidate needs that identity.
639
+ def search_entry_type(entry, dir, results)
640
+ actual = entry['scope_type'] || entry['type']
641
+ possible = UNIT_TYPES_BY_DIR.fetch(dir)
642
+ actual ||= possible.one? ? possible.first : readable_search_unit(DIR_TO_TYPE.fetch(dir), entry['identifier'], results)&.fetch('type')
643
+ return actual if possible.include?(actual)
644
+
645
+ results.stop('unreadable_or_corrupt_source')
646
+ nil
647
+ end
648
+ private :search_entry_type
649
+
650
+ def readable_search_unit(type, identifier, results)
651
+ load_search_unit(type, identifier)
652
+ rescue JSON::ParserError, IOError, SystemCallError
653
+ results.stop('unreadable_or_corrupt_source')
654
+ nil
655
+ end
656
+ private :readable_search_unit
657
+
658
+ def typed_search_entries(entries, dir, types, results)
659
+ entries.filter_map do |entry|
660
+ actual = search_entry_type(entry, dir, results)
661
+ entry.merge('scope_type' => actual) if types.include?(actual)
662
+ end
663
+ end
664
+ private :typed_search_entries
665
+
596
666
  def scoped_search_entries(entries, dir, scope)
597
667
  entries.flat_map do |entry|
598
668
  UNIT_TYPES_BY_DIR.fetch(dir).filter_map do |type|
@@ -870,7 +940,7 @@ module Woods
870
940
  expected_dir.join('manifest.json').to_s, File::RDONLY
871
941
  )
872
942
  file.flock(File::LOCK_SH)
873
- marker = @generation.current
943
+ marker = published_marker
874
944
  resolved = resolve_payload_dir(marker)
875
945
  marker_still_matches = marker.number.zero? || (same_generation?(marker) && resolved == expected_dir)
876
946
  if marker_still_matches && expected_dir.directory?
@@ -881,13 +951,16 @@ module Woods
881
951
  file.flock(File::LOCK_UN)
882
952
  file.close
883
953
  load_generation(marker) unless marker.number.zero?
954
+ rescue Woods::Generation::InvalidMarker
955
+ file&.close unless file&.closed?
956
+ raise
884
957
  rescue Errno::ENOENT
885
958
  file&.close unless file&.closed?
886
959
  return if saw_missing_manifest && missing_manifest_dir == expected_dir
887
960
 
888
961
  saw_missing_manifest = true
889
962
  missing_manifest_dir = expected_dir
890
- marker = @generation.current
963
+ marker = published_marker
891
964
  load_generation(marker) unless marker.number.zero?
892
965
  end
893
966
  end
@@ -934,13 +1007,26 @@ module Woods
934
1007
  return nil if @pin_depth.positive?
935
1008
 
936
1009
  signature = generation_signature
937
- return nil if signature.nil? || signature == @generation_signature
1010
+ if signature.nil?
1011
+ published_marker if @loaded_generation
1012
+ return nil
1013
+ end
1014
+ return nil if signature == @generation_signature
938
1015
 
1016
+ marker = published_marker
1017
+ resolve_payload_dir(marker)
1018
+ load_generation(marker) unless marker.number.zero? || same_generation?(marker)
939
1019
  @generation_signature = signature
940
- marker = @generation.current
941
- return nil if marker.number.zero? || same_generation?(marker)
1020
+ @loaded_generation
1021
+ end
942
1022
 
943
- load_generation(marker)
1023
+ def published_marker
1024
+ marker = @generation.current!
1025
+ if marker.number.zero? && @loaded_generation&.positive? && current_payload_dir != @index_dir
1026
+ raise Woods::Generation::InvalidMarker, 'Published generation marker is missing'
1027
+ end
1028
+
1029
+ marker
944
1030
  end
945
1031
 
946
1032
  # Adopt one already-read marker as the reader's loaded generation.
@@ -949,8 +1035,9 @@ module Woods
949
1035
  # @param marker [Woods::Generation::Marker]
950
1036
  # @return [void]
951
1037
  def load_generation(marker)
1038
+ directory = resolve_payload_dir(marker)
952
1039
  reload!
953
- @payload_dir = resolve_payload_dir(marker)
1040
+ @payload_dir = directory
954
1041
  @loaded_token = marker.token
955
1042
  @loaded_generation = marker.number
956
1043
  end
@@ -968,7 +1055,7 @@ module Woods
968
1055
 
969
1056
  marker = Woods::Generation.new(output_dir: @index_dir).current
970
1057
  resolve_payload_dir(marker).join('manifest.json').file?
971
- rescue TypeError, NoMethodError
1058
+ rescue TypeError, NoMethodError, Woods::Generation::InvalidMarker
972
1059
  # Match Bootstrapper's startup preflight for malformed marker shapes.
973
1060
  # Keep this local: errors during later generation refresh still surface.
974
1061
  false
@@ -995,7 +1082,12 @@ module Woods
995
1082
  # @param marker [Woods::Generation::Marker]
996
1083
  # @return [Pathname]
997
1084
  def resolve_payload_dir(marker)
998
- Woods::Generation.new(output_dir: @index_dir).payload_dir(marker)
1085
+ directory = Woods::Generation.new(output_dir: @index_dir).payload_dir(marker)
1086
+ if marker.payload && !marker.payload.empty? && directory == @index_dir
1087
+ raise Woods::Generation::InvalidMarker, 'Published generation payload is unavailable or outside the index'
1088
+ end
1089
+
1090
+ directory
999
1091
  end
1000
1092
 
1001
1093
  # Compare the *token*, not the number.
@@ -1103,9 +1195,11 @@ module Woods
1103
1195
  # generation file
1104
1196
  def generation_signature
1105
1197
  stat = File.stat(@generation.path)
1106
- [stat.mtime.to_f, stat.size, stat.ino]
1107
- rescue SystemCallError
1198
+ [stat.mtime.to_f, stat.ctime.to_f, stat.size, stat.ino]
1199
+ rescue Errno::ENOENT
1108
1200
  nil
1201
+ rescue SystemCallError
1202
+ raise Woods::Generation::InvalidMarker, 'Published generation marker is unavailable'
1109
1203
  end
1110
1204
 
1111
1205
  # Case-insensitive literal prefix/suffix check on an identifier.
@@ -1211,8 +1305,9 @@ module Woods
1211
1305
  # Read the selected type bucket, never the identifier-only lookup map.
1212
1306
  # Keep the existing per-file signature checks and LRU cache for deep reads.
1213
1307
  def load_search_unit(type, identifier)
1214
- data = load_unit(TYPE_TO_DIR.fetch(type), unit_filename(identifier))
1215
- unless valid_published_unit?(data, TYPE_TO_DIR.fetch(type), identifier)
1308
+ directory = TYPE_TO_DIR[type] || UNIT_TYPE_TO_DIR.fetch(type)
1309
+ data = load_unit(directory, unit_filename(identifier))
1310
+ unless valid_published_unit?(data, directory, identifier)
1216
1311
  raise IOError, "typed unit identity mismatch: #{type}:#{identifier}"
1217
1312
  end
1218
1313
 
@@ -1263,7 +1358,13 @@ module Woods
1263
1358
  data = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
1264
1359
  raise IOError, "non-regular unit file: #{dir}/#{filename}" unless file.stat.file?
1265
1360
 
1266
- JSON.parse(file.read)
1361
+ # Decode the checked descriptor's bytes as UTF-8, independent of the
1362
+ # process locale. JSON parsers may accept invalid UTF-8 verbatim, so
1363
+ # validate before parsing instead of replacing malformed source bytes.
1364
+ content = file.binmode.read.force_encoding(Encoding::UTF_8)
1365
+ raise JSON::ParserError, "invalid UTF-8 in unit file: #{dir}/#{filename}" unless content.valid_encoding?
1366
+
1367
+ JSON.parse(content)
1267
1368
  end
1268
1369
  unless valid_published_unit?(data, dir, identifier)
1269
1370
  raise IOError, "typed unit identity mismatch: #{dir}/#{filename}"
@@ -64,6 +64,15 @@ module Woods
64
64
  return super unless reader && tool_names&.include?(request[:name])
65
65
 
66
66
  reader.with_pinned_generation { super }
67
+ rescue Woods::Generation::InvalidMarker
68
+ message = 'Published generation marker is unavailable or malformed; run woods:validate and restore or rebuild the index.'
69
+ meta = { error_code: :corrupt_artifact, tool: request[:name] }
70
+ meta[:completeness] = SearchResults.unavailable if request[:name] == 'search'
71
+ ::MCP::Tool::Response.new(
72
+ [{ type: 'text', text: message }], error: true,
73
+ structured_content: { text: message },
74
+ meta: meta
75
+ ).to_h
67
76
  end
68
77
 
69
78
  def read_resource_contents(request, **kwargs)
@@ -71,6 +80,13 @@ module Woods
71
80
  return super unless reader
72
81
 
73
82
  reader.with_pinned_generation { super }
83
+ rescue Woods::Generation::InvalidMarker => e
84
+ raise ::MCP::Server::RequestHandlerError.new(
85
+ 'Published generation marker is unavailable or malformed.', request,
86
+ error_type: :internal_error, original_error: e,
87
+ error_code: ::JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
88
+ error_data: { uri: request[:uri], error_code: 'corrupt_artifact' }
89
+ )
74
90
  end
75
91
  end
76
92
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
 
5
- require_relative '../util/host_guard'
5
+ require_relative 'origin_policy'
6
6
 
7
7
  module Woods
8
8
  module MCP
@@ -21,17 +21,13 @@ module Woods
21
21
  # also requiring Host to appear in the allow-list (or to be a loopback
22
22
  # address), we close that gap even when Rails is bound to 0.0.0.0.
23
23
  #
24
- # Port-matching: an allow-list entry WITHOUT a port (`http://localhost`)
25
- # matches that host on any port. An entry WITH a port (`http://localhost:3000`)
26
- # requires an exact port match. Specify explicit ports when port isolation
27
- # matters.
24
+ # Cross-origin entries match exactly. A portless entry also permits
25
+ # same-authority requests on other ports; cross-port browser clients need
26
+ # their actual origin explicitly configured, as required by the SDK.
28
27
  #
29
28
  # Also answers CORS preflight (OPTIONS) with the matching allow-list.
30
29
  class OriginGuard
31
- DEFAULT_ALLOWED = %w[
32
- http://localhost http://127.0.0.1 http://[::1]
33
- https://localhost https://127.0.0.1 https://[::1]
34
- ].freeze
30
+ DEFAULT_ALLOWED = OriginPolicy::DEFAULT_ORIGINS
35
31
 
36
32
  # Hosts that always pass the Host-header check even without an explicit
37
33
  # allow-list entry — they resolve to loopback by definition and cannot
@@ -56,6 +52,7 @@ module Woods
56
52
  # which runs after Rails railtie initializers captured the middleware
57
53
  # arguments — still takes effect (#183). Empty/nil falls back to
58
54
  # {DEFAULT_ALLOWED}.
55
+ # @param policy [OriginPolicy, nil] Captured policy also passed to the SDK
59
56
  # @param path [String, nil] When set, only requests whose PATH_INFO
60
57
  # starts with this prefix are guarded — everything else passes
61
58
  # straight through to the app. Nil (the default) guards every request.
@@ -82,27 +79,35 @@ module Woods
82
79
  method = env['REQUEST_METHOD']
83
80
  host = env['HTTP_HOST']
84
81
 
85
- return forbidden if origin && !origin_allowed?(origin)
86
- return forbidden_host if host && !host_allowed?(host)
82
+ return forbidden unless policy.origin_allowed?(origin, host: host)
83
+ return forbidden_host unless policy.host_allowed?(host)
87
84
 
88
85
  return preflight(origin) if method == 'OPTIONS'
89
86
 
90
87
  status, headers, body = @app.call(env)
91
- headers = cors_headers(origin).merge(headers) if origin && origin_allowed?(origin)
88
+ headers = cors_headers(origin).merge(headers) if origin
92
89
  [status, headers, body]
93
90
  end
94
91
 
92
+ # The same lazily captured immutable policy is passed to the transport.
93
+ # @return [OriginPolicy]
94
+ def policy
95
+ return @policy if @policy
96
+
97
+ @policy_mutex.synchronize do
98
+ @policy ||= OriginPolicy.new(allowed_origins: @allowed_source.call)
99
+ end
100
+ end
101
+
95
102
  private
96
103
 
97
- def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil)
104
+ def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil, policy: nil)
98
105
  @app = app
99
106
  @path = path
100
107
  @enabled = enabled
101
- if allowed_origins.respond_to?(:call)
102
- @allowed_source = allowed_origins
103
- else
104
- build_allow_list(allowed_origins)
105
- end
108
+ @policy = policy
109
+ @policy_mutex = Mutex.new
110
+ @allowed_source = allowed_origins.respond_to?(:call) ? allowed_origins : -> { allowed_origins }
106
111
  end
107
112
 
108
113
  # @param env [Hash] Rack environment
@@ -114,66 +119,8 @@ module Woods
114
119
  true
115
120
  end
116
121
 
117
- # Normalize a raw allow-list into `@allowed` + `@allowed_hosts`.
118
- #
119
- # @param origins [Array<String>, nil]
120
- # @return [Array<String>] the normalized allow-list
121
- def build_allow_list(origins)
122
- allowed = Array(origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) }
123
- allowed = DEFAULT_ALLOWED.dup if allowed.empty?
124
- @allowed_hosts = allowed.map { |o| extract_host(o) }.compact.uniq
125
- @allowed = allowed
126
- end
127
-
128
- # The allow-list, resolving a deferred source on first use. Memoized —
129
- # configuration is settled by the time the first request arrives.
130
- #
131
- # @return [Array<String>]
132
- def allowed
133
- @allowed || build_allow_list(@allowed_source.call)
134
- end
135
-
136
- # @return [Array<String>] hosts extracted from the allow-list
137
- def allowed_hosts
138
- allowed
139
- @allowed_hosts
140
- end
141
-
142
- def normalize(origin)
143
- origin.to_s.sub(%r{/\z}, '').downcase
144
- end
145
-
146
- def extract_host(origin)
147
- host = origin.to_s.sub(%r{\Ahttps?://}, '').sub(%r{/.*\z}, '').downcase
148
- host.empty? ? nil : host
149
- end
150
-
151
- def host_allowed?(host)
152
- # Canonicalize (strip port, trailing dot, IPv6 brackets) via the
153
- # shared helper so Qdrant and OriginGuard stay in sync on bypass
154
- # notations. `normalized` keeps the port for literal allow-list
155
- # lookups; `bare` drops it for loopback matching.
156
- normalized = host.to_s.downcase.sub(/\.\z/, '')
157
- bare = Util::HostGuard.canonicalize(host)
158
-
159
- # Reject non-canonical numeric hosts. Net::HTTP / getaddrinfo
160
- # would happily resolve `0x7f000001` or `2130706433` to 127.0.0.1,
161
- # bypassing the loopback allow-list.
162
- return false if Util::HostGuard.suspicious_numeric_host?(bare)
163
-
164
- return true if LOOPBACK_HOSTS.include?(bare)
165
-
166
- allowed_hosts.include?(normalized) || allowed_hosts.include?(bare)
167
- end
168
-
169
- def origin_allowed?(origin)
170
- return false if origin.match?(/[[:cntrl:]]/)
171
-
172
- allowed.include?(normalize(origin)) || allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
173
- end
174
-
175
122
  def preflight(origin)
176
- headers = origin && origin_allowed?(origin) ? cors_headers(origin) : {}
123
+ headers = origin ? cors_headers(origin) : {}
177
124
  [204, headers, []]
178
125
  end
179
126
 
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../util/host_guard'
5
+
6
+ module Woods
7
+ module MCP
8
+ # Immutable HTTP policy shared by Woods preflight and the MCP transport.
9
+ # Explicit cross-origin entries normalize HTTP(S) default ports. A portless entry also permits
10
+ # same-authority requests on another port, which the SDK accepts natively.
11
+ # No request header is rewritten and SDK rebinding protection stays enabled.
12
+ class OriginPolicy
13
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
14
+ DEFAULT_ORIGINS = %w[
15
+ http://localhost http://127.0.0.1 http://[::1]
16
+ https://localhost https://127.0.0.1 https://[::1]
17
+ ].freeze
18
+
19
+ # @param allowed_origins [Array<String>, nil] Explicit browser origins
20
+ def initialize(allowed_origins: nil)
21
+ entries = Array(allowed_origins).compact.reject do |entry|
22
+ entry.to_s.valid_encoding? && entry.to_s.strip.empty?
23
+ end
24
+ @explicit_origins = entries.map { |entry| configured_origin(entry) }.uniq.freeze
25
+ @allowed = (@explicit_origins.empty? ? DEFAULT_ORIGINS : @explicit_origins).freeze
26
+ @allowed_hosts = @explicit_origins.map do |origin|
27
+ host = authority(origin)
28
+ # SDK bare IPv6 host entries omit brackets; host:port entries retain
29
+ # them. URI#host alone retains brackets and loses explicit ports.
30
+ (host.end_with?(']') ? host[1...-1] : host).freeze
31
+ end.uniq.freeze
32
+ @transport_options = { allowed_origins: transport_origins, allowed_hosts: @allowed_hosts }.freeze
33
+ freeze
34
+ end
35
+
36
+ # @return [Hash] Constructor options supported by the MCP SDK
37
+ attr_reader :transport_options
38
+
39
+ # @param host [String, nil] Unmodified HTTP Host header
40
+ # @return [Boolean] Whether the request authority is allowed
41
+ def host_allowed?(host)
42
+ return true if host.nil?
43
+ return false unless host.is_a?(String) && host.valid_encoding?
44
+
45
+ parsed = parsed_origin("http://#{host}")
46
+ return false unless parsed
47
+
48
+ hostname = parsed.hostname.downcase
49
+ return false if Util::HostGuard.suspicious_numeric_host?(hostname)
50
+ return true if LOOPBACK_HOSTS.include?(hostname)
51
+
52
+ @allowed_hosts.include?(host.downcase) || @allowed_hosts.include?(hostname)
53
+ end
54
+
55
+ # @param origin [String, nil] Unmodified HTTP Origin header
56
+ # @param host [String, nil] Unmodified HTTP Host header
57
+ # @return [Boolean] Whether preflight and SDK dispatch can both accept it
58
+ def origin_allowed?(origin, host:)
59
+ return true if origin.nil?
60
+ return false unless parsed_origin(origin)
61
+
62
+ normalized = normalized_origin(origin)
63
+ return true if @explicit_origins.include?(normalized)
64
+ return false unless @allowed.include?(normalized) || @allowed.include?(normalized.sub(/:\d+\z/, ''))
65
+ return false unless host.is_a?(String) && host.valid_encoding?
66
+
67
+ # Matches the SDK's same-authority rule. The origin's scheme selects
68
+ # its default port; request.scheme is unreliable behind reverse proxies.
69
+ default_port = normalized.start_with?('https://') ? ':443' : ':80'
70
+ authority(normalized).delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
71
+ end
72
+
73
+ private
74
+
75
+ def configured_origin(entry)
76
+ raw = entry.to_s
77
+ normalized = raw.downcase.sub(%r{/\z}, '') if raw.valid_encoding?
78
+ unless parsed_origin(normalized)
79
+ label = raw.b.byteslice(0, 160).inspect
80
+ raise ArgumentError, "Invalid MCP allowed origin #{label}: expected http(s)://host[:port] without a path"
81
+ end
82
+
83
+ normalized_origin(normalized).freeze
84
+ end
85
+
86
+ def normalized_origin(origin)
87
+ value = origin.downcase
88
+ value.delete_suffix(value.start_with?('https://') ? ':443' : ':80')
89
+ end
90
+
91
+ # The SDK compares configured origins literally. Include equivalent
92
+ # default-port spellings without rewriting the incoming request headers.
93
+ def transport_origins
94
+ @explicit_origins.flat_map do |origin|
95
+ parsed = parsed_origin(origin)
96
+ default = parsed.scheme == 'https' ? 443 : 80
97
+ parsed.port == default ? [origin, "#{origin}:#{default}"] : [origin]
98
+ end.uniq.map(&:freeze).freeze
99
+ end
100
+
101
+ def authority(origin)
102
+ origin.sub(%r{\Ahttps?://}, '')
103
+ end
104
+
105
+ # Parse only serialized HTTP origins, never URLs with paths, userinfo,
106
+ # query strings, fragments or whitespace. The comparison layer handles
107
+ # default-port equivalence separately from parsing.
108
+ def parsed_origin(origin)
109
+ return unless origin.is_a?(String) && origin.valid_encoding? && origin.ascii_only?
110
+ return if origin.match?(/[[:space:][:cntrl:]]/)
111
+
112
+ parsed = URI.parse(origin)
113
+ return unless %w[http https].include?(parsed.scheme&.downcase)
114
+ return unless parsed.host && !parsed.host.empty?
115
+ return unless parsed.path.to_s.empty? && !parsed.userinfo && !parsed.query && !parsed.fragment
116
+ return unless parsed.port&.between?(1, 65_535)
117
+
118
+ parsed
119
+ rescue URI::InvalidURIError, ArgumentError
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../../source_contributors'
4
+
3
5
  module Woods
4
6
  module MCP
5
7
  module Renderers
@@ -16,7 +18,11 @@ module Woods
16
18
  lines = []
17
19
  lines << "## #{data['identifier']} (#{data['type']})"
18
20
  lines << ''
19
- lines << "**File:** `#{data['file_path']}`" if data['file_path']
21
+ if SourceContributors.multiple?(data)
22
+ lines << SourceContributors.label(data)
23
+ elsif data['file_path']
24
+ lines << "**File:** `#{data['file_path']}`"
25
+ end
20
26
  lines << "**Namespace:** #{data['namespace']}" if data['namespace']
21
27
  lines << ''
22
28
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../../source_contributors'
4
+
3
5
  module Woods
4
6
  module MCP
5
7
  module Renderers
@@ -14,7 +16,7 @@ module Woods
14
16
  lines = []
15
17
  lines << "#{data['identifier']} (#{data['type']})"
16
18
  lines << DIVIDER
17
- lines << "File: #{data['file_path']}" if data['file_path']
19
+ lines << SourceContributors.label(data) if data['file_path']
18
20
  lines << "Namespace: #{data['namespace']}" if data['namespace']
19
21
  lines << ''
20
22
 
@@ -65,7 +65,13 @@ module Woods
65
65
  }
66
66
  }
67
67
  result[:partial] = true unless complete
68
- result[:hint] = NARROWING_HINT unless complete
68
+ unless complete
69
+ result[:hint] = if @reason == 'unreadable_or_corrupt_source'
70
+ 'Run woods:validate and repair or republish unreadable unit artifacts.'
71
+ else
72
+ NARROWING_HINT
73
+ end
74
+ end
69
75
  result[:note] = note unless note.nil? || note.empty?
70
76
  result
71
77
  end