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.
@@ -4,7 +4,6 @@ require 'digest'
4
4
  require 'json'
5
5
  require 'set'
6
6
  require_relative 'ast/parser'
7
- require_relative 'ast/method_extractor'
8
7
  require_relative 'flow_analysis/operation_extractor'
9
8
  require_relative 'flow_document'
10
9
 
@@ -25,15 +24,25 @@ module Woods
25
24
  # puts flow.to_markdown
26
25
  #
27
26
  class FlowAssembler
27
+ # How many entries each per-instance memo holds before the oldest is
28
+ # dropped. One assembler serves a whole precompute run (every controller,
29
+ # every action), so an unbounded memo would hold the source and the parsed
30
+ # AST of every unit the run ever reached. A thousand covers the shared
31
+ # services a large app's flows keep landing on without pinning the whole
32
+ # index in memory.
33
+ MEMO_LIMIT = 1_000
34
+
28
35
  # @param graph [DependencyGraph] The dependency graph for resolving targets
29
36
  # @param extracted_dir [String] Directory containing extracted unit JSON files
30
37
  def initialize(graph:, extracted_dir:)
31
38
  @graph = graph
32
39
  @extracted_dir = extracted_dir
33
40
  @parser = Ast::Parser.new
34
- @method_extractor = Ast::MethodExtractor.new(parser: @parser)
35
41
  @operation_extractor = FlowAnalysis::OperationExtractor.new
36
42
  @resolved_targets = {}
43
+ @unit_cache = {}
44
+ @ast_cache = {}
45
+ @method_node_cache = {}
37
46
  end
38
47
 
39
48
  # Assemble an execution flow from the given entry point.
@@ -114,7 +123,7 @@ module Woods
114
123
  file_path = unit_data[:file_path]
115
124
 
116
125
  # Extract operations from the relevant method
117
- operations = extract_operations(source_code, method_name, metadata, unit_type)
126
+ operations = extract_operations(unit_id, source_code, method_name, metadata, unit_type)
118
127
 
