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/flow_assembler.rb
CHANGED
|
@@ -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 =
|
|
156
|
-
scope_node ||=
|
|
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
|
|
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 =
|
|
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 +
|
|
187
|
-
#
|
|
188
|
-
#
|
|
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
|
data/lib/woods/graph_analyzer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
817
|
-
|
|
852
|
+
catch(:cycle_limit) do
|
|
853
|
+
nodes.keys.sort.each do |start_node|
|
|
854
|
+
next unless color[start_node] == white
|
|
818
855
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
856
|
+
# Iterative DFS using an explicit stack.
|
|
857
|
+
# Each entry is [node, :enter] or [node, :exit].
|
|
858
|
+
stack = [[start_node, :enter]]
|
|
822
859
|
|
|
823
|
-
|
|
824
|
-
|
|
860
|
+
# Track the current DFS path for cycle extraction.
|
|
861
|
+
path = []
|
|
825
862
|
|
|
826
|
-
|
|
827
|
-
|
|
863
|
+
while stack.any?
|
|
864
|
+
node, action = stack.pop
|
|
828
865
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
866
|
+
if action == :exit
|
|
867
|
+
color[node] = black
|
|
868
|
+
path.pop
|
|
869
|
+
next
|
|
870
|
+
end
|
|
834
871
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
# @
|
|
878
|
-
#
|
|
879
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
939
|
-
queue = [
|
|
1026
|
+
parents = { source => nil }
|
|
1027
|
+
queue = [source]
|
|
1028
|
+
head = 0
|
|
940
1029
|
|
|
941
|
-
while queue.
|
|
942
|
-
current
|
|
1030
|
+
while head < queue.size
|
|
1031
|
+
current = queue[head]
|
|
1032
|
+
head += 1
|
|
943
1033
|
|
|
944
|
-
|
|
945
|
-
next if
|
|
1034
|
+
adjacency[current].each do |neighbor|
|
|
1035
|
+
next if parents.key?(neighbor)
|
|
946
1036
|
|
|
947
|
-
|
|
948
|
-
return
|
|
1037
|
+
parents[neighbor] = current
|
|
1038
|
+
return path_to(parents, neighbor) if neighbor == target
|
|
949
1039
|
|
|
950
|
-
|
|
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
|
data/lib/woods/payload_store.rb
CHANGED
|
@@ -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