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.
@@ -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
- begin_payload!
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
- if Woods.configuration.concurrent_extraction
414
- extract_all_concurrent
415
- else
416
- extract_all_sequential
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
- write_manifest
487
- write_structural_summary
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
- affected_ids = @dependency_graph.affected_by(change_set.absolute_paths)
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 = reconcile_changed_paths(change_set, affected_types)
544
+ touched = profile_phase('re-extraction') do
545
+ acc = reconcile_changed_paths(change_set, affected_types)
535
546
 
536
- (affected_ids - touched.to_a).each do |unit_id|
537
- touched.add(unit_id) if re_extract_unit(unit_id, affected_types: affected_types)
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
- affected_types.each do |type_key|
575
- regenerate_type_index(type_key)
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
- @dependency_graph = DependencyGraph.from_h(JSON.parse(AtomicFile.read(graph_path))) if graph_path.exist?
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
- write_manifest(incremental: true)
719
- write_structural_summary
720
- publish_generation(reason)
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
- marker = generation.bump!(reason: reason, payload: publishable_payload_name(generation))
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
- Rails.logger.info "[Woods] Refreshing flows for #{reextracted.size} controller(s), " \
1333
- "#{removed.size} removed..."
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: reextracted.filter_map { |id| unit_from_payload(:controllers, id) },
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
- ratio = Woods.configuration&.volatile_dependency_ratio || GraphAnalyzer::DEFAULT_VOLATILE_RATIO
1554
- GraphAnalyzer.new(@dependency_graph, volatile_ratio: ratio)
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
- AtomicFile.write(
2068
- payload_dir.join('dependency_graph.json'),
2069
- json_serialize(graph_data)
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: Digest::SHA256.hexdigest(
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 the source of truth after an incremental run, where only the
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
- counts = {}
2141
- chunks = 0
2142
-
2143
- Dir[payload_dir.join('*/_index.json').to_s].each do |index_path|
2144
- entries = JSON.parse(AtomicFile.read(index_path))
2145
- counts[File.basename(File.dirname(index_path)).to_sym] = entries.size
2146
- chunks += entries.sum { |e| e['chunk_count'].to_i }
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
- # An unreadable index silently drops that whole type from the manifest
2149
- # counts — warn rather than undercount without a trace.
2150
- type = File.basename(File.dirname(index_path))
2151
- Rails.logger.warn("[Woods] Skipping unreadable #{type}/_index.json in manifest counts: #{e.message}")
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 from the persisted per-type _index.json files, the source
2312
- # {#persisted_counts} uses for the manifest so the two artifacts cannot
2313
- # disagree about totals. Sorted for determinism, since the glob order is
2314
- # the filesystem's.
2315
- #
2316
- # @return [Hash{Symbol => Hash}, nil] nil when the payload holds no type
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}"