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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -7
- data/CONTRIBUTING.md +134 -19
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -25
- data/docs/CONSOLE_MCP_SETUP.md +82 -30
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +20 -15
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +153 -38
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +56 -30
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/embedded_executor.rb +30 -5
- data/lib/woods/console/rack_middleware.rb +29 -1
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +334 -11
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
- data/plugin/skills/woods-setup/SKILL.md +53 -6
- 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/.
|
|
50
|
-
#
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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] ||= []) << [
|
|
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
|
-
|
|
568
|
+
entry, dir = queue.shift
|
|
569
|
+
id = entry['identifier']
|
|
557
570
|
progressed = true
|
|
558
571
|
phase2_scanned += 1
|
|
559
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
941
|
-
|
|
1020
|
+
@loaded_generation
|
|
1021
|
+
end
|
|
942
1022
|
|
|
943
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 <<
|
|
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
|
-
|
|
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
|
data/lib/woods/mcp/server.rb
CHANGED
|
@@ -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?(
|
|
2501
|
-
return if %w[. ..].include?(
|
|
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, :
|
|
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
|
-
|
|
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],
|