woods 2.0.0.beta1 → 2.0.0.beta2
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 +138 -0
- data/CONTRIBUTING.md +53 -2
- data/README.md +3 -3
- data/docs/CONFIGURATION_REFERENCE.md +35 -0
- data/docs/INCREMENTAL_EXTRACTION.md +41 -1
- data/docs/INTERNALS.md +8 -5
- data/docs/PUBLISHED_INDEX.md +16 -0
- data/docs/UPGRADING_TO_2.md +2 -2
- data/lib/woods/atomic_file.rb +133 -3
- data/lib/woods/extractor.rb +329 -95
- data/lib/woods/flow_assembler.rb +87 -8
- data/lib/woods/flow_precomputer.rb +44 -7
- data/lib/woods/graph_analyzer.rb +161 -58
- data/lib/woods/payload_store.rb +14 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +86 -1
- metadata +5 -5
data/lib/woods/extractor.rb
CHANGED
|
@@ -404,16 +404,20 @@ module Woods
|
|
|
404
404
|
# package added between the two runs.
|
|
405
405
|
@package_resolver = nil
|
|
406
406
|
@incremental_extractors = nil
|
|
407
|
-
|
|
407
|
+
@persisted_index_stats = nil
|
|
408
|
+
@graph_sha = nil
|
|
409
|
+
profile_phase('payload seed') { begin_payload! }
|
|
408
410
|
|
|
409
411
|
# Eager load once — all extractors need loaded classes for introspection.
|
|
410
|
-
safe_eager_load!
|
|
412
|
+
profile_phase('eager load') { safe_eager_load! }
|
|
411
413
|
|
|
412
414
|
# Phase 1: Extract all units
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
profile_phase('extraction') do
|
|
416
|
+
if Woods.configuration.concurrent_extraction
|
|
417
|
+
extract_all_concurrent
|
|
418
|
+
else
|
|
419
|
+
extract_all_sequential
|
|
420
|
+
end
|
|
417
421
|
end
|
|
418
422
|
|
|
419
423
|
# Phase 1.5: Deduplicate results
|
|
@@ -444,7 +448,7 @@ module Woods
|
|
|
444
448
|
|
|
445
449
|
# Phase 4: Graph analysis (PageRank, structural metrics)
|
|
446
450
|
Rails.logger.info '[Woods] Analyzing dependency graph...'
|
|
447
|
-
@graph_analysis = build_graph_analyzer.analyze
|
|
451
|
+
@graph_analysis = profile_phase('graph analysis') { build_graph_analyzer.analyze }
|
|
448
452
|
|
|
449
453
|
# Phase 4.5: Normalize file_path to relative paths
|
|
450
454
|
Rails.logger.info '[Woods] Normalizing file paths...'
|
|
@@ -452,7 +456,7 @@ module Woods
|
|
|
452
456
|
|
|
453
457
|
# Phase 5: Write output
|
|
454
458
|
Rails.logger.info '[Woods] Writing output...'
|
|
455
|
-
write_results
|
|
459
|
+
profile_phase('write results') { write_results }
|
|
456
460
|
|
|
457
461
|
# Phase 5.1: Sweep unit files no current unit accounts for (#177). Must
|
|
458
462
|
# run after write_results — the just-written set is what defines
|
|
@@ -478,15 +482,17 @@ module Woods
|
|
|
478
482
|
# being opt-in does not excuse it.
|
|
479
483
|
if Woods.configuration.precompute_flows
|
|
480
484
|
Rails.logger.info '[Woods] Precomputing request flows...'
|
|
481
|
-
precompute_flows
|
|
485
|
+
profile_phase('flows') { precompute_flows }
|
|
482
486
|
end
|
|
483
487
|
|
|
484
488
|
write_dependency_graph
|
|
485
489
|
write_graph_analysis
|
|
486
|
-
|
|
487
|
-
|
|
490
|
+
profile_phase('manifest and summary') do
|
|
491
|
+
write_manifest
|
|
492
|
+
write_structural_summary
|
|
493
|
+
end
|
|
488
494
|
capture_snapshot
|
|
489
|
-
publish_generation('full')
|
|
495
|
+
profile_phase('publish') { publish_generation('full') }
|
|
490
496
|
|
|
491
497
|
log_summary
|
|
492
498
|
|
|
@@ -527,14 +533,22 @@ module Woods
|
|
|
527
533
|
change_set = ChangeSet.new(paths: changed_files, root: Rails.root)
|
|
528
534
|
affected_types = Set.new
|
|
529
535
|
|
|
530
|
-
# Blast radius from the pre-change graph
|
|
531
|
-
|
|
536
|
+
# Blast radius from the pre-change graph, bounded by
|
|
537
|
+
# `incremental_blast_radius_depth` (nil = the unbounded closure).
|
|
538
|
+
affected_ids = profile_phase('blast radius') do
|
|
539
|
+
@dependency_graph.affected_by(change_set.absolute_paths, max_depth: blast_radius_depth)
|
|
540
|
+
end
|
|
541
|
+
@flow_scope = profile_phase('flow radius') { flow_scope_for(change_set) }
|
|
532
542
|
Rails.logger.info "[Woods] #{change_set.size} changed files affect #{affected_ids.size} units"
|
|
533
543
|
|
|
534
|
-
touched =
|
|
544
|
+
touched = profile_phase('re-extraction') do
|
|
545
|
+
acc = reconcile_changed_paths(change_set, affected_types)
|
|
535
546
|
|
|
536
|
-
|
|
537
|
-
|
|
547
|
+
(affected_ids - acc.to_a).each do |unit_id|
|
|
548
|
+
acc.add(unit_id) if re_extract_unit(unit_id, affected_types: affected_types)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
acc
|
|
538
552
|
end
|
|
539
553
|
|
|
540
554
|
touched.merge(reconcile_class_based_types(affected_types))
|
|
@@ -571,8 +585,10 @@ module Woods
|
|
|
571
585
|
finalize_incremental_unit_json(affected_types)
|
|
572
586
|
|
|
573
587
|
# Regenerate type indexes for affected types
|
|
574
|
-
|
|
575
|
-
|
|
588
|
+
profile_phase('type index') do
|
|
589
|
+
affected_types.each do |type_key|
|
|
590
|
+
regenerate_type_index(type_key)
|
|
591
|
+
end
|
|
576
592
|
end
|
|
577
593
|
|
|
578
594
|
finalize_incremental_run(touched)
|
|
@@ -651,6 +667,71 @@ module Woods
|
|
|
651
667
|
|
|
652
668
|
private
|
|
653
669
|
|
|
670
|
+
# Time one phase of a run and log how long it took, when WOODS_PROFILE=1.
|
|
671
|
+
#
|
|
672
|
+
# The per-extractor lines (see {#extract_all_sequential}) already report
|
|
673
|
+
# extraction itself. Everything after it (the graph load, the analysis,
|
|
674
|
+
# the flows, the publish) was unattributed, so a slow run could only be
|
|
675
|
+
# split by guessing. Off by default and free when off: the block is
|
|
676
|
+
# yielded directly, with no timing and no log line. Timed on the
|
|
677
|
+
# monotonic clock, so a wall-clock adjustment mid-run cannot produce a
|
|
678
|
+
# negative phase.
|
|
679
|
+
#
|
|
680
|
+
# @param name [String] phase name, as it appears in the log line
|
|
681
|
+
# @return [Object] whatever the block returned
|
|
682
|
+
def profile_phase(name)
|
|
683
|
+
return yield unless profiling?
|
|
684
|
+
|
|
685
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
686
|
+
result = yield
|
|
687
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
688
|
+
Rails.logger.info "[Woods] [profile] #{name} in #{elapsed.round(2)}s"
|
|
689
|
+
result
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# How many reverse hops {#extract_changed} walks from the changed files
|
|
693
|
+
# before it stops re-extracting dependents.
|
|
694
|
+
#
|
|
695
|
+
# Unbounded by default. A unit outside the cap keeps its content, and the
|
|
696
|
+
# one derived field a re-extraction elsewhere can change (`dependents`)
|
|
697
|
+
# is refreshed regardless: {#register_and_write} marks every target of a
|
|
698
|
+
# re-extracted unit's edges, before and after registration, so a unit
|
|
699
|
+
# that gains or loses an inbound edge is rewritten by
|
|
700
|
+
# {#finalize_incremental_unit_json} whether or not the walk reached it.
|
|
701
|
+
#
|
|
702
|
+
# @return [Integer, nil]
|
|
703
|
+
def blast_radius_depth
|
|
704
|
+
Woods.configuration&.incremental_blast_radius_depth
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# The units a controller's flow document could reach from this run's
|
|
708
|
+
# changed files, read off the pre-change graph.
|
|
709
|
+
#
|
|
710
|
+
# {FlowAssembler} stops expanding at {FlowPrecomputer::DEFAULT_MAX_DEPTH},
|
|
711
|
+
# so a controller further than that from everything the run changed
|
|
712
|
+
# assembles the same document it already has. The bound is the
|
|
713
|
+
# assembler's own constant, not a second literal: raising the assembly
|
|
714
|
+
# depth widens this walk with it.
|
|
715
|
+
#
|
|
716
|
+
# Reverse reachability is the same relation the refresh already rested on
|
|
717
|
+
# (`touched` is itself the graph's reverse closure), so this narrows the
|
|
718
|
+
# distance without changing the edge set behind the decision.
|
|
719
|
+
#
|
|
720
|
+
# @param change_set [Woods::ChangeSet]
|
|
721
|
+
# @return [Set<String>]
|
|
722
|
+
def flow_scope_for(change_set)
|
|
723
|
+
return Set.new unless Woods.configuration.precompute_flows
|
|
724
|
+
|
|
725
|
+
@dependency_graph.affected_by(
|
|
726
|
+
change_set.absolute_paths, max_depth: FlowPrecomputer::DEFAULT_MAX_DEPTH
|
|
727
|
+
).to_set
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# @return [Boolean] whether phase timing is enabled for this process
|
|
731
|
+
def profiling?
|
|
732
|
+
ENV.fetch('WOODS_PROFILE', nil) == '1'
|
|
733
|
+
end
|
|
734
|
+
|
|
654
735
|
# Load the persisted graph and reset the per-run bookkeeping that the
|
|
655
736
|
# incremental helpers read. Shared by {#extract_changed} and {#refresh};
|
|
656
737
|
# calling either without this leaves `@dependents_dirty` and
|
|
@@ -665,19 +746,27 @@ module Woods
|
|
|
665
746
|
# @return [void]
|
|
666
747
|
# @raise [Woods::ExtractionError] see {#begin_payload!}
|
|
667
748
|
def prepare_incremental_run
|
|
668
|
-
begin_payload!(strict: true)
|
|
749
|
+
profile_phase('payload seed') { begin_payload!(strict: true) }
|
|
669
750
|
graph_path = payload_dir.join('dependency_graph.json')
|
|
670
751
|
ensure_incremental_baseline!(graph_path)
|
|
671
|
-
|
|
752
|
+
profile_phase('previous graph load') do
|
|
753
|
+
@dependency_graph = DependencyGraph.from_h(JSON.parse(AtomicFile.read(graph_path))) if graph_path.exist?
|
|
754
|
+
end
|
|
672
755
|
|
|
673
756
|
ModelNameCache.reset!
|
|
674
|
-
safe_eager_load!
|
|
757
|
+
profile_phase('eager load') { safe_eager_load! }
|
|
675
758
|
|
|
676
759
|
@dependents_dirty = Set.new
|
|
677
760
|
@incremental_written = {}
|
|
761
|
+
# nil = no scope was computed for this run, so every re-extracted
|
|
762
|
+
# controller has its flows reassembled. {#refresh} leaves it that way.
|
|
763
|
+
@flow_scope = nil
|
|
764
|
+
@previous_flow_index_entries = nil
|
|
678
765
|
@incremental_extractors = nil
|
|
679
766
|
@active_record_names = nil
|
|
680
767
|
@package_resolver = nil
|
|
768
|
+
@persisted_index_stats = nil
|
|
769
|
+
@graph_sha = nil
|
|
681
770
|
end
|
|
682
771
|
|
|
683
772
|
# Write the graph and the derived artifacts after an incremental run.
|
|
@@ -713,11 +802,13 @@ module Woods
|
|
|
713
802
|
return
|
|
714
803
|
end
|
|
715
804
|
|
|
716
|
-
write_incremental_graph_analysis
|
|
717
|
-
refresh_incremental_flows(touched)
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
805
|
+
profile_phase('graph analysis') { write_incremental_graph_analysis }
|
|
806
|
+
profile_phase('flows') { refresh_incremental_flows(touched) }
|
|
807
|
+
profile_phase('manifest and summary') do
|
|
808
|
+
write_manifest(incremental: true)
|
|
809
|
+
write_structural_summary
|
|
810
|
+
end
|
|
811
|
+
profile_phase('publish') { publish_generation(reason) }
|
|
721
812
|
|
|
722
813
|
return unless Woods.configuration.enable_snapshots
|
|
723
814
|
|
|
@@ -737,7 +828,11 @@ module Woods
|
|
|
737
828
|
# @return [void]
|
|
738
829
|
def publish_generation(reason)
|
|
739
830
|
generation = Generation.new(output_dir: @output_dir)
|
|
740
|
-
|
|
831
|
+
# Resolve (and if necessary rename) the payload first, so the flush
|
|
832
|
+
# below covers the directory under the name the pointer will carry.
|
|
833
|
+
payload = publishable_payload_name(generation)
|
|
834
|
+
profile_phase('payload sync') { sync_payload }
|
|
835
|
+
marker = generation.bump!(reason: reason, payload: payload)
|
|
741
836
|
prune_payloads(marker.number)
|
|
742
837
|
marker
|
|
743
838
|
rescue StandardError => e
|
|
@@ -893,6 +988,47 @@ module Woods
|
|
|
893
988
|
EXTRACTORS.keys.map(&:to_s) + PAYLOAD_DIRS
|
|
894
989
|
end
|
|
895
990
|
|
|
991
|
+
# Whether payload files pay for their own durability as they are written.
|
|
992
|
+
#
|
|
993
|
+
# False by default and false on every host that has not asked otherwise:
|
|
994
|
+
# {#sync_payload} makes the whole payload durable in one flush before the
|
|
995
|
+
# pointer names it, so a per-file fsync buys only the window in which
|
|
996
|
+
# nothing can read the file anyway. `durable_payload_writes = true`
|
|
997
|
+
# restores the per-file cost for a host that wants it; it does not, and
|
|
998
|
+
# cannot, remove the publish flush.
|
|
999
|
+
#
|
|
1000
|
+
# @return [Boolean]
|
|
1001
|
+
def payload_writes_durable?
|
|
1002
|
+
Woods.configuration&.durable_payload_writes ? true : false
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# One filesystem flush that makes this run's whole payload durable,
|
|
1006
|
+
# immediately before the pointer that names it is written durably.
|
|
1007
|
+
#
|
|
1008
|
+
# This is the guarantee that replaces the per-file fsync {#write_results}
|
|
1009
|
+
# used to pay for every unit: **when `generation.json` is durable, every
|
|
1010
|
+
# file in the payload it names is durable.** What is given up is an
|
|
1011
|
+
# individual payload file being durable before the pointer exists, and
|
|
1012
|
+
# nobody reads a payload file in that window: every reader resolves
|
|
1013
|
+
# through the pointer, and a crash leaves an unreferenced partial payload
|
|
1014
|
+
# that the next run prunes.
|
|
1015
|
+
#
|
|
1016
|
+
# Unconditional on purpose. `durable_payload_writes` decides whether the
|
|
1017
|
+
# per-file fsyncs are *also* paid; it can never turn this off, because
|
|
1018
|
+
# that would be the one change that actually weakens the contract.
|
|
1019
|
+
# {Woods::AtomicFile.sync_directory_tree} degrades through `sync -f`, a
|
|
1020
|
+
# bare `sync`, and finally a per-file fsync pass, so the flush cannot
|
|
1021
|
+
# silently become a no-op either.
|
|
1022
|
+
#
|
|
1023
|
+
# A run that degraded to a flat publish has no payload directory;
|
|
1024
|
+
# {#payload_dir} then returns the output directory, which is the tree that
|
|
1025
|
+
# needs flushing in exactly the same way.
|
|
1026
|
+
#
|
|
1027
|
+
# @return [Symbol, nil] the strategy {Woods::AtomicFile} used
|
|
1028
|
+
def sync_payload
|
|
1029
|
+
AtomicFile.sync_directory_tree(payload_dir)
|
|
1030
|
+
end
|
|
1031
|
+
|
|
896
1032
|
# The pointer to publish, or nil when this run built no payload directory.
|
|
897
1033
|
#
|
|
898
1034
|
# The directory was named from the generation number this run expected to
|
|
@@ -1279,12 +1415,14 @@ module Woods
|
|
|
1279
1415
|
annotated.each do |unit|
|
|
1280
1416
|
AtomicFile.write(
|
|
1281
1417
|
type_dir.join(collision_safe_filename(unit.identifier)),
|
|
1282
|
-
json_serialize(unit.to_h)
|
|
1418
|
+
json_serialize(unit.to_h),
|
|
1419
|
+
durable: payload_writes_durable?
|
|
1283
1420
|
)
|
|
1284
1421
|
end
|
|
1285
1422
|
AtomicFile.write(
|
|
1286
1423
|
type_dir.join('_index.json'),
|
|
1287
|
-
json_serialize(type_index_entries(units))
|
|
1424
|
+
json_serialize(type_index_entries(units)),
|
|
1425
|
+
durable: payload_writes_durable?
|
|
1288
1426
|
)
|
|
1289
1427
|
end
|
|
1290
1428
|
end
|
|
@@ -1329,12 +1467,16 @@ module Woods
|
|
|
1329
1467
|
removed = previous_flow_index_controllers & (touched.to_set - reextracted.to_set)
|
|
1330
1468
|
return if reextracted.empty? && removed.empty?
|
|
1331
1469
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1470
|
+
reassemble, carried = partition_flow_controllers(
|
|
1471
|
+
reextracted.filter_map { |id| unit_from_payload(:controllers, id) }
|
|
1472
|
+
)
|
|
1473
|
+
Rails.logger.info "[Woods] Refreshing flows for #{reassemble.size} controller(s), " \
|
|
1474
|
+
"#{carried.size} carried forward, #{removed.size} removed..."
|
|
1334
1475
|
precomputer = FlowPrecomputer.new(units: [], graph: @dependency_graph, output_dir: payload_dir.to_s)
|
|
1335
1476
|
annotations = precomputer.recompute_delta(
|
|
1336
|
-
touched_units:
|
|
1337
|
-
removed_identifiers: removed.to_a
|
|
1477
|
+
touched_units: reassemble,
|
|
1478
|
+
removed_identifiers: removed.to_a,
|
|
1479
|
+
carried_identifiers: carried.map(&:identifier)
|
|
1338
1480
|
)
|
|
1339
1481
|
patch_flow_annotations(annotations)
|
|
1340
1482
|
sweep_orphaned_flow_files
|
|
@@ -1347,6 +1489,52 @@ module Woods
|
|
|
1347
1489
|
regenerate_type_index(:controllers) if annotations.any?
|
|
1348
1490
|
end
|
|
1349
1491
|
|
|
1492
|
+
# Split the run's re-extracted controllers into the ones whose flow
|
|
1493
|
+
# documents have to be reassembled and the ones that only need their
|
|
1494
|
+
# annotation back.
|
|
1495
|
+
#
|
|
1496
|
+
# A controller is reassembled when the run changed something its flow can
|
|
1497
|
+
# reach ({#flow_scope_for}), or when its own action set no longer matches
|
|
1498
|
+
# the previous generation's index. The second test is what catches an
|
|
1499
|
+
# action arriving from further up a controller inheritance chain than the
|
|
1500
|
+
# flow radius reaches, and a controller the index has never seen.
|
|
1501
|
+
#
|
|
1502
|
+
# Without a scope (a targeted {#refresh}, or a routes re-run) everything
|
|
1503
|
+
# is reassembled, which is what this path always did.
|
|
1504
|
+
#
|
|
1505
|
+
# @param units [Array<ExtractedUnit>] the run's re-extracted controllers
|
|
1506
|
+
# @return [Array(Array<ExtractedUnit>, Array<ExtractedUnit>)]
|
|
1507
|
+
def partition_flow_controllers(units)
|
|
1508
|
+
return [units, []] if @flow_scope.nil?
|
|
1509
|
+
|
|
1510
|
+
previous = previous_flow_index_actions
|
|
1511
|
+
units.partition do |unit|
|
|
1512
|
+
@flow_scope.include?(unit.identifier) || flow_actions_of(unit) != previous[unit.identifier]
|
|
1513
|
+
end
|
|
1514
|
+
end
|
|
1515
|
+
|
|
1516
|
+
# The actions a unit's metadata declares, as the index records them.
|
|
1517
|
+
#
|
|
1518
|
+
# @param unit [ExtractedUnit]
|
|
1519
|
+
# @return [Set<String>]
|
|
1520
|
+
def flow_actions_of(unit)
|
|
1521
|
+
Array(unit.metadata[:actions] || unit.metadata['actions']).to_set(&:to_s)
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
# The previous generation's flow index, grouped by controller.
|
|
1525
|
+
#
|
|
1526
|
+
# @return [Hash{String => Set<String>}] controller identifier to its
|
|
1527
|
+
# recorded action names, defaulting to an empty set
|
|
1528
|
+
# @raise [Woods::ExtractionError] when the index is missing or corrupt
|
|
1529
|
+
def previous_flow_index_actions
|
|
1530
|
+
previous_flow_index_entries.keys.each_with_object(Hash.new { Set.new }) do |entry_point, grouped|
|
|
1531
|
+
controller, action = entry_point.to_s.split('#', 2)
|
|
1532
|
+
next unless action
|
|
1533
|
+
|
|
1534
|
+
grouped[controller] = grouped[controller] + [action]
|
|
1535
|
+
end
|
|
1536
|
+
end
|
|
1537
|
+
|
|
1350
1538
|
# Does the run's seeded payload hold a flow family at all? An absent
|
|
1351
1539
|
# family (no flows/ directory, or an empty one) is a genuine absence —
|
|
1352
1540
|
# typically an index built while the gate was off — and skips the
|
|
@@ -1372,11 +1560,21 @@ module Woods
|
|
|
1372
1560
|
# @return [Set<String>]
|
|
1373
1561
|
# @raise [Woods::ExtractionError] when the index is missing or corrupt
|
|
1374
1562
|
def previous_flow_index_controllers
|
|
1563
|
+
previous_flow_index_entries.keys.to_set { |entry_point| entry_point.to_s.split('#', 2).first }
|
|
1564
|
+
end
|
|
1565
|
+
|
|
1566
|
+
# The previous generation's flow index itself, read once per run: both
|
|
1567
|
+
# the removal set and the reassembly partition are derived from it.
|
|
1568
|
+
#
|
|
1569
|
+
# @return [Hash{String => String}] entry point to relative document path
|
|
1570
|
+
# @raise [Woods::ExtractionError] when the index is missing or corrupt
|
|
1571
|
+
def previous_flow_index_entries
|
|
1572
|
+
return @previous_flow_index_entries if @previous_flow_index_entries
|
|
1573
|
+
|
|
1375
1574
|
index_path = payload_dir.join('flows', 'flow_index.json')
|
|
1376
1575
|
raise Woods::ExtractionError, 'flows/ is populated but flow_index.json is missing' unless index_path.exist?
|
|
1377
1576
|
|
|
1378
|
-
JSON.parse(AtomicFile.read(index_path))
|
|
1379
|
-
.keys.to_set { |entry_point| entry_point.to_s.split('#', 2).first }
|
|
1577
|
+
@previous_flow_index_entries = JSON.parse(AtomicFile.read(index_path))
|
|
1380
1578
|
rescue JSON::ParserError => e
|
|
1381
1579
|
raise Woods::ExtractionError, "previous flow_index.json does not parse: #{e.message}"
|
|
1382
1580
|
end
|
|
@@ -1433,7 +1631,7 @@ module Woods
|
|
|
1433
1631
|
end
|
|
1434
1632
|
next if JSON.generate(data) == before
|
|
1435
1633
|
|
|
1436
|
-
AtomicFile.write(path, json_serialize(data))
|
|
1634
|
+
AtomicFile.write(path, json_serialize(data), durable: payload_writes_durable?)
|
|
1437
1635
|
end
|
|
1438
1636
|
end
|
|
1439
1637
|
|
|
@@ -1550,8 +1748,14 @@ module Woods
|
|
|
1550
1748
|
#
|
|
1551
1749
|
# @return [GraphAnalyzer]
|
|
1552
1750
|
def build_graph_analyzer
|
|
1553
|
-
|
|
1554
|
-
|
|
1751
|
+
config = Woods.configuration
|
|
1752
|
+
ratio = config&.volatile_dependency_ratio || GraphAnalyzer::DEFAULT_VOLATILE_RATIO
|
|
1753
|
+
GraphAnalyzer.new(
|
|
1754
|
+
@dependency_graph,
|
|
1755
|
+
volatile_ratio: ratio,
|
|
1756
|
+
cycle_limit: config ? config.graph_cycle_limit : GraphAnalyzer::DEFAULT_CYCLE_LIMIT,
|
|
1757
|
+
cycle_max_length: config ? config.graph_cycle_max_length : GraphAnalyzer::DEFAULT_CYCLE_MAX_LENGTH
|
|
1758
|
+
)
|
|
1555
1759
|
end
|
|
1556
1760
|
|
|
1557
1761
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -1651,7 +1855,7 @@ module Woods
|
|
|
1651
1855
|
else
|
|
1652
1856
|
metadata.delete('package')
|
|
1653
1857
|
end
|
|
1654
|
-
AtomicFile.write(file, json_serialize(data))
|
|
1858
|
+
AtomicFile.write(file, json_serialize(data), durable: payload_writes_durable?)
|
|
1655
1859
|
|
|
1656
1860
|
identifier = data['identifier']
|
|
1657
1861
|
type = (data['type'] || type_dir.singularize).to_sym
|
|
@@ -1715,7 +1919,7 @@ module Woods
|
|
|
1715
1919
|
payload = json_serialize(unit.to_h)
|
|
1716
1920
|
return if identical_on_disk?(path, payload)
|
|
1717
1921
|
|
|
1718
|
-
AtomicFile.write(path, payload)
|
|
1922
|
+
AtomicFile.write(path, payload, durable: payload_writes_durable?)
|
|
1719
1923
|
end
|
|
1720
1924
|
|
|
1721
1925
|
# The serialized `extracted_at` scalar as {ExtractedUnit#to_h} +
|
|
@@ -1960,14 +2164,16 @@ module Woods
|
|
|
1960
2164
|
units.each do |unit|
|
|
1961
2165
|
AtomicFile.write(
|
|
1962
2166
|
type_dir.join(collision_safe_filename(unit.identifier)),
|
|
1963
|
-
json_serialize(unit.to_h)
|
|
2167
|
+
json_serialize(unit.to_h),
|
|
2168
|
+
durable: payload_writes_durable?
|
|
1964
2169
|
)
|
|
1965
2170
|
end
|
|
1966
2171
|
|
|
1967
2172
|
# Also write a type index for fast lookups
|
|
1968
2173
|
AtomicFile.write(
|
|
1969
2174
|
type_dir.join('_index.json'),
|
|
1970
|
-
json_serialize(type_index_entries(units))
|
|
2175
|
+
json_serialize(type_index_entries(units)),
|
|
2176
|
+
durable: payload_writes_durable?
|
|
1971
2177
|
)
|
|
1972
2178
|
end
|
|
1973
2179
|
end
|
|
@@ -2064,10 +2270,23 @@ module Woods
|
|
|
2064
2270
|
# registration order must not reach it (B-180).
|
|
2065
2271
|
graph_data[:pagerank] = @dependency_graph.pagerank.sort_by { |identifier, _| identifier }.to_h
|
|
2066
2272
|
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2273
|
+
payload = json_serialize(graph_data)
|
|
2274
|
+
# The bytes about to land on disk are the bytes `graph_sha` covers, so
|
|
2275
|
+
# keep the digest here rather than reading a whole large-app graph back
|
|
2276
|
+
# to compute it. AtomicFile writes in binary mode and reads back as
|
|
2277
|
+
# UTF-8, so the two digests are the same either way.
|
|
2278
|
+
@graph_sha = Digest::SHA256.hexdigest(payload)
|
|
2279
|
+
AtomicFile.write(payload_dir.join('dependency_graph.json'), payload, durable: payload_writes_durable?)
|
|
2280
|
+
end
|
|
2281
|
+
|
|
2282
|
+
# The digest of the dependency graph this run wrote.
|
|
2283
|
+
#
|
|
2284
|
+
# Falls back to reading the file for a caller that writes the analysis
|
|
2285
|
+
# without having written the graph in the same run.
|
|
2286
|
+
#
|
|
2287
|
+
# @return [String] hex SHA256 of dependency_graph.json
|
|
2288
|
+
def graph_sha
|
|
2289
|
+
@graph_sha || Digest::SHA256.hexdigest(AtomicFile.read(payload_dir.join('dependency_graph.json')))
|
|
2071
2290
|
end
|
|
2072
2291
|
|
|
2073
2292
|
def write_graph_analysis
|
|
@@ -2075,14 +2294,13 @@ module Woods
|
|
|
2075
2294
|
|
|
2076
2295
|
enriched = @graph_analysis.merge(
|
|
2077
2296
|
generated_at: Time.current.iso8601,
|
|
2078
|
-
graph_sha:
|
|
2079
|
-
AtomicFile.read(payload_dir.join('dependency_graph.json'))
|
|
2080
|
-
)
|
|
2297
|
+
graph_sha: graph_sha
|
|
2081
2298
|
)
|
|
2082
2299
|
|
|
2083
2300
|
AtomicFile.write(
|
|
2084
2301
|
payload_dir.join('graph_analysis.json'),
|
|
2085
|
-
json_serialize(enriched)
|
|
2302
|
+
json_serialize(enriched),
|
|
2303
|
+
durable: payload_writes_durable?
|
|
2086
2304
|
)
|
|
2087
2305
|
end
|
|
2088
2306
|
|
|
@@ -2127,32 +2345,55 @@ module Woods
|
|
|
2127
2345
|
|
|
2128
2346
|
AtomicFile.write(
|
|
2129
2347
|
payload_dir.join('manifest.json'),
|
|
2130
|
-
json_serialize(manifest)
|
|
2348
|
+
json_serialize(manifest),
|
|
2349
|
+
durable: payload_writes_durable?
|
|
2131
2350
|
)
|
|
2132
2351
|
end
|
|
2133
2352
|
|
|
2134
2353
|
# Unit and chunk counts derived from the per-type _index.json files on
|
|
2135
|
-
# disk
|
|
2354
|
+
# disk: the source of truth after an incremental run, where only the
|
|
2136
2355
|
# affected units were re-extracted.
|
|
2137
2356
|
#
|
|
2138
2357
|
# @return [Array(Hash{Symbol => Integer}, Integer)] counts by type, total chunk count
|
|
2139
2358
|
def persisted_counts
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2359
|
+
stats = persisted_index_stats
|
|
2360
|
+
[stats.transform_values { |type_stats| type_stats[:count] },
|
|
2361
|
+
stats.sum { |_type, type_stats| type_stats[:chunks] }]
|
|
2362
|
+
end
|
|
2363
|
+
|
|
2364
|
+
# One pass over the persisted per-type _index.json files.
|
|
2365
|
+
#
|
|
2366
|
+
# {#persisted_counts} feeds the manifest and {#persisted_summary_stats}
|
|
2367
|
+
# feeds SUMMARY.md, they run back to back at the end of every incremental
|
|
2368
|
+
# run, and each used to parse every type index for itself. Reading once and
|
|
2369
|
+
# deriving both is also what keeps the two artifacts from disagreeing about
|
|
2370
|
+
# totals, which was previously a property of them using the same source
|
|
2371
|
+
# rather than the same read.
|
|
2372
|
+
#
|
|
2373
|
+
# An unreadable index drops that whole type from the manifest and the
|
|
2374
|
+
# summary alike, with one warning rather than the two the separate passes
|
|
2375
|
+
# emitted. Sorted for determinism, since the glob order is the
|
|
2376
|
+
# filesystem's.
|
|
2377
|
+
#
|
|
2378
|
+
# Memoized per run: invalidated at the start of {#extract_all} and
|
|
2379
|
+
# {#prepare_incremental_run}, and again whenever {#regenerate_type_index}
|
|
2380
|
+
# rewrites an index underneath it.
|
|
2381
|
+
#
|
|
2382
|
+
# @return [Hash{Symbol => Hash}] type => `{ count:, chunks:, namespaces: }`
|
|
2383
|
+
def persisted_index_stats
|
|
2384
|
+
@persisted_index_stats ||= Dir[payload_dir.join('*/_index.json').to_s].each_with_object({}) do |path, stats|
|
|
2385
|
+
entries = JSON.parse(AtomicFile.read(path))
|
|
2386
|
+
stats[File.basename(File.dirname(path)).to_sym] = {
|
|
2387
|
+
count: entries.size,
|
|
2388
|
+
chunks: entries.sum { |entry| entry['chunk_count'].to_i },
|
|
2389
|
+
namespaces: namespace_histogram(entries.map { |entry| entry['namespace'] })
|
|
2390
|
+
}
|
|
2147
2391
|
rescue JSON::ParserError => e
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
next
|
|
2392
|
+
type = File.basename(File.dirname(path))
|
|
2393
|
+
Rails.logger.warn(
|
|
2394
|
+
"[Woods] Skipping unreadable #{type}/_index.json in manifest counts and summary totals: #{e.message}"
|
|
2395
|
+
)
|
|
2153
2396
|
end
|
|
2154
|
-
|
|
2155
|
-
[counts, chunks]
|
|
2156
2397
|
end
|
|
2157
2398
|
|
|
2158
2399
|
# Capture a temporal snapshot after extraction completes.
|
|
@@ -2287,7 +2528,8 @@ module Woods
|
|
|
2287
2528
|
|
|
2288
2529
|
AtomicFile.write(
|
|
2289
2530
|
payload_dir.join('SUMMARY.md'),
|
|
2290
|
-
summary.join("\n")
|
|
2531
|
+
summary.join("\n"),
|
|
2532
|
+
durable: payload_writes_durable?
|
|
2291
2533
|
)
|
|
2292
2534
|
end
|
|
2293
2535
|
|
|
@@ -2308,32 +2550,14 @@ module Woods
|
|
|
2308
2550
|
end
|
|
2309
2551
|
|
|
2310
2552
|
# The incremental path's counterpart to {#results_summary_stats}: the same
|
|
2311
|
-
# shape read back
|
|
2312
|
-
#
|
|
2313
|
-
#
|
|
2314
|
-
# the
|
|
2315
|
-
#
|
|
2316
|
-
#
|
|
2317
|
-
# indexes at all, so a bare index writes no summary — matching the full
|
|
2318
|
-
# path's early return when it extracted nothing
|
|
2553
|
+
# shape, read back through {#persisted_index_stats}. A type whose index is
|
|
2554
|
+
# empty is left out, where the manifest still counts it as zero.
|
|
2555
|
+
#
|
|
2556
|
+
# @return [Hash{Symbol => Hash}, nil] nil when the payload holds no
|
|
2557
|
+
# non-empty type index, so a bare index writes no summary, matching the
|
|
2558
|
+
# full path's early return when it extracted nothing
|
|
2319
2559
|
def persisted_summary_stats
|
|
2320
|
-
stats = {}
|
|
2321
|
-
|
|
2322
|
-
Dir[payload_dir.join('*/_index.json').to_s].each do |index_path|
|
|
2323
|
-
entries = JSON.parse(AtomicFile.read(index_path))
|
|
2324
|
-
next if entries.empty?
|
|
2325
|
-
|
|
2326
|
-
stats[File.basename(File.dirname(index_path)).to_sym] = {
|
|
2327
|
-
count: entries.size,
|
|
2328
|
-
chunks: entries.sum { |e| e['chunk_count'].to_i },
|
|
2329
|
-
namespaces: namespace_histogram(entries.map { |e| e['namespace'] })
|
|
2330
|
-
}
|
|
2331
|
-
rescue JSON::ParserError => e
|
|
2332
|
-
# Same posture as {#persisted_counts}: an unreadable index drops that
|
|
2333
|
-
# type from the manifest and the summary alike, so the two still agree.
|
|
2334
|
-
type = File.basename(File.dirname(index_path))
|
|
2335
|
-
Rails.logger.warn("[Woods] Skipping unreadable #{type}/_index.json in summary totals: #{e.message}")
|
|
2336
|
-
end
|
|
2560
|
+
stats = persisted_index_stats.reject { |_type, type_stats| type_stats[:count].zero? }
|
|
2337
2561
|
|
|
2338
2562
|
stats.empty? ? nil : stats
|
|
2339
2563
|
end
|
|
@@ -2352,6 +2576,10 @@ module Woods
|
|
|
2352
2576
|
type_dir = payload_dir.join(type_key.to_s)
|
|
2353
2577
|
return unless type_dir.directory?
|
|
2354
2578
|
|
|
2579
|
+
# This run's counts and summary totals are read from the index files;
|
|
2580
|
+
# rewriting one invalidates whatever was read before.
|
|
2581
|
+
@persisted_index_stats = nil
|
|
2582
|
+
|
|
2355
2583
|
# Scan existing unit JSON files (exclude _index.json)
|
|
2356
2584
|
index = Dir[type_dir.join('*.json')].filter_map do |file|
|
|
2357
2585
|
next if File.basename(file) == '_index.json'
|
|
@@ -2371,7 +2599,8 @@ module Woods
|
|
|
2371
2599
|
|
|
2372
2600
|
AtomicFile.write(
|
|
2373
2601
|
type_dir.join('_index.json'),
|
|
2374
|
-
json_serialize(index)
|
|
2602
|
+
json_serialize(index),
|
|
2603
|
+
durable: payload_writes_durable?
|
|
2375
2604
|
)
|
|
2376
2605
|
end
|
|
2377
2606
|
|
|
@@ -2806,6 +3035,11 @@ module Woods
|
|
|
2806
3035
|
return Set.new if keys.empty?
|
|
2807
3036
|
|
|
2808
3037
|
keys += ROUTE_CONSUMER_EXTRACTORS if keys.include?(:routes)
|
|
3038
|
+
# A routes re-run replaces every controller, and a flow document
|
|
3039
|
+
# carries the route itself, which no dependency edge connects to the
|
|
3040
|
+
# controller. Nothing about that is reachable by a graph walk, so the
|
|
3041
|
+
# run drops its flow scope and reassembles every touched controller.
|
|
3042
|
+
@flow_scope = nil if keys.include?(:routes)
|
|
2809
3043
|
|
|
2810
3044
|
keys.each_with_object(Set.new) do |key, touched|
|
|
2811
3045
|
touched.merge(replace_type_wholesale(key, affected_types))
|
|
@@ -3282,7 +3516,7 @@ module Woods
|
|
|
3282
3516
|
|
|
3283
3517
|
return if JSON.generate(data) == before
|
|
3284
3518
|
|
|
3285
|
-
AtomicFile.write(path, json_serialize(data))
|
|
3519
|
+
AtomicFile.write(path, json_serialize(data), durable: payload_writes_durable?)
|
|
3286
3520
|
affected_types&.add(extractor_key)
|
|
3287
3521
|
rescue JSON::ParserError => e
|
|
3288
3522
|
Rails.logger.warn "[Woods] Could not finalize #{identifier}: #{e.message}"
|