119
128
  step = {
120
129
  unit: identifier,
@@ -146,19 +155,75 @@ module Woods
146
155
  # one the callee actually defines locally (inherited, dynamically
147
156
  # defined, or metaprogrammed) — losing the trace entirely would be worse
148
157
  # than over-including it.
149
- def extract_operations(source_code, method_name, metadata, unit_type)
158
+ def extract_operations(unit_id, source_code, method_name, metadata, unit_type)
150
159
  operations = []
151
160
 
152
161
  # For controllers, prepend before_action callbacks
153
162
  prepend_callbacks(operations, metadata, method_name) if unit_type == 'controller'
154
163
 
155
- scope_node = method_name && @method_extractor.extract_method(source_code, method_name)
156
- scope_node ||= @parser.parse(source_code)
164
+ scope_node = method_node(unit_id, source_code, method_name)
165
+ scope_node ||= parsed_source(unit_id, source_code)
157
166
  operations.concat(@operation_extractor.extract(scope_node))
158
167
 
159
168
  operations
160
169
  end
161
170
 
171
+ # The unit's whole parsed source, parsed once per assembler instance.
172
+ #
173
+ # A unit reached from several controllers used to be re-parsed once per
174
+ # action of every controller that reached it, and Prism dominates the
175
+ # flow run's cost on a large app.
176
+ #
177
+ # @param unit_id [String] cache key; a unit's source is fixed for a run
178
+ # @param source_code [String] the unit's source
179
+ # @return [Ast::Node] root node
180
+ def parsed_source(unit_id, source_code)
181
+ memoize(@ast_cache, unit_id) { @parser.parse(source_code) }
182
+ end
183
+
184
+ # The `def` node for +method_name+, from a per-unit index built off the
185
+ # memoized parse.
186
+ #
187
+ # First definition wins for a name defined more than once, matching
188
+ # {Ast::MethodExtractor#extract_method}'s first-match lookup.
189
+ #
190
+ # @param unit_id [String] cache key
191
+ # @param source_code [String] the unit's source
192
+ # @param method_name [String, nil] the method the caller invoked
193
+ # @return [Ast::Node, nil] nil when no method was named, or the unit does
194
+ # not define one by that name
195
+ def method_node(unit_id, source_code, method_name)
196
+ return nil unless method_name
197
+
198
+ nodes = memoize(@method_node_cache, unit_id) do
199
+ parsed_source(unit_id, source_code).find_all(:def).each_with_object({}) do |node, index|
200
+ index[node.method_name] ||= node
201
+ end
202
+ end
203
+
204
+ nodes[method_name.to_s]
205
+ end
206
+
207
+ # Read +key+ from +cache+, computing and storing it on a miss.
208
+ #
209
+ # Least-recently-used, using the fact that a Ruby Hash iterates in
210
+ # insertion order: a hit re-inserts the key at the young end, and
211
+ # +Hash#shift+ drops the old end once the cache is over {MEMO_LIMIT}.
212
+ # A nil value is a real answer (most call targets are not units) and is
213
+ # cached like any other.
214
+ #
215
+ # @param cache [Hash]
216
+ # @param key [Object]
217
+ # @return [Object] the cached or freshly computed value
218
+ def memoize(cache, key)
219
+ return cache[key] = cache.delete(key) if cache.key?(key)
220
+
221
+ value = yield
222
+ cache[key] = value
223
+ cache.shift while cache.size > MEMO_LIMIT
224
+ value
225
+ end
226
+
162
227
  # Prepend before_action callbacks from controller metadata.
163
228
  #
164
229
  # Handles two metadata formats:
@@ -296,13 +361,27 @@ module Woods
296
361
  end
297
362
  end
298
363
 
299
- # Load an ExtractedUnit's data from its JSON file on disk.
364
+ # Load an ExtractedUnit's data from its JSON file on disk, memoized per
365
+ # assembler instance.
366
+ #
367
+ # The glob and the JSON parse used to run once per expansion, so a unit
368
+ # reached from many controllers paid for both once per action of every one
369
+ # of them. The memo also makes {#extract_route} free: the entry unit it
370
+ # asks for is the one {#expand} just loaded.
371
+ #
372
+ # @param unit_id [String] Unit identifier
373
+ # @return [Hash, nil] symbolized unit data, or nil when no file matched
374
+ def load_unit(unit_id)
375
+ memoize(@unit_cache, unit_id) { read_unit(unit_id) }
376
+ end
377
+
378
+ # The disk half of {#load_unit}.
300
379
  #
301
380
  # Uses {Extractor#collision_safe_filename} convention (with SHA256 digest suffix).
302
381
  # Falls back to legacy {Extractor#safe_filename} for older indexes.
303
382
  # Searches across type subdirectories since the extractor writes to
304
383
  # `<output_dir>/<type>/<filename>.json`.
305
- def load_unit(unit_id)
384
+ def read_unit(unit_id)
306
385
  base = unit_id.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
307
386
  digest = Digest::SHA256.hexdigest(unit_id)[0, 8]
308
387
  filenames = [
@@ -89,11 +89,17 @@ module Woods
89
89
  # controller units, rehydrated from the payload this run seeded
90
90
  # @param removed_identifiers [Array<String>] controller identifiers
91
91
  # whose unit the run pruned
92
+ # @param carried_identifiers [Array<String>] controllers the run
93
+ # re-extracted whose flows it established cannot have changed. Their
94
+ # index entries stay as they are and their annotation is read back out
95
+ # of those entries, because a re-extracted unit lost the annotation
96
+ # whether or not its flows moved. Skipping the assembly is the whole
97
+ # saving; skipping the annotation would diverge from a full run.
92
98
  # @return [Hash{String => Hash{String => String}}] per-controller
93
99
  # annotation (action => relative flow path) to write into the units'
94
100
  # metadata; an empty hash means "no flows", which clears any
95
101
  # annotation a previous run had written
96
- def recompute_delta(touched_units:, removed_identifiers: [])
102
+ def recompute_delta(touched_units:, removed_identifiers: [], carried_identifiers: [])
97
103
  FileUtils.mkdir_p(@flows_dir)
98
104
 
99
105
  assembler = FlowAssembler.new(graph: @graph, extracted_dir: @output_dir)
@@ -110,8 +116,13 @@ module Woods
110
116
  delta.merge!(entries)
111
117
  end
112
118
 
119
+ previous = previous_flow_index
120
+ Array(carried_identifiers).each do |identifier|
121
+ annotations[identifier] = carried_annotation(previous, identifier)
122
+ end
123
+
113
124
  replaced = touched_units.map(&:identifier) + Array(removed_identifiers)
114
- carried = previous_flow_index.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
125
+ carried = previous.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
115
126
 
116
127
  write_flow_index(carried.merge(delta))
117
128
 
@@ -156,6 +167,18 @@ module Woods
156
167
  [entries, unit_flow_paths]
157
168
  end
158
169
 
170
+ # One controller's annotation, rebuilt from the index entries it keeps.
171
+ #
172
+ # @param previous [Hash{String => String}] the previous flow index
173
+ # @param identifier [String] controller identifier
174
+ # @return [Hash{String => String}] action to relative flow path
175
+ def carried_annotation(previous, identifier)
176
+ previous.each_with_object({}) do |(entry_point, path), annotation|
177
+ controller, action = entry_point.to_s.split('#', 2)
178
+ annotation[action] = path if action && controller == identifier
179
+ end
180
+ end
181
+
159
182
  # The controller part of a flow index entry point.
160
183
  #
161
184
  # @param entry_point [String]
@@ -183,9 +206,10 @@ module Woods
183
206
 
184
207
  # Assemble a flow for one entry point and write the JSON file.
185
208
  #
186
- # Written via {Woods::AtomicFile} (temp + fsync + rename) like every
187
- # other index artifact a plain +File.write+ interrupted mid-write left
188
- # a torn partial for the MCP read side to trip over.
209
+ # Written via {Woods::AtomicFile} (temp + rename) like every other index
210
+ # artifact. A plain +File.write+ interrupted mid-write left a torn
211
+ # partial for the MCP read side to trip over. Not durable by default: see
212
+ # {#durable_writes?}.
189
213
  #
190
214
  # @param assembler [FlowAssembler]
191
215
  # @param entry_point [String]
@@ -199,7 +223,7 @@ module Woods
199
223
 
200
224
  filename = Woods::FilenameUtils.flow_filename(controller_id, action)
201
225
 
202
- Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h))
226
+ Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h), durable: durable_writes?)
203
227
 
