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
@@ -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
@@ -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
@@ -601,6 +601,8 @@ module Woods
601
601
  respond.call(renderer.render(:search, payload))
602
602
  rescue Retrieval::Scope::InvalidScopeError => e
603
603
  respond_err.call(e.message, code: :unsupported_argument, tool: 'search', argument: 'scope')
604
+ rescue ArgumentError => e
605
+ respond_err.call(e.message, code: :invalid_params, tool: 'search', argument: 'types')
604
606
  rescue IOError, SystemCallError, JSON::ParserError, EncodingError
605
607
  respond_err.call(
606
608
  'Search completeness: unknown (unreadable_or_corrupt_source). ' \
@@ -1169,6 +1171,10 @@ module Woods
1169
1171
  }
1170
1172
  ) do |entry_point:, server_context:, depth: nil|
1171
1173
  max_depth = coerce_int.call(depth) || 3
1174
+ unless reader.find_unit(entry_point.split('#', 2).first)
1175
+ next respond_err.call('Flow entry unit not found; use search and lookup to select an indexed unit.',
1176
+ code: :not_found, tool: 'trace_flow', entry_point: entry_point)
1177
+ end
1172
1178
 
1173
1179
  # Prefer the precomputed flow JSON written by FlowPrecomputer during
1174
1180
  # extraction (gated on `config.precompute_flows`) — it avoids
@@ -1316,6 +1322,7 @@ module Woods
1316
1322
  require_relative '../extractor' unless defined?(Woods::Extractor)
1317
1323
  extractor = Woods::Extractor.new(output_dir: output_dir)
1318
1324
  incremental ? extractor.extract_changed(files) : extractor.extract_all
1325
+ extractor.raise_on_publication_failure!
1319
1326
  end
1320
1327
  end
1321
1328
 
@@ -1893,7 +1900,7 @@ module Woods
1893
1900
 
1894
1901
  def define_snapshot_tools(server, snapshot_store, respond, respond_err, snap_missing)
1895
1902
  define_list_snapshots_tool(server, snapshot_store, respond, snap_missing)
1896
- define_snapshot_diff_tool(server, snapshot_store, respond, snap_missing)
1903
+ define_snapshot_diff_tool(server, snapshot_store, respond, respond_err, snap_missing)
1897
1904
  define_unit_history_tool(server, snapshot_store, respond, snap_missing)
1898
1905
  define_snapshot_detail_tool(server, snapshot_store, respond, respond_err, snap_missing)
1899
1906
  end
@@ -1918,7 +1925,7 @@ module Woods
1918
1925
  end
1919
1926
  end
1920
1927
 
1921
- def define_snapshot_diff_tool(server, snapshot_store, respond, snap_missing)
1928
+ def define_snapshot_diff_tool(server, snapshot_store, respond, respond_err, snap_missing)
1922
1929
  server.define_tool(
1923
1930
  name: 'snapshot_diff',
1924
1931
  description: 'Compare two extraction snapshots by git SHA. Returns lists of added, modified, and deleted units.',
@@ -1932,6 +1939,17 @@ module Woods
1932
1939
  ) do |sha_a:, sha_b:, server_context:|
1933
1940
  next snap_missing.call('snapshot_diff') unless snapshot_store
1934
1941
 
1942
+ unless [sha_a, sha_b].all? { |sha| sha.match?(/\A[0-9a-f]+\z/i) }
1943
+ next respond_err.call('Snapshot SHAs must be hexadecimal identifiers from list_snapshots.',
1944
+ code: :invalid_params, tool: 'snapshot_diff')
1945
+ end
1946
+ sha_a = sha_a.downcase
1947
+ sha_b = sha_b.downcase
1948
+ unless snapshot_store.find(sha_a) && snapshot_store.find(sha_b)
1949
+ next respond_err.call('One or both snapshots were not found; use list_snapshots for available SHAs.',
1950
+ code: :not_found, tool: 'snapshot_diff')
1951
+ end
1952
+
1935
1953
  result = snapshot_store.diff(sha_a, sha_b)
