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
@@ -13,6 +13,7 @@ require_relative 'filename_utils'
13
13
  require_relative 'token_utils'
14
14
  require_relative 'extracted_unit'
15
15
  require_relative 'dependency_graph'
16
+ require_relative 'extraction_identities'
16
17
  require_relative 'payload_store'
17
18
  require_relative 'git_provenance'
18
19
  require_relative 'git_history'
@@ -58,6 +59,8 @@ require_relative 'change_set'
58
59
  require_relative 'generation'
59
60
  require_relative 'path_dispatcher'
60
61
  require_relative 'source_inputs/session'
62
+ require_relative 'source_references/extraction'
63
+ require_relative 'module_reconciliation'
61
64
 
62
65
  module Woods
63
66
  # Extractor is the main orchestrator for codebase extraction.
@@ -74,8 +77,11 @@ module Woods
74
77
  # extractor.extract_changed(["app/models/user.rb", "app/services/checkout.rb"])
75
78
  #
76
79
  class Extractor
80
+ include ExtractionIdentities
77
81
  include FilenameUtils
78
82
  include Extractors::SourceNesting
83
+ include SourceReferences::Extraction
84
+ include ModuleReconciliation
79
85
 
80
86
  # Directories under app/ that contain classes we need to extract.
81
87
  # Used by eager_load_extraction_directories as a fallback when
@@ -215,7 +221,7 @@ module Woods
215
221
  database_view: :extract_view_file,
216
222
  caching: :extract_caching_file,
217
223
  test_mapping: :extract_test_file,
218
- poro: :extract_poro_file,
224
+ poro: :extract_poro_units,
219
225
  lib: :extract_lib_file
220
226
  }.freeze
221
227
 
@@ -226,7 +232,12 @@ module Woods
226
232
  # class-based entry point to re-extract them through. Re-extracting one
227
233
  # of these by file is faithful only when the file names the unit; see
228
234
  # {#re_extracted_units}.
229
- CLASS_DISCOVERED_FALLBACK = { job: :extract_job_class }.freeze
235
+ CLASS_DISCOVERED_FALLBACK = { job: :extract_job_class, serializer: :extract_serializer_class }.freeze
236
+
237
+ # These families combine file discovery with runtime descendants. Only
238
+ # their union can authorize removals; a per-file pass cannot see nested
239
+ # classes, while a descendant-only pass cannot see source-only units.
240
+ HYBRID_DISCOVERY_EXTRACTORS = %i[jobs serializers].freeze
230
241
 
231
242
  # Unit types each extractor owns — the inverse of {TYPE_TO_EXTRACTOR_KEY}.
232
243
  #
@@ -277,7 +288,8 @@ module Woods
277
288
  components: { type: :component, method: :extract_component },
278
289
  view_components: { type: :view_component, method: :extract_component },
279
290
  action_cable_channels: { type: :action_cable_channel, method: :extract_channel },
280
- # Additions only — see `reconcile_removals: false`. GraphQL is the one
291
+ # Additions and known-type role changes, without discovery-based removals.
292
+ # See `reconcile_removals: false`. GraphQL is the one
281
293
  # entry whose discovery set is not authoritative for its unit type
282
294
  # (#167).
283
295
  # `types:` because this extractor emits four unit types, not one:
@@ -301,6 +313,7 @@ module Woods
301
313
  #
302
314
  # @return [Hash{Symbol => Symbol}] extractor key => unit type