204
228
  # Relative — this value is persisted (flow_index.json and the unit's
205
229
  # metadata[:flow_paths]), so it must not carry this machine's root.
@@ -213,7 +237,20 @@ module Woods
213
237
  # @param flow_map [Hash{String => String}]
214
238
  def write_flow_index(flow_map)
215
239
  index_path = File.join(@flows_dir, 'flow_index.json')
216
- Woods::AtomicFile.write(index_path, canonical_json(flow_map))
240
+ Woods::AtomicFile.write(index_path, canonical_json(flow_map), durable: durable_writes?)
241
+ end
242
+
243
+ # Whether a flow document pays for its own fsync as it is written.
244
+ #
245
+ # False by default. A flow document is a payload file: nothing reads it
246
+ # until +generation.json+ names the payload, and
247
+ # {Woods::AtomicFile.sync_directory_tree} flushes the whole payload before
248
+ # that pointer is written. `durable_payload_writes = true` buys the
249
+ # per-file fsync back for a host that wants it.
250
+ #
251
+ # @return [Boolean]
252
+ def durable_writes?
253
+ Woods.configuration&.durable_payload_writes ? true : false
217
254
  end
218
255
 
219
256
  # Emit deterministic pretty JSON — keys recursively sorted so two runs
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
3
4
  require 'set'
4
5
 
5
6
  module Woods
@@ -55,11 +56,30 @@ module Woods
55
56
  # can tell whether it was truncated (B-182).