1936
1954
  respond.call(JSON.pretty_generate({
1937
1955
  sha_a: sha_a, sha_b: sha_b,
@@ -1981,6 +1999,7 @@ module Woods
1981
1999
  ) do |git_sha:, server_context:|
1982
2000
  next snap_missing.call('snapshot_detail') unless snapshot_store
1983
2001
 
2002
+ git_sha = git_sha.downcase
1984
2003
  snapshot = snapshot_store.find(git_sha)
1985
2004
  if snapshot
1986
2005
  respond.call(JSON.pretty_generate(snapshot))
@@ -2497,8 +2516,9 @@ module Woods
2497
2516
 
2498
2517
  target = URI::DEFAULT_PARSER.unescape(raw_target).force_encoding(Encoding::UTF_8)
2499
2518
  return unless target.valid_encoding?
2500
- return if target.match?(%r{[%\\/\x00-\x1f\x7f]})
2501
- return if %w[. ..].include?(target)
2519
+ return if target.match?(/[%\\\x00-\x1f\x7f]/) || target.start_with?('/')
2520
+ return if target.split('/').any? { |part| %w[. ..].include?(part) }
2521
+ return if parsed.host == 'type' && target.include?('/')
2502
2522
 
2503
2523
  [parsed.host.to_sym, target]
2504
2524
  rescue URI::InvalidURIError
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ # Keeps callable app/models modules under one extractor owner as model
5
+ # inclusions change. Partial runtime discovery cannot establish removals.
6
+ module ModuleReconciliation # rubocop:disable Metrics/ModuleLength -- one ownership/provenance policy across writers
7
+ private
8
+
9
+ # @param affected_types [Set<Symbol>] extractor keys requiring index updates
10
+ # @return [Set<String>] added, migrated or removed module identifiers
11
+ def reconcile_model_mixins(affected_types)
12
+ concerns = extractor_for(:concerns)
13
+ return Set.new unless concerns.respond_to?(:runtime_model_mixins)
14
+
15
+ live = concerns.runtime_model_mixins
16
+ live_names = live.values.flatten.to_set(&:name)
17
+ known = @dependency_graph.units_of_type(:concern).to_set
18
+ added = newly_claimed_concerns(concerns, live, known)
19
+ touched = register_and_write(:concerns, added, affected_types)
20
+ touched.merge(remove_claimed_standalone_modules(live_names, affected_types))
21
+ touched.merge(remove_unclaimed_concerns(concerns, known - live_names, affected_types)) if @eager_load_complete
22
+ touched.merge(add_standalone_modules(affected_types))
23
+ end
24
+
25
+ def newly_claimed_concerns(concerns, live, known)
26
+ live.flat_map do |path, modules|
27
+ next [] if modules.all? { |mod| known.include?(mod.name) }
28
+
29
+ checked_extraction(:concerns, concerns) do
30
+ Array(concerns.extract_model_mixin_file(path)).reject { |unit| known.include?(unit.identifier) }
31
+ end || []
32
+ end
33
+ end
34
+
35
+ def remove_claimed_standalone_modules(live_names, affected_types)
36
+ live_names.each_with_object(Set.new) do |identifier, touched|
37
+ next unless standalone_module_identity?(identifier)
38
+
39
+ touched.add(identifier) if remove_unit(identifier, affected_types, type: :poro)
40
+ end
41
+ end
42
+
43
+ def remove_unclaimed_concerns(concerns, stale, affected_types)
44
+ stale.each_with_object(Set.new) do |identifier, touched|
45
+ path = @dependency_graph.node(identifier, type: :concern)[:file_path]
46
+ next if concerns.conventional_concern_path?(path)
47
+
48
+ touched.add(identifier) if remove_unit(identifier, affected_types, type: :concern)
49
+ end
50
+ end
51
+
52
+ def runtime_model_mixin_file?(extractor, type, path)
53
+ type == :concern && extractor.respond_to?(:extract_model_mixin_file) &&
54
+ extractor.respond_to?(:conventional_concern_path?) && !extractor.conventional_concern_path?(path)
55
+ end
56
+
57
+ def add_standalone_modules(affected_types)
58
+ poros = extractor_for(:poros)
59
+ return Set.new unless poros.respond_to?(:standalone_modules)
60
+
61
+ discovered = checked_extraction(:poros, poros) { poros.standalone_modules }
62
+ return Set.new unless discovered
63
+
64
+ added = discovered.values.flatten.reject do |unit|
65
+ @dependency_graph.node(unit.identifier, type: :poro) ||
66
+ @dependency_graph.node(unit.identifier, type: :concern)
67
+ end
68
+ register_and_write(:poros, added, affected_types)
69
+ end
70
+
71
+ def standalone_module_identity?(identifier)
72
+ return false unless @dependency_graph.node(identifier, type: :poro)
73
+
74
+ path = payload_dir.join('poros', collision_safe_filename(identifier))
75
+ return false unless path.file?
76
+
77
+ JSON.parse(AtomicFile.read(path)).fetch('metadata', {})['ruby_kind'] == 'module'
78
+ end
79
+
80
+ # A missing includer during fallback boot does not transfer its previously
81
+ # published concern to the PORO extractor. Filter fresh candidates too.
82
+ def authoritative_module_units(units)
83
+ return units if @eager_load_complete
84
+
85
+ units.reject do |unit|
86
+ unit.type == :poro && unit.metadata[:ruby_kind] == 'module' &&
87
+ @dependency_graph.node(unit.identifier, type: :concern)
88
+ end
89
+ end
90
+
91
+ # @param identifier [String] published typed identity
92
+ # @param type [Symbol] current owner
93
+ # @return [Boolean] whether incomplete loading requires retaining this unit
94
+ def retain_partial_module?(identifier, type)
95
+ return false if @eager_load_complete
96
+
97
+ return false unless runtime_module_identity?(identifier, type)
98
+ return false if type == :poro && live_concern_identity?(identifier)
99
+
100
+ verify_retained_module_source!(identifier, type)
101
+ true
102
+ end
103
+
104
+ def runtime_module_identity?(identifier, type)
105
+ return standalone_module_identity?(identifier) if type == :poro
106
+ return false unless type == :concern
107
+
108
+ concerns = extractor_for(:concerns)
109
+ path = @dependency_graph.node(identifier, type: type)[:file_path]
110
+ concerns.respond_to?(:conventional_concern_path?) && !concerns.conventional_concern_path?(path)
111
+ end
112
+
113
+ def live_concern_identity?(identifier)
114
+ concerns = extractor_for(:concerns)
115
+ concerns.respond_to?(:runtime_model_mixins) &&
116
+ concerns.runtime_model_mixins.values.any? { |modules| modules.any? { |mod| mod.name == identifier } }
117
+ end
118
+
119
+ # Cache identities predate every consumption update in this run. A freshly
120
+ # written shared-file sibling must not certify retained module metadata as
121
+ # having consumed changed source during incomplete eager loading.
122
+ def verify_retained_module_source!(identifier, type)
123
+ return unless @source_inputs
124
+
125
+ path = @dependency_graph.node(identifier, type: type)[:file_path]
126
+ relative = File.expand_path(path, Rails.root).delete_prefix("#{File.expand_path(Rails.root)}/")
127
+ before = @source_reference_baseline&.dig('files', relative, 'identity')
128
+ return if before && before == @source_inputs.source_identity(path)
129
+
130
+ raise SourceReferences::RebuildRequired,
131
+ "Cannot refresh retained #{type}:#{identifier} after incomplete eager loading. " \
132
+ 'Retry in a fresh Rails process with a complete eager load; the previous generation remains active.'
133
+ end
134
+
135
+ def consume_module_aware_refresh(key, units)
136
+ if !@eager_load_complete && %i[poros concerns].include?(key)
137
+ @source_inputs&.unverified("extractor:#{key}")
138
+ else
139
+ @source_inputs&.consume_extractor(key, units)
140
+ end
141
+ end
142
+
143
+ # These formerly file-only families now retain runtime-discovered modules
144
+ # on a partial boot, so their replacement authority follows the same gate.
145
+ def replacement_discovery_complete?(key)
146
+ return @eager_load_complete if %i[poros concerns].include?(key)
147
+
148
+ super
149
+ end
150
+ end
151
+ end
@@ -165,7 +165,7 @@ module Woods
165
165
  file_rule(:migrations, :extract_migration_file, %w[db/migrate], recursive: false),
166
166
  # POROs are app/models classes that are *not* ActiveRecord models;
167
167
  # the extractor makes that call itself given ar_names.
168
- file_rule(:poros, :extract_poro_file, %w[app/models], exclude: %w[/concerns/]),
168
+ file_rule(:poros, :extract_poro_units, %w[app/models], exclude: %w[/concerns/]),
169
169
  file_rule(:libs, :extract_lib_file, %w[lib], exclude: ex::LibExtractor::EXCLUDED_SEGMENTS),
170
170
  file_rule(:test_mappings, :extract_test_file, %w[spec], extensions: %w[_spec.rb]),
171
171
  file_rule(:test_mappings, :extract_test_file, %w[test], extensions: %w[_test.rb])
@@ -184,11 +184,16 @@ module Woods
184
184
  Woods::Extractors::ViewTemplateExtractor::ENGINES.flat_map { |k| k.new.extensions }.uniq
185
185
  end
186
186
 
187
+ def aggregate_source_rules
188
+ [whole_app_rule(:libs, %w[lib], extensions: %w[.rb], exclude: Woods::Extractors::LibExtractor::EXCLUDED_SEGMENTS),
189
+ whole_app_rule(:rake_tasks, Woods::Extractors::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake])]
190
+ end
191
+
187
192
  def build_whole_app_rules
188
193
  [
189
194
  # A task may combine definitions from several files; any change or
190
195
  # deletion must reconcile the complete task set, not its primary file.
191
- whole_app_rule(:rake_tasks, Woods::Extractors::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake]),
196
+ *aggregate_source_rules,
192
197
  whole_app_rule(:routes, %w[config/routes], exact_paths: %w[config/routes.rb]),
193
198
  whole_app_rule(:engines, %w[config/routes], exact_paths: %w[config/routes.rb Gemfile.lock]),
194
199
  whole_app_rule(:middleware, %w[config/initializers config/environments],