303
315
  WHOLE_APP_EXTRACTORS = {
316
+ libs: :lib,
304
317
  rake_tasks: :rake_task,
305
318
  routes: :route,
306
319
  middleware: :middleware,
@@ -358,7 +371,9 @@ module Woods
358
371
  # flat index — the output root also holds `generation.json`, `dumps/`,
359
372
  # `tasks/`, `woods.sqlite3` and `payloads/` itself, none of which belong
360
373
  # to a generation's payload.
361
- PAYLOAD_FILES = %w[manifest.json source_inputs.json dependency_graph.json graph_analysis.json SUMMARY.md].freeze
374
+ PAYLOAD_FILES = %w[
375
+ manifest.json source_inputs.json source_references.json dependency_graph.json graph_analysis.json SUMMARY.md
376
+ ].freeze
362
377
 
363
378
  # Payload directories that are not per-type unit directories.
364
379
  PAYLOAD_DIRS = %w[flows].freeze
@@ -432,6 +447,7 @@ module Woods
432
447
  # Phase 1.6: Package membership. Runs before the graph is rebuilt so
433
448
  # registration copies metadata[:package] onto the node (#280).
434
449
  profile_phase('package annotation') { annotate_packages }
450
+ profile_phase('source references') { enrich_source_references_full }
435
451
 
436
452
  # Rebuild the graph from deduped results. #164 gave DependencyGraph
437
453
  # `#remove`/`#unregister`, so surgical removal is now possible — but a
@@ -492,6 +508,8 @@ module Woods
492
508
  if Woods.configuration.precompute_flows
493
509
  Rails.logger.info '[Woods] Precomputing request flows...'
494
510
  profile_phase('flows') { precompute_flows }
511
+ else
512
+ withdraw_disabled_flows
495
513
  end
496
514
 
497
515
  profile_phase('graph write') do
@@ -541,11 +559,19 @@ module Woods
541
559
  #
542
560
  # @param changed_files [Array<String>] List of changed file paths
543
561
  # @return [Array<String>] Identifiers of units re-extracted, added, or removed
562
+ # @raise [Woods::ExtractionError] when boot-captured inputs require a fresh
563
+ # Rails process and full extraction instead of reusing this runtime
544
564
  def extract_changed(changed_files)
545
565
  profile_started = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profiling?
546
- prepare_incremental_run(operation: 'incremental')
547
-
548
566
  change_set = ChangeSet.new(paths: changed_files, root: Rails.root)
567
+ require_relative 'input_rules'
568
+ restart_paths = change_set.relative_paths.select { |path| InputRules.new.action(path) == :full }
569
+ unless restart_paths.empty?
570
+ raise Woods::ExtractionError,
571
+ "Restart-sensitive inputs (#{restart_paths.join(', ')}) require a fresh Rails process; " \
572
+ 'run woods:extract after applying schema/configuration changes'
573
+ end
574
+ prepare_incremental_run(operation: 'incremental')
549
575
  affected_types = Set.new
550
576
 
551
577
  # Blast radius from the pre-change graph, bounded by
@@ -553,6 +579,7 @@ module Woods
553
579
  affected_ids = profile_phase('blast radius') do
554
580
  @dependency_graph.affected_by(change_set.absolute_paths, max_depth: blast_radius_depth)
555
581
  end
582
+ @hybrid_discovery_keys = hybrid_discovery_keys(change_set, affected_ids)
556
583
  @flow_scope = profile_phase('flow radius') { flow_scope_for(change_set) }
557
584
  Rails.logger.info "[Woods] #{change_set.size} changed files affect #{affected_ids.size} units"
558
585
 
@@ -567,6 +594,7 @@ module Woods
567
594
  end
568
595
 
569
596
  profile_phase('reconciliation') do
597
+ @hybrid_discovery_keys.each { |key| touched.merge(replace_type_wholesale(key, affected_types)) }
570
598
  touched.merge(reconcile_class_based_types(affected_types))
571
599
  touched.merge(reconcile_model_mixins(affected_types))
572
600
  touched.merge(rerun_whole_app_extractors(change_set, affected_types))
@@ -601,6 +629,7 @@ module Woods
601
629
  end
602
630
 
603
631
  raise_on_handled_extraction_failure!
632
+ touched.merge(profile_phase('source references') { enrich_source_references_incremental(affected_types) })
604
633
  finalize_incremental_unit_json(affected_types)
605
634
 
606
635
  # Regenerate type indexes for affected types
@@ -663,7 +692,9 @@ module Woods
663
692
  acc.merge(replace_type_wholesale(key, affected_types))
664
693
  end
665
694
 
695
+ touched.merge(reconcile_model_mixins(affected_types)) if (known & %i[models poros concerns]).any?
666
696
  raise_on_handled_extraction_failure!
697
+ touched.merge(profile_phase('source references') { enrich_source_references_incremental(affected_types) })
667
698
  finalize_incremental_unit_json(affected_types)
668
699
  profile_phase('type index') { affected_types.each { |type_key| regenerate_type_index(type_key) } }
669
700
  finalize_incremental_run(touched, reason: "refresh:#{known.sort.join(',')}")
@@ -680,8 +711,9 @@ module Woods
680
711
  # the resident watch daemon can keep its recoverable posture: it detects
681
712
  # the unchanged generation, reports degraded, and carries the paths into
682
713
  # a later cycle. One-shot callers have no later cycle, so the rake tasks
683
- # call this method before reporting success and receive a typed non-zero
684
- # failure instead of claiming an unreachable payload was published.
714
+ # and embedded pipeline tool call this method before reporting success.
715
+ # They receive a typed failure instead of claiming an unreachable payload
716
+ # was published.
685
717
  #
686
718
  # @return [void]
687
719
  # @raise [Woods::ExtractionError] when the generation marker could not be
@@ -780,6 +812,7 @@ module Woods
780
812
  # @return [void]
781
813
  # @raise [Woods::ExtractionError] see {#begin_payload!}
782
814
  def prepare_incremental_run(operation: 'incremental')
815
+ ensure_supported_incremental_writer!
783
816
  profile_phase('source capture') { begin_source_inputs(operation) }
784
817
  profile_phase('payload seed') { begin_payload!(strict: true) }
785
818
  graph_path = payload_dir.join('dependency_graph.json')
@@ -788,16 +821,20 @@ module Woods
788
821
  @dependency_graph = DependencyGraph.from_h(JSON.parse(AtomicFile.read(graph_path))) if graph_path.exist?
789
822
  end
790
823
 
824
+ prepare_source_reference_baseline
791
825
  ModelNameCache.reset!
792
826
  profile_phase('eager load') { safe_eager_load! }
793
827
 
794
828
  @dependents_dirty = Set.new
795
829
  @incremental_written = {}
830
+ @library_replacement_touched = nil
831
+ @identity_claims = {}
796
832
  # nil = no scope was computed for this run, so every re-extracted
797
833
  # controller has its flows reassembled. {#refresh} leaves it that way.
798
834
  @flow_scope = nil
799
835
  @previous_flow_index_entries = nil
800
836
  @incremental_extractors = nil
837
+ @hybrid_discovery_keys = Set.new
801
838
  @active_record_names = nil
802
839
  @package_resolver = nil
803
840
  @persisted_index_stats = nil
@@ -830,9 +867,10 @@ module Woods
830
867
  # reading "incremental" after a `woods:refresh[routes]` is being misled
831
868
  # @return [void]
832
869
  def finalize_incremental_run(touched, reason: 'incremental')
870
+ withdrew_flows = withdraw_disabled_flows unless Woods.configuration.precompute_flows
833
871
  profile_phase('graph write') { write_dependency_graph }
834
872
 
835
- if touched.empty?
873
+ if touched.empty? && !withdrew_flows
836
874
  Rails.logger.info '[Woods] Incremental run changed nothing — leaving manifest timestamp untouched'
837
875
  return
838
876
  end
@@ -892,6 +930,9 @@ module Woods
892
930
  # handoff can additionally establish the pre-Bundler/Rails boot boundary.
893
931
  def begin_source_inputs(operation)
894
932
  @failed_consumers = Set.new
933
+ @source_reference_refreshed = Set.new
934
+ @source_reference_paths = nil
935
+ @source_reference_baseline = nil
895
936
  @source_inputs = SourceInputs::Session.new(root: Rails.root, output_dir: @output_dir,
896
937
  baseline_path: source_input_baseline_path,
897
938
  operation: operation)
@@ -940,6 +981,10 @@ module Woods
940
981
  return unless @source_inputs
941
982
 
942
983
  manifest = @source_inputs.finish(generation: @payload_generation, eager_load_complete: @eager_load_complete)
984
+ verify_source_reference_publication!(manifest)
985
+ if manifest.unavailable?
986
+ manifest.bind_reference_cache!(SourceReferences::Cache.read(payload_dir.join(SourceReferences::Cache::FILE_NAME)))
987
+ end
943
988
  AtomicFile.write(payload_dir.join(SourceInputs::Manifest::FILE_NAME), JSON.pretty_generate(manifest.data))
944
989
  end
945
990
 
@@ -1001,6 +1046,31 @@ module Woods
1001
1046
  @payload_generation = nil
1002
1047
  end
1003
1048
 
1049
+ # Readers retain legacy compatibility, but partial writers cannot migrate
1050
+ # retained v1 units. A missing version in a generation manifest is allowed:
1051
+ # early v2 prereleases published generations before recording provenance.
1052
+ def ensure_supported_incremental_writer!
1053
+ generation = Generation.new(output_dir: @output_dir)
1054
+ directory = generation.payload_dir
1055
+ manifest_path = directory.join('manifest.json')
1056
+ return unless manifest_path.file?
1057
+
1058
+ if directory == generation.root
1059
+ raise Woods::ExtractionError,
1060
+ "Cannot incrementally update a legacy flat index under #{@output_dir}; run a full woods:extract first"
1061
+ end
1062
+
1063
+ manifest = JSON.parse(AtomicFile.read(manifest_path))
1064
+ writer = manifest.fetch('woods_version', nil)
1065
+ return if writer.nil? || Gem::Version.new(writer).segments.first >= 2
1066
+
1067
+ raise Woods::ExtractionError,
1068
+ "Cannot incrementally update an index written by Woods #{writer}; run a full woods:extract first"
1069
+ rescue JSON::ParserError, ArgumentError, TypeError, NoMethodError => e
1070
+ raise Woods::ExtractionError,
1071
+ "Cannot verify the baseline manifest writer (#{e.class}); run a full woods:extract first"
1072
+ end
1073
+
1004
1074
  # Refuse an incremental run that has no baseline to be incremental
1005
1075
  # against (CORE-2). With no published generation and no dependency
1006
1076
  # graph — a failed CI cache restore, a typo'd WOODS_OUTPUT, a first run
@@ -1011,8 +1081,9 @@ module Woods
1011
1081
  # until a full extraction. The watch daemon already enforces this
1012
1082
  # invariant on its side (a missing generation marker means one full
1013
1083
  # extraction); the one-shot entry points must refuse rather than
1014
- # publish silently. A pre-generation flat index passes: its graph was
1015
- # seeded into this run's payload directory by {#begin_payload!}.
1084
+ # publish silently. Legacy flat manifests are refused by the writer
1085
+ # compatibility check before seeding; a bare graph can still provide an
1086
+ # embedding caller's baseline without asserting a legacy writer version.
1016
1087
  #
1017
1088
  # @param graph_path [Pathname] the seeded payload's dependency graph
1018
1089
  # @return [void]
@@ -1432,17 +1503,17 @@ module Woods
1432
1503
  retained_paths = {}
1433
1504
 
1434
1505
  units.each do |unit|
1435
- if retained_paths.key?(unit.identifier)
1436
- prior_path = retained_paths[unit.identifier]
1437
- if unit.file_path != prior_path
1438
- raise Woods::ExtractionError, same_type_collision_message(type, unit, prior_path)
1439
- end
1506
+ identity = unit.identifier
1507
+ path = identity_source(unit.file_path)
1508
+ if retained_paths.key?(identity)
1509
+ prior_path = retained_paths[identity]
1510
+ reject_identity_collision!(unit, prior_path) if path != prior_path
1440
1511
 
1441
1512
  dropped += 1
1442
1513
  next
1443
1514
  end
1444
1515
 
1445
- retained_paths[unit.identifier] = unit.file_path
1516
+ retained_paths[identity] = path
1446
1517
  deduped << unit
1447
1518
  end
1448
1519
 
@@ -1461,6 +1532,7 @@ module Woods
1461
1532
  'only one unit could ever be indexed, so extraction aborted. ' \
1462
1533
  'Wrapper-nested class naming requires Zeitwerk mode with Zeitwerk >= 2.6.9; on older loaders or ' \
1463
1534
  'classic-mode hosts, check that support before changing valid namespace wrappers. ' \
1535
+ 'After moving a class, run woods:extract in a fresh Rails process if incremental ownership cannot be proven. ' \
1464
1536
  'For a genuine duplicate, merge the declarations into one file or split them into distinct constants'
1465
1537
  end
1466
1538
 
@@ -1485,6 +1557,31 @@ module Woods
1485
1557
  Rails.logger.info "[Woods] Precomputed #{flow_map.size} request flows"
1486
1558
  end
1487
1559
 
1560
+ # Withdraw the derived family in this unpublished payload. Atomic unit
1561
+ # replacement preserves hardlinked bytes in the preceding generation.
1562
+ # @return [Boolean] whether disabling precomputation changed the payload
1563
+ def withdraw_disabled_flows
1564
+ flows = payload_dir.join('flows')
1565
+ changed = flows.exist?
1566
+ FileUtils.remove_entry(flows) if changed
1567
+ controllers = payload_dir.join('controllers')
1568
+ return changed unless controllers.directory?
1569
+
1570
+ annotated = false
1571
+ controllers.children.sort.each do |file|
1572
+ next unless file.extname == '.json' && file.basename.to_s != '_index.json'
1573
+
1574
+ data = JSON.parse(AtomicFile.read(file))
1575
+ next unless data.fetch('metadata', {}).key?('flow_paths')
1576
+
1577
+ data['metadata'].delete('flow_paths')
1578
+ AtomicFile.write(file, json_serialize(data), durable: payload_writes_durable?)
1579
+ annotated = true
1580
+ end
1581
+ regenerate_type_index(:controllers) if annotated
1582
+ changed || annotated
1583
+ end
1584
+
1488
1585
  # Precompute runs after write_results (FlowAssembler reads unit JSON
1489
1586
  # from disk), so units annotated in memory with metadata[:flow_paths]
1490
1587
  # must be re-written and their type index refreshed to pick up the
@@ -1789,8 +1886,10 @@ module Woods
1789
1886
  next if %i[rails_source gem_source].include?(type)
1790
1887
 
1791
1888
  units.each do |unit|
1792
- path = unit.file_path
1793
- file_paths << path if git_enrichable_path?(path, root)
1889
+ SourceContributors.paths(unit).each do |source|
1890
+ path = File.expand_path(source, Rails.root)
1891
+ file_paths << path if git_enrichable_path?(path, root)
1892
+ end
1794
1893
  end
1795
1894
  end
1796
1895
 
@@ -1804,8 +1903,12 @@ module Woods
1804
1903
  units.each do |unit|
1805
1904
  next unless unit.file_path
1806
1905
 
1807
- relative = unit.file_path.sub(root, '')
1808
- unit.metadata[:git] = git_data[relative] if git_data[relative]
1906
+ if SourceContributors.multiple?(unit)
1907
+ SourceContributors.annotate_git(unit, git_data)
1908
+ else
1909
+ relative = unit.file_path.sub(root, '')
1910
+ unit.metadata[:git] = git_data[relative] if git_data[relative]
1911
+ end
1809
1912
  end
1810
1913
  end
1811
1914
  end
@@ -1881,7 +1984,7 @@ module Woods
1881
1984
  def annotate_package(unit)
1882
1985
  return nil if %i[rails_source gem_source].include?(unit.type) || unit.file_path.nil?
1883
1986
 
1884
- package = package_resolver.package_for(unit.file_path)
1987
+ package = SourceContributors.annotate_package(unit, package_resolver)
1885
1988
  if package
1886
1989
  unit.metadata[:package] = package
1887
1990
  else
@@ -1937,15 +2040,17 @@ module Woods
1937
2040
  relative_path = data['file_path']
1938
2041
  return nil if relative_path.nil? || relative_path.start_with?('/')
1939
2042
 
1940
- package = package_resolver.package_for(relative_path)
1941
2043
  metadata = (data['metadata'] ||= {})
1942
- return nil if metadata['package'] == package
2044
+ before = JSON.generate(metadata)
2045
+ package = SourceContributors.annotate_package(data, package_resolver)
1943
2046
 
1944
2047
  if package
1945
2048
  metadata['package'] = package
1946
2049
  else
1947
2050
  metadata.delete('package')
1948
2051
  end
2052
+ return nil if JSON.generate(metadata) == before
2053
+
1949
2054
  AtomicFile.write(file, json_serialize(data), durable: payload_writes_durable?)
1950
2055
 
1951
2056
  identifier = data['identifier']
@@ -1992,10 +2097,9 @@ module Woods
1992
2097
  # time, so the fsync is the cost being avoided here; the comparison read is
1993
2098
  # cheaper than the write it replaces.
1994
2099
  #
1995
- # Only the *write* is skipped. Graph registration, the dependents marking
1996
- # and `@incremental_written` all still happen for every unit, because those
1997
- # are what equivalence and the git-enrichment pass depend on — skipping any
1998
- # of them would make an unchanged unit differ from a full extraction.
2100
+ # This helper skips only the write. Incremental registration separately
2101
+ # checks complete serialized equality before marking dependents or Git work;
2102
+ # differences in those derived fields still require the normal finalization.
1999
2103
  #
2000
2104
  # Compared as bytes: `AtomicFile.write` is binmode, and the encoding a read
2001
2105
  # comes back tagged with depends on the process's default external encoding
@@ -2464,7 +2568,13 @@ module Woods
2464
2568
  #
2465
2569
  # @return [Hash{Symbol => Hash}] type => `{ count:, chunks:, namespaces: }`
2466
2570
  def persisted_index_stats
2467
- @persisted_index_stats ||= Dir[payload_dir.join('*/_index.json').to_s].each_with_object({}) do |path, stats|
2571
+ return @persisted_index_stats if @persisted_index_stats
2572
+
2573
+ directories = payload_dir.directory? ? payload_dir.children.sort : []
2574
+ paths = directories.filter_map do |directory|
2575
+ directory.join('_index.json') if directory.directory? && directory.join('_index.json').file?
2576
+ end
2577
+ @persisted_index_stats ||= paths.each_with_object({}) do |path, stats|
2468
2578
  entries = JSON.parse(AtomicFile.read(path))
2469
2579
  stats[File.basename(File.dirname(path)).to_sym] = {
2470
2580
  count: entries.size,
@@ -2664,8 +2774,8 @@ module Woods
2664
2774
  @persisted_index_stats = nil
2665
2775
 
2666
2776
  # Scan existing unit JSON files (exclude _index.json)
2667
- index = Dir[type_dir.join('*.json')].filter_map do |file|
2668
- next if File.basename(file) == '_index.json'
2777
+ index = type_dir.children.sort.filter_map do |file|
2778
+ next unless file.extname == '.json' && file.basename.to_s != '_index.json'
2669
2779
 
2670
2780
  data = JSON.parse(AtomicFile.read(file))
2671
2781
  {
@@ -2780,6 +2890,7 @@ module Woods
2780
2890
 
2781
2891
  @incremental_extractors[key] = EXTRACTORS[key]&.new
2782
2892
  rescue StandardError => e
2893
+ (@failed_consumers ||= Set.new).add(key)
2783
2894
  @source_inputs&.unverified("extractor:#{key}")
2784
2895
  Rails.logger.warn "[Woods] Could not build #{key} extractor: #{e.message}"
2785
2896
  @incremental_extractors[key] = nil
@@ -2798,6 +2909,18 @@ module Woods
2798
2909
  end
2799
2910
  end
2800
2911
 
2912
+ # Runtime metadata can depend on Ruby outside a class's definition or its
2913
+ # recorded graph edges (for example, queue_as Settings::QUEUE). A Ruby edit
2914
+ # therefore refreshes both complete hybrid inventories after Rails reload.
2915
+ # Non-Ruby batches retain the narrower pre-change graph selection.
2916
+ def hybrid_discovery_keys(change_set, affected_ids)
2917
+ return HYBRID_DISCOVERY_EXTRACTORS.to_set if change_set.absolute_paths.any? { |path| path.end_with?('.rb') }
2918
+
2919
+ affected_ids.flat_map { |id| @dependency_graph.node_types(id) }
2920
+ .filter_map { |type| TYPE_TO_EXTRACTOR_KEY[type] }
2921
+ .intersection(HYBRID_DISCOVERY_EXTRACTORS).to_set
2922
+ end
2923
+
2801
2924
  # Re-extract every file-based unit defined by the changed paths that still
2802
2925
  # exist on disk, and prune the ones those paths no longer define.
2803
2926
  #
@@ -2817,46 +2940,47 @@ module Woods
2817
2940
  # @param affected_types [Set<Symbol>] out-param of touched extractor keys
2818
2941
  # @return [Set<String>] identifiers written or removed
2819
2942
  def reconcile_changed_paths(change_set, affected_types)
2820
- dispatcher = PathDispatcher.new
2943
+ batches = collect_changed_path_batches(change_set)
2821
2944
  touched = Set.new
2822
2945
 
2823
- change_set.existing_paths.each do |absolute_path|
2824
- rules = dispatcher.file_rules_for(change_set.relativize(absolute_path))
2825
- next if rules.empty?
2946
+ with_changed_path_ownership(batches) do
2947
+ batches.each do |absolute_path, rules, entries|
2948
+ produced = Set.new
2949
+ entries.each do |rule, units|
2950
+ next unless units
2826
2951
 
2827
- produced = Set.new
2828
- # A rule whose extraction *raised* tells us nothing about what the path
2829
- # defines, so it must not license pruning what the path defined before.
2830
- raised = false
2831
- rules.each do |rule|
2832
- units = extract_with_rule(rule, absolute_path)
2833
- if units.nil?
2834
- raised = true
2835
- next
2952
+ produced.merge(units.map { |unit| [unit.identifier, unit.type] })
2953
+ touched.merge(register_and_write(rule.extractor_key, units, affected_types))
2954
+ unless source_consumer_failed?(rule.extractor_key)
2955
+ @source_inputs&.consume_file(rule.extractor_key, absolute_path)
2956
+ end
2836
2957
  end
2837
2958
 
2838
- # (identifier, type) pairs, not bare identifiers: two rules can
2839
- # claim one path (policies/pundit_policies) and mint the same
2840
- # identifier for different unit types. When one of them stops
2841
- # producing, an identifier-keyed set would let the survivor shield
2842
- # the stale sibling-type node from the prune below (the #225 shape,
2843
- # one method over — CORE-1).
2844
- produced.merge(units.map { |unit| [unit.identifier, unit.type] })
2845
- touched.merge(register_and_write(rule.extractor_key, units, affected_types))
2846
- unless source_consumer_failed?(rule.extractor_key)
2847
- @source_inputs&.consume_file(rule.extractor_key,
2848
- absolute_path)
2849
- end
2959
+ next if entries.any? { |_rule, units| units.nil? }
2960
+
2961
+ touched.merge(prune_path_leftovers(absolute_path, rules, produced, affected_types))
2850
2962
  end
2963
+ end
2964
+ touched
2965
+ end
2851
2966
 
2852
- next if raised
2967
+ # Collect every path before verifying ownership or mutating the graph.
2968
+ # An empty successful result can release an owner; a failed/skipped rule
2969
+ # provides no such evidence. Keep (identifier, type) pairs independent.
2970
+ def collect_changed_path_batches(change_set)
2971
+ dispatcher = PathDispatcher.new
2972
+ change_set.existing_paths.filter_map do |absolute_path|
2973
+ rules = dispatcher.file_rules_for(change_set.relativize(absolute_path))
2974
+ rules = rules.reject { |rule| rule.extractor_key == :libs }
2975
+ rules = rules.reject { |rule| @hybrid_discovery_keys&.include?(rule.extractor_key) }
2976
+ next if rules.empty?
2853
2977
 
2854
- touched.merge(
2855
- prune_path_leftovers(absolute_path, rules, produced, affected_types)
2856
- )
2978
+ entries = rules.map do |rule|
2979
+ units = extract_with_rule(rule, absolute_path)
2980
+ [rule, units && authoritative_module_units(units)]
2981
+ end
2982
+ [absolute_path, rules, entries]
2857
2983
  end
2858
-
2859
- touched
2860
2984
  end
2861
2985
 
2862
2986
  # Run one {PathDispatcher::Rule} against one file.
@@ -2874,12 +2998,12 @@ module Woods
2874
2998
  # broken constructor licensed pruning every previously-registered unit
2875
2999
  # on every changed path of that type, with the generation bumped over
2876
3000
  # the loss. Construction failure tells us nothing about the path; only
2877
- # a genuinely constructed extractor that lacks the method earns the [].
3001
+ # a completed supported extraction can establish the path's identities.
2878
3002
  if extractor.nil?
2879
3003
  source_consumer_failed?(rule.extractor_key, extractor)
2880
3004
  return nil
2881
3005
  end
2882
- return [] unless extractor.respond_to?(rule.method_name)
3006
+ return nil unless extractor.respond_to?(rule.method_name)
2883
3007
 
2884
3008
  result = checked_extraction(rule.extractor_key, extractor) do
2885
3009
  if rule.extractor_key == :poros
@@ -2923,6 +3047,7 @@ module Woods
2923
3047
  @dependency_graph.units_for_path(absolute_path).each do |identifier, node_type|
2924
3048
  next if produced.include?([identifier, node_type])
2925
3049
  next unless covered_keys.include?(TYPE_TO_EXTRACTOR_KEY[node_type])
3050
+ next if retain_partial_module?(identifier, node_type)
2926
3051
 
2927
3052
  removed.add(identifier) if remove_unit(identifier, affected_types, type: node_type)
2928
3053
  end
@@ -2957,6 +3082,9 @@ module Woods
2957
3082
  known = Array(spec[:types] || spec[:type])
2958
3083
  .flat_map { |type| @dependency_graph.units_of_type(type) }.to_set
2959
3084
 
3085
+ if key == :graphql
3086
+ touched.merge(reconcile_graphql_classification(extractor, discovered, excluded, affected_types))
3087
+ end
2960
3088
  touched.merge(add_discovered_classes(key, spec, discovered, known, excluded, affected_types))
2961
3089
  next if spec[:reconcile_removals] == false
2962
3090
 
@@ -2966,32 +3094,29 @@ module Woods
2966
3094
  touched
2967
3095
  end
2968
3096
 
2969
- # Runtime-only model mixins can enter or leave discovery when their
2970
- # includer changes, even if the mixin file itself is untouched.
2971
- # @param affected_types [Set<Symbol>]
2972
- # @return [Set<String>] Added or removed concern identifiers
2973
- def reconcile_model_mixins(affected_types)
2974
- extractor = extractor_for(:concerns)
2975
- return Set.new unless extractor.respond_to?(:runtime_model_mixins)
3097
+ # A new schema can promote an unchanged object to a query root. All four
3098
+ # GraphQL kinds share one payload filename, so withdraw the old typed owner
3099
+ # before writing its replacement. An incomplete inventory cannot demote it.
3100
+ def reconcile_graphql_classification(extractor, discovered, excluded, affected_types)
3101
+ unless extractor.runtime_discovery_complete?
3102
+ (@failed_consumers ||= Set.new).add(:graphql)
3103
+ return Set.new
3104
+ end
3105
+ return Set.new unless @eager_load_complete
2976
3106
 
2977
- live = extractor.runtime_model_mixins
2978
- known = @dependency_graph.units_of_type(:concern).to_set
2979
- added = live.flat_map do |path, modules|
2980
- next [] if modules.all? { |mod| known.include?(mod.name) }
3107
+ discovered.each_with_object(Set.new) do |klass, touched|
3108
+ next if excluded.include?(klass.name)
2981
3109
 
2982
- Array(extractor.extract_model_mixin_file(path)).reject { |unit| known.include?(unit.identifier) }
2983
- end
2984
- touched = register_and_write(:concerns, added, affected_types)
2985
- return touched unless @eager_load_complete
3110
+ previous = @dependency_graph.node_types(klass.name) & GRAPHQL_TYPES
3111
+ expected = extractor.runtime_unit_type(klass)
3112
+ next if previous.empty? || !expected || previous == [expected]
2986
3113
 
2987
- live_names = live.values.flatten.to_set(&:name)
2988
- known.each do |identifier|
2989
- path = @dependency_graph.node(identifier, type: :concern)[:file_path]
2990
- next if extractor.conventional_concern_path?(path) || live_names.include?(identifier)
3114
+ unit = extractor.extract_from_runtime_type(klass)
3115
+ source_consumer_failed?(:graphql, extractor)
3116
+ next unless unit
2991
3117
 
2992
- touched.add(identifier) if remove_unit(identifier, affected_types, type: :concern)
3118
+ touched.merge(register_and_write(:graphql, [unit], affected_types))
2993
3119
  end
2994
- touched
2995
3120
  end
2996
3121
 
2997
3122
  # Pruned class-based identifiers the tree still governs, and that the
@@ -3178,11 +3303,12 @@ module Woods
3178
3303
  # subject to the same eager-load gate the reconciler applies, see
3179
3304
  # {#remove_replaced_units}.
3180
3305
  #
3181
- # Fail closed (M8): the rescue exists so a re-run that learned nothing —
3182
- # `extract_all` itself raising — costs the run nothing. It must not swallow
3183
- # a failure that lands AFTER the replacement started mutating state a
3184
- # published generation would carry: {#register_and_write} registers the
3185
- # graph node before writing the unit file, and {#remove_unit_of_type} rm_f's
3306
+ # A re-run that raises before mutation retains the previous units and
3307
+ # records a failed consumer. The batch guard then refuses publication even
3308
+ # if another extractor succeeded, so callers can retry the complete batch.
3309
+ # Failures AFTER replacement starts mutating state abort immediately:
3310
+ # {#register_and_write} registers the graph node before writing the unit
3311
+ # file, and {#remove_unit_of_type} rm_f's
3186
3312
  # the file before dropping the graph node, so a raise in either window
3187
3313
  # leaves the graph and the payload directory disagreeing. Swallowed, the run
3188
3314
  # went on to publish a generation whose dependency_graph.json held nodes
@@ -3200,9 +3326,10 @@ module Woods
3200
3326
  # then fails before changing anything. And the counter is reset BEFORE
3201
3327
  # `extract_all`, not after: {#register_and_write} is shared with the
3202
3328
  # reconcile paths that run earlier in the same pass, and one of those
3203
- # leaving the counter positive must not turn a later, mutation-free
3204
- # wholesale failure into an abort — only the current key's wholesale pass
3205
- # contributes to the decision.
3329
+ # leaving the counter positive must not misreport a later, mutation-free
3330
+ # failure as a half-written replacement. Only the current key's wholesale
3331
+ # pass contributes to the immediate-abort decision; either failure still
3332
+ # prevents the batch from publishing.
3206
3333
  #
3207
3334
  # @param key [Symbol] extractor key
3208
3335
  # @param affected_types [Set<Symbol>]
@@ -3210,6 +3337,8 @@ module Woods
3210
3337
  # @raise [Woods::ExtractionError] when the replacement failed after
3211
3338
  # mutating durable state
3212
3339
  def replace_type_wholesale(key, affected_types)
3340
+ return @library_replacement_touched if key == :libs && @library_replacement_touched
3341
+
3213
3342
  extractor = extractor_for(key)
3214
3343
  if extractor.nil?
3215
3344
  source_consumer_failed?(key, extractor)
@@ -3221,13 +3350,16 @@ module Woods
3221
3350
  result = checked_extraction(key, extractor) { extractor.extract_all }
3222
3351
  return Set.new if SourceInputs::ConsumerErrors.failed?(extractor)
3223
3352
 
3224
- units = Array(result).compact.uniq(&:identifier)
3353
+ units = authoritative_module_units(deduplicate_type_units(key, Array(result).compact))
3225
3354
  Rails.logger.info "[Woods] Re-ran #{key} wholesale: #{units.size} units"
3226
3355
 
3227
- touched = register_and_write(key, units, affected_types)
3356
+ touched = with_replacement_ownership(key) { register_and_write(key, units, affected_types) }
3228
3357
  touched.merge(remove_replaced_units(key, units, affected_types))
3229
- @source_inputs&.consume_extractor(key, units) unless source_consumer_failed?(key, extractor)
3358
+ consume_module_aware_refresh(key, units) unless source_consumer_failed?(key, extractor)
3359
+ @library_replacement_touched = touched if key == :libs
3230
3360
  touched
3361
+ rescue IdentityCollisionError
3362
+ raise
3231
3363
  rescue StandardError => e
3232
3364
  if @wholesale_mutations.to_i.positive?
3233
3365
  raise Woods::ExtractionError, <<~MSG.tr("\n", ' ').strip
@@ -3240,6 +3372,7 @@ module Woods
3240
3372
  MSG
3241
3373
  end
3242
3374
 
3375
+ (@failed_consumers ||= Set.new).add(key)
3243
3376
  @source_inputs&.unverified("extractor:#{key}")
3244
3377
  Rails.logger.error "[Woods] Wholesale re-run of #{key} failed: #{e.message}"
3245
3378
  Set.new
@@ -3315,7 +3448,7 @@ module Woods
3315
3448
  # @param affected_types [Set<Symbol>]
3316
3449
  # @return [Set<String>] identifiers removed
3317
3450
  def remove_replaced_units(key, units, affected_types)
3318
- if CLASS_BASED_DISCOVERY.key?(key) && !@eager_load_complete
3451
+ unless replacement_discovery_complete?(key)
3319
3452
  Rails.logger.warn(
3320
3453
  "[Woods] Skipping stale-unit removal for #{key}: the eager load was incomplete, " \
3321
3454
  'so its discovery set is known-partial — the type may hold stale units until a clean boot'
@@ -3326,6 +3459,8 @@ module Woods
3326
3459
  EXTRACTOR_KEY_TO_TYPES.fetch(key, []).each_with_object(Set.new) do |unit_type, removed|
3327
3460
  fresh = units.select { |u| u.type == unit_type }.to_set(&:identifier)
3328
3461
  (@dependency_graph.units_of_type(unit_type) - fresh.to_a).each do |stale|
3462
+ next if retain_partial_module?(stale, unit_type)
3463
+
3329
3464
  removed.add(stale) if remove_unit(stale, affected_types, type: unit_type)
3330
3465
  end
3331
3466
  end
@@ -3480,15 +3615,23 @@ module Woods
3480
3615
  # @param affected_types [Set<Symbol>]
3481
3616
  # @return [Set<String>] identifiers written
3482
3617
  def register_and_write(extractor_key, units, affected_types)
3483
- units = Array(units).compact
3618
+ units = authoritative_module_units(deduplicate_type_units(extractor_key, Array(units).compact))
3484
3619
  return Set.new if units.empty?
3485
3620
 
3486
- affected_types&.add(extractor_key)
3621
+ verify_identity_claims!(units)
3622
+ withdraw_previous_graphql_kinds(units, affected_types) if extractor_key == :graphql
3487
3623
  type_dir = payload_dir.join(extractor_key.to_s)
3488
3624
  FileUtils.mkdir_p(type_dir)
3489
3625
 
3490
3626
  units.each_with_object(Set.new) do |unit, written|
3491
3627
  annotate_package(unit)
3628
+ unless source_consumer_failed?(extractor_key)
3629
+ SourceContributors.paths(unit).each { |path| @source_inputs&.consume_unit(extractor_key, path) }
3630
+ end
3631
+ (@source_reference_refreshed ||= Set.new).add([unit.type.to_s, unit.identifier])
3632
+ next if unchanged_registered_unit?(type_dir, unit)
3633
+
3634
+ affected_types&.add(extractor_key)
3492
3635
  mark_dependents_dirty(unit.identifier)
3493
3636
  # Marked BEFORE registration: DependencyGraph#register inserts the
3494
3637
  # node before it iterates the unit's dependencies, so a malformed
@@ -3506,11 +3649,52 @@ module Woods
3506
3649
  (@incremental_written ||= {})[unit.identifier] = unit.file_path
3507
3650
 
3508
3651
  write_unit_file(type_dir.join(collision_safe_filename(unit.identifier)), unit)
3509
- @source_inputs&.consume_unit(extractor_key, unit.file_path) unless source_consumer_failed?(extractor_key)
3510
3652
  written.add(unit.identifier)
3511
3653
  end
3512
3654
  end
3513
3655
 
3656
+ # Only exact serialized equality (apart from the extraction timestamp)
3657
+ # permits skipping graph and enrichment work. Derived metadata or changed
3658
+ # edges keep the ordinary registration path; identity checks already ran.
3659
+ def unchanged_registered_unit?(type_dir, unit)
3660
+ return false unless @dependency_graph.node(unit.identifier, type: unit.type)
3661
+
3662
+ normalized = unit.dup
3663
+ normalized.file_path = normalize_file_path(unit.file_path)
3664
+ identical_on_disk?(type_dir.join(collision_safe_filename(unit.identifier)), json_serialize(normalized.to_h))
3665
+ end
3666
+
3667
+ # GraphQL kinds share a payload filename. Validate retained source ownership
3668
+ # for the whole batch, then remove old kinds before any replacement write.
3669
+ # This covers discovery, changed-file dispatch, and targeted refresh alike.
3670
+ def withdraw_previous_graphql_kinds(units, affected_types)
3671
+ replacements = units.flat_map do |unit|
3672
+ current = identity_source(unit.file_path)
3673
+ GRAPHQL_TYPES.each do |type|
3674
+ key = [type, unit.identifier]
3675
+ next unless @identity_claims.key?(key) && @identity_claims[key] != current
3676
+
3677
+ reject_identity_collision!(unit, @identity_claims[key])
3678
+ end
3679
+ previous = (@dependency_graph.node_types(unit.identifier) & GRAPHQL_TYPES) - [unit.type]
3680
+ previous.map do |type|
3681
+ prior = identity_source(@dependency_graph.node(unit.identifier, type: type)[:file_path])
3682
+ unless Array(@authoritative_replacement_types).include?(unit.type) ||
3683
+ prior == current || confirmed_source_move?(prior, current)
3684
+ reject_identity_collision!(unit, prior)
3685
+ end
3686
+ [unit.identifier, type]
3687
+ end
3688
+ end
3689
+ if replacements.any? && !@eager_load_complete
3690
+ (@failed_consumers ||= Set.new).add(:graphql)
3691
+ raise Woods::ExtractionError,
3692
+ 'GraphQL classification changed during an incomplete eager load; retry after a complete application boot'
3693
+ end
3694
+
3695
+ replacements.each { |identifier, type| remove_unit(identifier, affected_types, type: type) }
3696
+ end
3697
+
3514
3698
  # Remove a unit from the graph and delete its JSON from the index.
3515
3699
  #
3516
3700
  # Callers that know which type they mean must say so. An identifier can
@@ -3648,7 +3832,10 @@ module Woods
3648
3832
  end
3649
3833
 
3650
3834
  git = git_data && git_for_type(identifier, type, git_data)
3651
- if git
3835
+ if git_data && SourceContributors.multiple?(data)
3836
+ SourceContributors.annotate_git(data, git_data)
3837
+ @dependency_graph.annotate(identifier, type: type, commit_count: nil, change_frequency: nil)
3838
+ elsif git
3652
3839
  (data['metadata'] ||= {})['git'] = JSON.parse(JSON.generate(git))
3653
3840
  annotate_node_from_git(identifier, type, git)
3654
3841
  end
@@ -3709,10 +3896,10 @@ module Woods
3709
3896
 
3710
3897
  root = "#{Rails.root}/"
3711
3898
  paths = identifiers.flat_map do |identifier|
3712
- @dependency_graph.nodes_for(identifier).filter_map do |node|
3713
- next if %i[rails_source gem_source].include?(node[:type])
3899
+ @dependency_graph.nodes_for(identifier).flat_map do |node|
3900
+ next [] if %i[rails_source gem_source].include?(node[:type])
3714
3901
 
3715
- node[:file_path] if git_enrichable_path?(node[:file_path], root)
3902
+ (node[:source_paths] || Array(node[:file_path])).select { |path| git_enrichable_path?(path, root) }
3716
3903
  end
3717
3904
  end
3718
3905
 
@@ -3757,6 +3944,11 @@ module Woods
3757
3944
  # @param affected_types [Set<Symbol>, nil]
3758
3945
  # @return [String, nil] the identifier when this type was re-extracted and written
3759
3946
  def re_extract_unit_of_type(unit_id, type, affected_types)
3947
+ if type == :lib
3948
+ touched = replace_type_wholesale(:libs, affected_types)
3949
+ return touched.include?(unit_id) ? unit_id : nil
3950
+ end
3951
+
3760
3952
  node = @dependency_graph.node(unit_id, type: type)
3761
3953
  file_path = node && node[:file_path]
3762
3954
 
@@ -3765,6 +3957,7 @@ module Woods
3765
3957
 
3766
3958
  extractor_key = TYPE_TO_EXTRACTOR_KEY[type]
3767
3959
  return nil unless extractor_key
3960
+ return nil if @hybrid_discovery_keys&.include?(extractor_key)
3768
3961
 
3769
3962
  extractor = extractor_for(extractor_key)
3770
3963
  if extractor.nil?
@@ -3780,17 +3973,21 @@ module Woods
3780
3973
  units = Array(result).compact
3781
3974
  return nil if units.empty?
3782
3975
 
3783
- register_and_write(extractor_key, units, affected_types)
3784
- unit_id
3976
+ written = register_and_write(extractor_key, units, affected_types)
3977
+ unit_id if written.include?(unit_id)
3785
3978
  end
3786
3979
 
3787
3980
  # Dispatch one re-extraction to the right extractor entry point.
3788
3981
  #
3789
3982
  # @return [ExtractedUnit, Array<ExtractedUnit>, nil]
3790
3983
  def re_extracted_units(extractor, type, unit_id, file_path, extractor_key)
3791
- if (method = CLASS_BASED[type])
3984
+ if type == :configuration && unit_id == 'BehavioralProfile'
3985
+ extractor.extract_behavioral_profile
3986
+ elsif (method = CLASS_BASED[type])
3792
3987
  klass = constant_for_identifier(unit_id)
3793
3988
  klass && extractor.public_send(method, klass)
3989
+ elsif runtime_model_mixin_file?(extractor, type, file_path)
3990
+ extractor.extract_model_mixin_file(file_path)
3794
3991
  elsif (method = FILE_BASED[type])
3795
3992
  units = Array(extract_file_based_unit(extractor, method, file_path, extractor_key)).compact
3796
3993
  return units unless CLASS_DISCOVERED_FALLBACK.key?(type)