56
57
  DEFAULT_VOLATILE_LIMIT = 20
57
58
 
59
+ # How many distinct cycles {#detect_cycles} enumerates before it stops.
60
+ # The DFS finds one cycle per back-edge, and a dense graph has tens of
61
+ # thousands of them; nobody reads past the first few hundred, and the
62
+ # per-cycle signature work is what made analysis the fixed floor of every
63
+ # incremental run. `nil` means no cap.
64
+ DEFAULT_CYCLE_LIMIT = 500
65
+
66
+ # The longest cycle {#detect_cycles} will record, counted in distinct
67
+ # nodes. A back-edge deep in a DFS closes a cycle as long as the path,
68
+ # which on a large graph is thousands of nodes: unreadable as a report and
69
+ # expensive to canonicalize. `nil` means no cap.
70
+ DEFAULT_CYCLE_MAX_LENGTH = 50
71
+
58
72
  # @param dependency_graph [DependencyGraph] The graph to analyze
59
73
  # @param volatile_ratio [Numeric] see {#volatile_dependencies}
60
- def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO)
74
+ # @param cycle_limit [Integer, nil] see {DEFAULT_CYCLE_LIMIT}
75
+ # @param cycle_max_length [Integer, nil] see {DEFAULT_CYCLE_MAX_LENGTH}
76
+ def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO,
77
+ cycle_limit: DEFAULT_CYCLE_LIMIT, cycle_max_length: DEFAULT_CYCLE_MAX_LENGTH)
61
78
  @graph = dependency_graph
62
79
  @volatile_ratio = volatile_ratio.to_f
80
+ @cycle_limit = cycle_limit
81
+ @cycle_max_length = cycle_max_length
82
+ @cycle_limit_reached = false
63
83
  end
64
84
 
65
85
  # ══════════════════════════════════════════════════════════════════════
@@ -147,6 +167,19 @@ module Woods
147
167
  @cycles ||= detect_cycles
148
168
  end
149
169
 
170
+ # Whether {#cycles} is a truncated view of the graph's cycles.
171
+ #
172
+ # True when either cap fired: the count cap stopped enumeration, or at
173
+ # least one cycle was longer than the length cap and was skipped. A reader
174
+ # of the array alone cannot tell, so this is published as
175
+ # `stats[:cycle_limit_reached]` alongside it.
176
+ #
177
+ # @return [Boolean]
178
+ def cycle_limit_reached?
179
+ cycles
180
+ @cycle_limit_reached
181
+ end
182
+
150
183
  # Units that bridge different types in the graph.
151
184
  #
152
185
  # Computes a simplified betweenness centrality metric — for each unit, we
@@ -179,6 +212,7 @@ module Woods
179
212
 
180
213
  pairs.each do |source, target|
181
214
  path = bfs_shortest_path(source, target)
215
+
182
216
  next unless path && path.size > 2
183
217
 
184
218
  # Credit intermediate nodes (exclude source and target)
@@ -361,6 +395,7 @@ module Woods
361
395
  dead_end_count: computed_dead_ends.size,
362
396
  hub_count: computed_hubs.size,
363
397
  cycle_count: computed_cycles.size,
398
+ cycle_limit_reached: cycle_limit_reached?,
364
399
  cross_database_edge_count: computed_cross_database.size,
365
400
  volatile_dependency_count: all_volatile_dependencies.size,
366
401
  volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT,
@@ -802,6 +837,7 @@ module Woods
802
837
  # @return [Array<Array<String>>] Detected cycles
803
838
  def detect_cycles
804
839
  nodes = graph_nodes
840
+ @cycle_limit_reached = false
805
841
  return [] if nodes.empty?
806
842
 
807
843
  white = 0
@@ -813,53 +849,48 @@ module Woods
813
849
  found_cycles = []
814
850
  seen_cycle_signatures = Set.new
815
851
 
