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
data/lib/woods/extractor.rb
CHANGED
|
@@ -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: :
|
|
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
|
|
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[
|
|
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
|
|
684
|
-
# failure instead of claiming an unreachable payload
|
|
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.
|
|
1015
|
-
#
|
|
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
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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[
|
|
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
|
-
|
|
1793
|
-
|
|
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
|
-
|
|
1808
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
1996
|
-
#
|
|
1997
|
-
#
|
|
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
|
|
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 =
|
|
2668
|
-
next
|
|
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
|
-
|
|
2943
|
+
batches = collect_changed_path_batches(change_set)
|
|
2821
2944
|
touched = Set.new
|
|
2822
2945
|
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
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
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
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
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2855
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
2970
|
-
#
|
|
2971
|
-
#
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
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
|
-
|
|
2978
|
-
|
|
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
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
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
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
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.
|
|
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
|
-
#
|
|
3182
|
-
#
|
|
3183
|
-
#
|
|
3184
|
-
#
|
|
3185
|
-
# graph node before writing the unit
|
|
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
|
|
3204
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
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[:
|
|
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
|
|
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)
|