816
- nodes.keys.sort.each do |start_node|
817
- next unless color[start_node] == white
852
+ catch(:cycle_limit) do
853
+ nodes.keys.sort.each do |start_node|
854
+ next unless color[start_node] == white
818
855
 
819
- # Iterative DFS using an explicit stack.
820
- # Each entry is [node, :enter] or [node, :exit].
821
- stack = [[start_node, :enter]]
856
+ # Iterative DFS using an explicit stack.
857
+ # Each entry is [node, :enter] or [node, :exit].
858
+ stack = [[start_node, :enter]]
822
859
 
823
- # Track the current DFS path for cycle extraction.
824
- path = []
860
+ # Track the current DFS path for cycle extraction.
861
+ path = []
825
862
 
826
- while stack.any?
827
- node, action = stack.pop
863
+ while stack.any?
864
+ node, action = stack.pop
828
865
 
829
- if action == :exit
830
- color[node] = black
831
- path.pop
832
- next
833
- end
866
+ if action == :exit
867
+ color[node] = black
868
+ path.pop
869
+ next
870
+ end
834
871
 
835
- # :enter action
836
- next unless color[node] == white
837
-
838
- color[node] = gray
839
- path.push(node)
840
- stack.push([node, :exit])
841
-
842
- # Not sorted, deliberately: this list is the unit's own declared
843
- # dependency order, which is identical in a full and an incremental
844
- # run, so sorting it would change nothing any test can observe.
845
- neighbors = @graph.dependencies_of(node)
846
- neighbors.each do |neighbor|
847
- case color[neighbor]
848
- when white
849
- parent[neighbor] = node
850
- stack.push([neighbor, :enter])
851
- when gray
852
- # Found a cycle extract it from the path
853
- cycle = extract_cycle_from_path(path, neighbor)
854
- if cycle
855
- sig = normalize_cycle_signature(cycle)
856
- unless seen_cycle_signatures.include?(sig)
857
- seen_cycle_signatures.add(sig)
858
- found_cycles << cycle
859
- end
872
+ # :enter action
873
+ next unless color[node] == white
874
+
875
+ color[node] = gray
876
+ path.push(node)
877
+ stack.push([node, :exit])
878
+
879
+ # Not sorted, deliberately: this list is the unit's own declared
880
+ # dependency order, which is identical in a full and an incremental
881
+ # run, so sorting it would change nothing any test can observe.
882
+ neighbors = @graph.dependencies_of(node)
883
+ neighbors.each do |neighbor|
884
+ case color[neighbor]
885
+ when white
886
+ parent[neighbor] = node
887
+ stack.push([neighbor, :enter])
888
+ when gray
889
+ # Found a cycle: extract it from the path
890
+ collect_cycle(path, neighbor, found_cycles, seen_cycle_signatures)
860
891
  end
892
+ # black nodes are fully explored, skip them
861
893
  end
862
- # black nodes are fully explored, skip them
863
894
  end
864
895
  end
865
896
  end
@@ -869,16 +900,52 @@ module Woods
869
900
  found_cycles
870
901
  end
871
902
 
903
+ # Record the cycle closed by a back-edge to +cycle_start+.
904
+ #
905
+ # Skips a cycle longer than the length cap and one already recorded under
906
+ # another rotation. Throws +:cycle_limit+ once the count cap is full,
907
+ # which ends the whole scan in {#detect_cycles}.
908
+ #
909
+ # @param path [Array<String>] Current DFS path
910
+ # @param cycle_start [String] The node that closes the cycle
911
+ # @param found_cycles [Array<Array<String>>] Accumulator
912
+ # @param seen [Set<String>] Signatures already recorded
913
+ # @return [void]
914
+ def collect_cycle(path, cycle_start, found_cycles, seen)
915
+ cycle = extract_cycle_from_path(path, cycle_start, max_length: @cycle_max_length)
916
+ if cycle == :too_long
917
+ @cycle_limit_reached = true
918
+ return
919
+ end
920
+ return unless cycle
921
+
922
+ signature = normalize_cycle_signature(cycle)
923
+ return if seen.include?(signature)
924
+
925
+ seen.add(signature)
926
+ found_cycles << cycle
927
+ return unless @cycle_limit && found_cycles.size >= @cycle_limit
928
+
929
+ @cycle_limit_reached = true
930
+ throw :cycle_limit
931
+ end
932
+
872
933
  # Extracts a cycle from the current DFS path when a back-edge to
873
934
  # +cycle_start+ is found.
874
935
  #
936
+ # The length check runs on indexes, before the slice: an over-long cycle
937
+ # costs nothing beyond the +index+ lookup that found it.
938
+ #
875
939
  # @param path [Array<String>] Current DFS path
876
940
  # @param cycle_start [String] The node that closes the cycle
877
- # @return [Array<String>, nil] The cycle path ending with cycle_start repeated,
878
- # or nil if cycle_start is not in the path
879
- def extract_cycle_from_path(path, cycle_start)
941
+ # @param max_length [Integer, nil] Longest cycle to build, in distinct nodes
942
+ # @return [Array<String>, Symbol, nil] The cycle path ending with cycle_start
943
+ # repeated; +:too_long+ when it exceeds +max_length+; nil when cycle_start
944
+ # is not in the path
945
+ def extract_cycle_from_path(path, cycle_start, max_length: nil)
880
946
  start_index = path.index(cycle_start)
881
947
  return nil unless start_index
948
+ return :too_long if max_length && (path.size - start_index) > max_length
882
949
 
883
950
  path[start_index..] + [cycle_start]
884
951
  end
@@ -886,17 +953,20 @@ module Woods
886
953
  # Normalize a cycle so that duplicate rotations are treated as the same cycle.
887
954
  # For example, [A, B, C, A] and [B, C, A, B] are the same cycle.
888
955
  #
956
+ # Keyed by digest rather than by the joined path: the set holds one
957
+ # 64-byte key per cycle instead of a string as long as the cycle, so
958
+ # membership stays constant-cost however deep the DFS went.
959
+ #
889
960
  # @param cycle [Array<String>] Cycle path with repeated last element
890
- # @return [String] Canonical string representation
961
+ # @return [String] Canonical hex digest of the rotated loop
891
962
  def normalize_cycle_signature(cycle)
892
963
  # Remove the trailing repeated element to get the raw loop
893
964
  loop_nodes = cycle[0..-2]
894
- return loop_nodes.join('->') if loop_nodes.empty?
965
+ return Digest::SHA256.hexdigest('') if loop_nodes.empty?
895
966
 
896
967
  # Rotate so the lexicographically smallest element is first
897
968
  min_index = loop_nodes.each_with_index.min_by { |node, _i| node }.last
898
- rotated = loop_nodes.rotate(min_index)
899
- rotated.join('->')
969
+ Digest::SHA256.hexdigest(loop_nodes.rotate(min_index).join('->'))
900
970
  end
901
971
 
902
972
  # ──────────────────────────────────────────────────────────────────────
@@ -927,32 +997,65 @@ module Woods
927
997
  pairs.to_a
928
998
  end
929
999
 
1000
+ # Forward adjacency, resolved once per node per analyzer instance.
1001
+ #
1002
+ # {#bridges} runs `sample_size` whole-graph traversals, and
1003
+ # `DependencyGraph#dependencies_of` sorts and flattens the node's edge
1004
+ # buckets on every call, so an uncached BFS re-derived the same adjacency
1005
+ # list up to 200 times per node. Populated on demand rather than up front:
1006
+ # a traversal that never reaches a node should not pay for it.
1007
+ #
1008
+ # @return [Hash{String => Array<String>}]
1009
+ def adjacency
1010
+ @adjacency ||= Hash.new { |cache, identifier| cache[identifier] = @graph.dependencies_of(identifier) }
1011
+ end
1012
+
930
1013
  # BFS shortest path between two nodes, following forward edges.
931
1014
  #
1015
+ # Carries parent pointers rather than a path per queue entry. The old form
1016
+ # allocated a copy of the path so far for every node it enqueued, which on
1017
+ # a large graph is a full array per node per traversal; the path is now
1018
+ # built once, for the one node that matched.
1019
+ #
932
1020
  # @param source [String] Starting node identifier
933
1021
  # @param target [String] Target node identifier
934
1022
  # @return [Array<String>, nil] Shortest path or nil if unreachable
935
1023
  def bfs_shortest_path(source, target)
936
1024
  return [source] if source == target
937
1025
 
938
- visited = Set.new([source])
939
- queue = [[source, [source]]]
1026
+ parents = { source => nil }
1027
+ queue = [source]
1028
+ head = 0
940
1029
 
941
- while queue.any?
942
- current, path = queue.shift
1030
+ while head < queue.size
1031
+ current = queue[head]
1032
+ head += 1
943
1033
 
944
- @graph.dependencies_of(current).each do |neighbor|
945
- next if visited.include?(neighbor)
1034
+ adjacency[current].each do |neighbor|
1035
+ next if parents.key?(neighbor)
946
1036
 
947
- new_path = path + [neighbor]
948
- return new_path if neighbor == target
1037
+ parents[neighbor] = current
1038
+ return path_to(parents, neighbor) if neighbor == target
949
1039
 
950
- visited.add(neighbor)
951
- queue.push([neighbor, new_path])
1040
+ queue.push(neighbor)
952
1041
  end
953
1042
  end
954
1043
 
955
1044
  nil
956
1045
  end
1046
+
1047
+ # Walk parent pointers back to the source and reverse.
1048
+ #
1049
+ # @param parents [Hash{String => String, nil}] node => the node it was reached from
1050
+ # @param node [String] the end of the path
1051
+ # @return [Array<String>] source-first path ending at +node+
1052
+ def path_to(parents, node)
1053
+ path = []
1054
+ while node
1055
+ path << node
1056
+ node = parents[node]
1057
+ end
1058
+ path.reverse
1059
+ end
957
1060
  end
958
1061
  end
@@ -99,6 +99,10 @@ module Woods
99
99
  return unless from.directory?
100
100
 
101
101
  to = Pathname.new(target.to_s)
102
+ # The one directory `find` never offers: it yields the root as `.`, and
103
+ # a caller seeding a subtree (Extractor#seed_payload_from_flat_root)
104
+ # passes a target that does not exist yet.
105
+ FileUtils.mkdir_p(to.to_s)
102
106
  from.find do |entry|
103
107
  relative = entry.relative_path_from(from)
104
108
  next if relative.to_s == '.'
@@ -211,11 +215,20 @@ module Woods
211
215
  end
212
216
  end
213
217
 
218
+ # `Pathname#find` is a pre-order walk: a directory is always visited
219
+ # before anything inside it. So the directory branch has already created
220
+ # every parent a file could need, and the `mkdir_p` the file branch used
221
+ # to run was one stat-heavy syscall chain per unit re-proving what the
222
+ # previous entry established. On a payload of 8000+ files that was the
223
+ # bulk of the incremental seed.
224
+ #
225
+ # @param entry [Pathname] source entry, as `find` yielded it
226
+ # @param destination [Pathname] where it belongs in the target
227
+ # @return [void]
214
228
  def replicate(entry, destination)
215
229
  if entry.directory?
216
230
  FileUtils.mkdir_p(destination.to_s)
217
231
  else
218
- FileUtils.mkdir_p(destination.dirname.to_s)
219
232
  link_or_copy(entry, destination)
220
233
  end
221
234
  end
@@ -21,7 +21,10 @@ module Woods
21
21
  #
22
22
  # @yield Block to trace
23
23
  # @return [Array<Hash>] Collected trace events
24
+ # @raise [ArgumentError] if no block is given
24
25
  def self.record(&block)
26
+ raise ArgumentError, 'block required' unless block
27
+
25
28
  traces = []
26
29
 
27
30
  trace = TracePoint.new(:call, :return) do |tp|
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '2.0.0.beta1'
4
+ VERSION = '2.0.0.beta2'
5
5
  end