woods 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +160 -0
- data/CONTRIBUTING.md +30 -1
- data/README.md +31 -0
- data/lib/tasks/woods.rake +52 -2
- data/lib/woods/atomic_file.rb +43 -0
- data/lib/woods/cache/cache_middleware.rb +18 -1
- data/lib/woods/console/embedded_executor.rb +20 -2
- data/lib/woods/console/eval_guard.rb +8 -2
- data/lib/woods/console/sql_noise_stripper.rb +113 -0
- data/lib/woods/console/sql_table_scanner.rb +22 -9
- data/lib/woods/console/sql_validator.rb +1 -2
- data/lib/woods/coordination/pipeline_lock.rb +112 -7
- data/lib/woods/dependency_graph.rb +38 -1
- data/lib/woods/embedding/text_preparer.rb +6 -1
- data/lib/woods/export/unit_facts.rb +80 -0
- data/lib/woods/extractor.rb +138 -26
- data/lib/woods/extractors/concern_extractor.rb +3 -5
- data/lib/woods/extractors/configuration_extractor.rb +3 -5
- data/lib/woods/extractors/controller_extractor.rb +1 -1
- data/lib/woods/extractors/database_view_extractor.rb +1 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -5
- data/lib/woods/extractors/event_extractor.rb +2 -2
- data/lib/woods/extractors/factory_extractor.rb +2 -4
- data/lib/woods/extractors/graphql_extractor.rb +1 -1
- data/lib/woods/extractors/i18n_extractor.rb +6 -4
- data/lib/woods/extractors/job_extractor.rb +4 -6
- data/lib/woods/extractors/lib_extractor.rb +1 -1
- data/lib/woods/extractors/mailer_extractor.rb +1 -1
- data/lib/woods/extractors/manager_extractor.rb +3 -5
- data/lib/woods/extractors/migration_extractor.rb +1 -1
- data/lib/woods/extractors/model_extractor.rb +26 -10
- data/lib/woods/extractors/phlex_extractor.rb +1 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -5
- data/lib/woods/extractors/poro_extractor.rb +1 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -5
- data/lib/woods/extractors/rake_task_extractor.rb +2 -4
- data/lib/woods/extractors/serializer_extractor.rb +4 -6
- data/lib/woods/extractors/service_extractor.rb +3 -5
- data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
- data/lib/woods/extractors/shared_utility_methods.rb +14 -0
- data/lib/woods/extractors/state_machine_extractor.rb +2 -4
- data/lib/woods/extractors/validator_extractor.rb +3 -5
- data/lib/woods/extractors/view_component_extractor.rb +1 -1
- data/lib/woods/filename_utils.rb +31 -2
- data/lib/woods/flow_assembler.rb +45 -28
- data/lib/woods/flow_precomputer.rb +2 -1
- data/lib/woods/index_artifact.rb +5 -1
- data/lib/woods/mcp/server.rb +68 -15
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
- data/lib/woods/model_name_cache.rb +6 -1
- data/lib/woods/notion/client.rb +4 -1
- data/lib/woods/notion/exporter.rb +7 -1
- data/lib/woods/obsidian/errors.rb +11 -0
- data/lib/woods/obsidian/name_mapper.rb +132 -0
- data/lib/woods/obsidian/note_builder.rb +260 -0
- data/lib/woods/obsidian/vault_assets.rb +104 -0
- data/lib/woods/obsidian/vault_exporter.rb +412 -0
- data/lib/woods/operator/error_escalator.rb +15 -4
- data/lib/woods/resilience/circuit_breaker.rb +98 -24
- data/lib/woods/resolved_config.rb +4 -1
- data/lib/woods/retrieval/ranker.rb +45 -7
- data/lib/woods/retry_after.rb +36 -0
- data/lib/woods/unblocked/client.rb +4 -1
- data/lib/woods/unblocked/document_builder.rb +21 -27
- data/lib/woods/update_check.rb +190 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +24 -0
- metadata +12 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Coordination
|
|
@@ -38,12 +39,13 @@ module Woods
|
|
|
38
39
|
def acquire
|
|
39
40
|
FileUtils.mkdir_p(@lock_dir)
|
|
40
41
|
|
|
41
|
-
# Check for stale lock first (separate from atomic creation)
|
|
42
42
|
if File.exist?(@lock_path)
|
|
43
43
|
return false unless stale?
|
|
44
|
-
|
|
45
|
-
#
|
|
46
|
-
|
|
44
|
+
# Retire the stale lock atomically. A bare rm_f + create here is
|
|
45
|
+
# a TOCTOU race: two processes passing the stale check together
|
|
46
|
+
# could each delete-and-create, the second deleting the first's
|
|
47
|
+
# FRESH lock — both would then "hold" it.
|
|
48
|
+
return false unless retire_stale_lock
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
# Atomic lock creation: File::EXCL ensures this fails if file already exists
|
|
@@ -58,10 +60,37 @@ module Woods
|
|
|
58
60
|
|
|
59
61
|
# Release the lock.
|
|
60
62
|
#
|
|
63
|
+
# Deletes the lock file only if it still carries this instance's
|
|
64
|
+
# token — a run that outlived stale_timeout may have been
|
|
65
|
+
# legitimately taken over, and deleting unconditionally would drop
|
|
66
|
+
# the new holder's lock.
|
|
67
|
+
#
|
|
61
68
|
# @return [void]
|
|
62
69
|
def release
|
|
63
|
-
|
|
70
|
+
return unless @held
|
|
71
|
+
|
|
72
|
+
# Clear @held up front so no later failure can leave this instance
|
|
73
|
+
# believing it still holds the lock.
|
|
64
74
|
@held = false
|
|
75
|
+
|
|
76
|
+
# Rename first, then inspect: a plain read-then-unlink is a TOCTOU —
|
|
77
|
+
# after we read our own token a takeover could replace the file, and
|
|
78
|
+
# our unlink would then delete the NEW holder's lock. Renaming
|
|
79
|
+
# atomically captures whatever is at the path.
|
|
80
|
+
graveyard = "#{@lock_path}.release.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
81
|
+
begin
|
|
82
|
+
File.rename(@lock_path, graveyard)
|
|
83
|
+
rescue Errno::ENOENT
|
|
84
|
+
return # already gone
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if own_lock?(graveyard)
|
|
88
|
+
FileUtils.rm_f(graveyard)
|
|
89
|
+
else
|
|
90
|
+
# We were legitimately taken over — put the successor's lock back
|
|
91
|
+
# without clobbering a still-newer holder (see {#restore_lock}).
|
|
92
|
+
restore_lock(graveyard)
|
|
93
|
+
end
|
|
65
94
|
end
|
|
66
95
|
|
|
67
96
|
# Execute a block while holding the lock.
|
|
@@ -100,9 +129,85 @@ module Woods
|
|
|
100
129
|
true
|
|
101
130
|
end
|
|
102
131
|
|
|
103
|
-
#
|
|
132
|
+
# Atomically retire a stale lock file via rename. Rename is atomic on
|
|
133
|
+
# POSIX: of any processes racing to take over the same stale lock,
|
|
134
|
+
# exactly one rename succeeds; the losers get ENOENT and back off.
|
|
135
|
+
# Winning the rename does NOT guarantee winning the lock — another
|
|
136
|
+
# process may O_EXCL-create between our rename and our create, which
|
|
137
|
+
# the caller's EEXIST rescue handles.
|
|
138
|
+
#
|
|
139
|
+
# Rename alone is not enough, though: a competitor that already passed
|
|
140
|
+
# `stale?` on the SAME original file may have retired it and created a
|
|
141
|
+
# FRESH lock before we run. Our rename would then move that fresh lock
|
|
142
|
+
# aside — and both processes would "hold" the lock. So after winning
|
|
143
|
+
# the rename we re-check the retired file's age; if it turns out to be
|
|
144
|
+
# fresh (someone beat us to the takeover), we put it back and lose the
|
|
145
|
+
# race instead of clobbering a live holder.
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean] true if this process retired a genuinely stale lock
|
|
148
|
+
def retire_stale_lock
|
|
149
|
+
graveyard = "#{@lock_path}.stale.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
150
|
+
File.rename(@lock_path, graveyard)
|
|
151
|
+
|
|
152
|
+
unless stale_file?(graveyard)
|
|
153
|
+
# We grabbed a lock that is no longer stale — a competitor already
|
|
154
|
+
# took over. Restore it (without clobbering a still-newer holder)
|
|
155
|
+
# and back off.
|
|
156
|
+
restore_lock(graveyard)
|
|
157
|
+
return false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
FileUtils.rm_f(graveyard)
|
|
161
|
+
true
|
|
162
|
+
rescue Errno::ENOENT
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whether the lock file at +path+ carries this instance's token.
|
|
167
|
+
#
|
|
168
|
+
# @param path [String]
|
|
169
|
+
# @return [Boolean] true when the token matches, or the file is corrupt
|
|
170
|
+
# (an unparseable lock we already renamed aside is treated as ours to
|
|
171
|
+
# discard rather than restore).
|
|
172
|
+
def own_lock?(path)
|
|
173
|
+
JSON.parse(File.read(path))['token'] == @token
|
|
174
|
+
rescue JSON::ParserError
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Put a lock file we renamed aside back at @lock_path WITHOUT clobbering
|
|
179
|
+
# a lock another process may have O_EXCL-created in the meantime.
|
|
180
|
+
# `File.link` is atomic and fails with EEXIST if @lock_path already
|
|
181
|
+
# exists, so a newer holder always wins; the aside copy is discarded
|
|
182
|
+
# either way. A plain `File.rename` back would overwrite that newer
|
|
183
|
+
# holder's lock — reintroducing a double-hold.
|
|
184
|
+
#
|
|
185
|
+
# @param graveyard [String] path of the renamed-aside lock file
|
|
186
|
+
# @return [void]
|
|
187
|
+
def restore_lock(graveyard)
|
|
188
|
+
File.link(graveyard, @lock_path)
|
|
189
|
+
rescue Errno::EEXIST
|
|
190
|
+
# A newer holder already claimed the path — our copy is obsolete.
|
|
191
|
+
nil
|
|
192
|
+
ensure
|
|
193
|
+
FileUtils.rm_f(graveyard)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Whether the file at +path+ is older than the stale timeout.
|
|
197
|
+
#
|
|
198
|
+
# @param path [String]
|
|
199
|
+
# @return [Boolean]
|
|
200
|
+
def stale_file?(path)
|
|
201
|
+
Time.now - File.mtime(path) > @stale_timeout
|
|
202
|
+
rescue Errno::ENOENT
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [String] Lock file content (JSON with PID, timestamp, and
|
|
207
|
+
# an ownership token release verifies before deleting)
|
|
104
208
|
def lock_content
|
|
105
|
-
|
|
209
|
+
@token = SecureRandom.hex(8)
|
|
210
|
+
JSON.generate(pid: Process.pid, locked_at: Time.now.iso8601, name: @name, token: @token)
|
|
106
211
|
end
|
|
107
212
|
end
|
|
108
213
|
end
|
|
@@ -31,12 +31,20 @@ module Woods
|
|
|
31
31
|
@to_h = nil
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# Register a unit in the graph
|
|
34
|
+
# Register a unit in the graph.
|
|
35
|
+
#
|
|
36
|
+
# Re-registering an identifier (incremental extraction registers into a
|
|
37
|
+
# graph loaded from disk) first removes the previous registration's
|
|
38
|
+
# reverse edges, file-map entry, and type-index entry — otherwise stale
|
|
39
|
+
# dependents accumulate across incremental runs and get persisted back
|
|
40
|
+
# to dependency_graph.json.
|
|
35
41
|
#
|
|
36
42
|
# @param unit [ExtractedUnit] The unit to register
|
|
37
43
|
def register(unit)
|
|
38
44
|
@to_h = nil
|
|
39
45
|
|
|
46
|
+
unregister(unit.identifier) if @nodes.key?(unit.identifier)
|
|
47
|
+
|
|
40
48
|
@nodes[unit.identifier] = {
|
|
41
49
|
type: unit.type,
|
|
42
50
|
file_path: unit.file_path,
|
|
@@ -56,6 +64,35 @@ module Woods
|
|
|
56
64
|
end
|
|
57
65
|
end
|
|
58
66
|
|
|
67
|
+
# Remove an identifier's registration side effects: its contribution to
|
|
68
|
+
# the reverse indexes (derived from its recorded forward edges), its
|
|
69
|
+
# file-map entry, and its type-index entry. Forward node/edge data is
|
|
70
|
+
# overwritten by the caller (register), so it is not cleared here.
|
|
71
|
+
#
|
|
72
|
+
# @param identifier [String] Previously-registered unit identifier
|
|
73
|
+
# @return [void]
|
|
74
|
+
def unregister(identifier)
|
|
75
|
+
(@edges[identifier] || []).each do |edge|
|
|
76
|
+
if (set = @reverse[edge[:target]])
|
|
77
|
+
set.delete(identifier)
|
|
78
|
+
@reverse.delete(edge[:target]) if set.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
via_key = [edge[:target], edge[:via]]
|
|
82
|
+
next unless (set = @reverse_via[via_key])
|
|
83
|
+
|
|
84
|
+
set.delete(identifier)
|
|
85
|
+
@reverse_via.delete(via_key) if set.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
old_node = @nodes[identifier]
|
|
89
|
+
return unless old_node
|
|
90
|
+
|
|
91
|
+
old_path = old_node[:file_path]
|
|
92
|
+
@file_map.delete(old_path) if old_path && @file_map[old_path] == identifier
|
|
93
|
+
@type_index[old_node[:type]]&.delete(identifier)
|
|
94
|
+
end
|
|
95
|
+
|
|
59
96
|
# Find all units affected by changes to given files
|
|
60
97
|
# Uses BFS to find transitive dependents
|
|
61
98
|
#
|
|
@@ -95,7 +95,12 @@ module Woods
|
|
|
95
95
|
def append_dependency_line(lines, dependencies)
|
|
96
96
|
return unless dependencies&.any?
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
# Dependency hashes arrive symbol-keyed from the extractor's
|
|
99
|
+
# in-memory units but string-keyed from the indexer (Indexer#build_unit
|
|
100
|
+
# reads JSON and does not symbolize dependency keys, unlike chunks).
|
|
101
|
+
# Read both forms or the whole "dependencies:" prefix silently
|
|
102
|
+
# vanishes from every embedded document on the indexing path.
|
|
103
|
+
dep_names = dependencies.filter_map { |d| d[:target] || d['target'] }.first(10)
|
|
99
104
|
lines << "dependencies: #{dep_names.join(', ')}" if dep_names.any?
|
|
100
105
|
end
|
|
101
106
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
# Shared building blocks for the export formatters (Unblocked DocumentBuilder,
|
|
5
|
+
# Obsidian NoteBuilder).
|
|
6
|
+
module Export
|
|
7
|
+
# Format-agnostic extraction of facts from a unit's metadata.
|
|
8
|
+
#
|
|
9
|
+
# Both the Unblocked and Obsidian exporters need the same *facts* about a
|
|
10
|
+
# unit — its associations grouped by macro, its schema highlights — but
|
|
11
|
+
# render them differently (plain text + GitHub URIs vs. wikilinks +
|
|
12
|
+
# frontmatter). This value object owns the one definition of "what a unit's
|
|
13
|
+
# metadata says"; each exporter is a thin formatter over it. The returned
|
|
14
|
+
# structures are deliberately raw (no truncation, no ordering imposed beyond
|
|
15
|
+
# the source array): formatting decisions — sort order, top-N caps — belong
|
|
16
|
+
# to each formatter so neither exporter's output is constrained by the other.
|
|
17
|
+
class UnitFacts
|
|
18
|
+
# @param unit [Hash] parsed unit JSON
|
|
19
|
+
def initialize(unit)
|
|
20
|
+
@meta = (unit && unit['metadata']) || {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Associations grouped by macro, each as a structured entry. Group keys are
|
|
24
|
+
# whatever macros are present (insertion order from the source array); the
|
|
25
|
+
# formatter picks the display order.
|
|
26
|
+
#
|
|
27
|
+
# @return [Hash{String=>Array<Hash>}] macro => [{ target:, dependent: }]
|
|
28
|
+
def associations_by_type
|
|
29
|
+
associations.group_by { |assoc| assoc['type'] }.transform_values do |items|
|
|
30
|
+
items.map { |a| { target: a['target'] || a['name'], dependent: a.dig('options', 'dependent') } }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Integer] total association count across all macros
|
|
35
|
+
def association_count
|
|
36
|
+
associations.size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Hash] { enums:, scopes:, concerns:, callbacks: } — raw, untruncated
|
|
40
|
+
def schema_highlights
|
|
41
|
+
{ enums: enums, scopes: scopes, concerns: concerns, callbacks: callbacks }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enums
|
|
45
|
+
@meta['enums'].is_a?(Hash) ? @meta['enums'] : {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def scopes
|
|
49
|
+
@meta['scopes'].is_a?(Array) ? @meta['scopes'].filter_map { |s| s['name'] } : []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def concerns
|
|
53
|
+
@meta['inlined_concerns'].is_a?(Array) ? @meta['inlined_concerns'] : []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def callbacks
|
|
57
|
+
return [] unless @meta['callbacks'].is_a?(Array)
|
|
58
|
+
|
|
59
|
+
@meta['callbacks'].map { |cb| { type: cb['type'], filter: cb['filter'] } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Blast-radius bucket for a dependent count.
|
|
63
|
+
#
|
|
64
|
+
# @param dependent_count [Integer]
|
|
65
|
+
# @return [Symbol] :high (>50), :moderate (>20), or :none
|
|
66
|
+
def self.blast_radius(dependent_count)
|
|
67
|
+
return :high if dependent_count > 50
|
|
68
|
+
return :moderate if dependent_count > 20
|
|
69
|
+
|
|
70
|
+
:none
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def associations
|
|
76
|
+
@meta['associations'].is_a?(Array) ? @meta['associations'] : []
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/woods/extractor.rb
CHANGED
|
@@ -8,6 +8,7 @@ require 'pathname'
|
|
|
8
8
|
require 'set'
|
|
9
9
|
|
|
10
10
|
require_relative 'filename_utils'
|
|
11
|
+
require_relative 'token_utils'
|
|
11
12
|
require_relative 'extracted_unit'
|
|
12
13
|
require_relative 'dependency_graph'
|
|
13
14
|
require_relative 'git_provenance'
|
|
@@ -249,12 +250,6 @@ module Woods
|
|
|
249
250
|
Rails.logger.info '[Woods] Analyzing dependency graph...'
|
|
250
251
|
@graph_analysis = GraphAnalyzer.new(@dependency_graph).analyze
|
|
251
252
|
|
|
252
|
-
# Phase 3.5: Precompute request flows (opt-in)
|
|
253
|
-
if Woods.configuration.precompute_flows
|
|
254
|
-
Rails.logger.info '[Woods] Precomputing request flows...'
|
|
255
|
-
precompute_flows
|
|
256
|
-
end
|
|
257
|
-
|
|
258
253
|
# Phase 4: Enrich with git data
|
|
259
254
|
Rails.logger.info '[Woods] Enriching with git data...'
|
|
260
255
|
enrich_with_git_data
|
|
@@ -266,6 +261,17 @@ module Woods
|
|
|
266
261
|
# Phase 5: Write output
|
|
267
262
|
Rails.logger.info '[Woods] Writing output...'
|
|
268
263
|
write_results
|
|
264
|
+
|
|
265
|
+
# Phase 5.5: Precompute request flows (opt-in). Must run AFTER
|
|
266
|
+
# write_results — FlowAssembler loads unit JSON from disk, so running
|
|
267
|
+
# earlier assembled every flow from absent (fresh output dir) or
|
|
268
|
+
# stale (previous run's) data. precompute_flows re-writes the
|
|
269
|
+
# controller units it annotates with metadata[:flow_paths].
|
|
270
|
+
if Woods.configuration.precompute_flows
|
|
271
|
+
Rails.logger.info '[Woods] Precomputing request flows...'
|
|
272
|
+
precompute_flows
|
|
273
|
+
end
|
|
274
|
+
|
|
269
275
|
write_dependency_graph
|
|
270
276
|
write_graph_analysis
|
|
271
277
|
write_manifest
|
|
@@ -316,11 +322,17 @@ module Woods
|
|
|
316
322
|
regenerate_type_index(type_key)
|
|
317
323
|
end
|
|
318
324
|
|
|
319
|
-
# Update graph, manifest, and summary
|
|
325
|
+
# Update graph, manifest, and summary. No capture_snapshot here:
|
|
326
|
+
# snapshots must hash the FULL unit set, and incremental runs only
|
|
327
|
+
# re-extract affected units (@results stays empty) — capturing would
|
|
328
|
+
# record a snapshot whose diff reports every unit as deleted.
|
|
329
|
+
# Snapshots are captured on full extraction only.
|
|
320
330
|
write_dependency_graph
|
|
321
|
-
write_manifest
|
|
331
|
+
write_manifest(incremental: true)
|
|
322
332
|
write_structural_summary
|
|
323
|
-
|
|
333
|
+
if Woods.configuration.enable_snapshots
|
|
334
|
+
Rails.logger.info '[Woods] Skipping snapshot capture — snapshots are captured on full extraction only'
|
|
335
|
+
end
|
|
324
336
|
|
|
325
337
|
affected_ids
|
|
326
338
|
end
|
|
@@ -506,11 +518,42 @@ module Woods
|
|
|
506
518
|
all_units = @results.values.flatten(1)
|
|
507
519
|
precomputer = FlowPrecomputer.new(units: all_units, graph: @dependency_graph, output_dir: @output_dir.to_s)
|
|
508
520
|
flow_map = precomputer.precompute
|
|
521
|
+
rewrite_flow_annotated_units
|
|
509
522
|
Rails.logger.info "[Woods] Precomputed #{flow_map.size} request flows"
|
|
510
523
|
rescue StandardError => e
|
|
511
524
|
Rails.logger.error "[Woods] Flow precomputation failed: #{e.message}"
|
|
512
525
|
end
|
|
513
526
|
|
|
527
|
+
# Precompute runs after write_results (FlowAssembler reads unit JSON
|
|
528
|
+
# from disk), so units annotated in memory with metadata[:flow_paths]
|
|
529
|
+
# must be re-written and their type index refreshed to pick up the
|
|
530
|
+
# annotation.
|
|
531
|
+
#
|
|
532
|
+
# The index is rebuilt from the in-memory `units` (the authoritative
|
|
533
|
+
# full-extraction `@results`), NOT from a disk glob: this is a full
|
|
534
|
+
# extraction, the output dir is never wiped, and globbing would
|
|
535
|
+
# resurrect stale unit files for app classes deleted since the last run.
|
|
536
|
+
# (The incremental path, which only holds changed units in memory, still
|
|
537
|
+
# rebuilds from disk via {#regenerate_type_index}.)
|
|
538
|
+
def rewrite_flow_annotated_units
|
|
539
|
+
@results.each do |type, units|
|
|
540
|
+
annotated = units.select { |u| u.metadata[:flow_paths] }
|
|
541
|
+
next if annotated.empty?
|
|
542
|
+
|
|
543
|
+
type_dir = @output_dir.join(type.to_s)
|
|
544
|
+
annotated.each do |unit|
|
|
545
|
+
File.write(
|
|
546
|
+
type_dir.join(collision_safe_filename(unit.identifier)),
|
|
547
|
+
json_serialize(unit.to_h)
|
|
548
|
+
)
|
|
549
|
+
end
|
|
550
|
+
File.write(
|
|
551
|
+
type_dir.join('_index.json'),
|
|
552
|
+
json_serialize(type_index_entries(units))
|
|
553
|
+
)
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
514
557
|
# ──────────────────────────────────────────────────────────────────────
|
|
515
558
|
# Git Enrichment
|
|
516
559
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -702,23 +745,32 @@ module Woods
|
|
|
702
745
|
end
|
|
703
746
|
|
|
704
747
|
# Also write a type index for fast lookups
|
|
705
|
-
index = units.map do |u|
|
|
706
|
-
{
|
|
707
|
-
identifier: u.identifier,
|
|
708
|
-
file_path: u.file_path,
|
|
709
|
-
namespace: u.namespace,
|
|
710
|
-
estimated_tokens: u.estimated_tokens,
|
|
711
|
-
chunk_count: u.chunks.size
|
|
712
|
-
}
|
|
713
|
-
end
|
|
714
|
-
|
|
715
748
|
File.write(
|
|
716
749
|
type_dir.join('_index.json'),
|
|
717
|
-
json_serialize(
|
|
750
|
+
json_serialize(type_index_entries(units))
|
|
718
751
|
)
|
|
719
752
|
end
|
|
720
753
|
end
|
|
721
754
|
|
|
755
|
+
# Build the `_index.json` entry list for a set of in-memory units.
|
|
756
|
+
# Shared by {#write_results} and {#rewrite_flow_annotated_units} so both
|
|
757
|
+
# emit the index from the authoritative in-memory `@results` rather than
|
|
758
|
+
# re-deriving it from disk.
|
|
759
|
+
#
|
|
760
|
+
# @param units [Array<ExtractedUnit>]
|
|
761
|
+
# @return [Array<Hash>]
|
|
762
|
+
def type_index_entries(units)
|
|
763
|
+
units.map do |u|
|
|
764
|
+
{
|
|
765
|
+
identifier: u.identifier,
|
|
766
|
+
file_path: u.file_path,
|
|
767
|
+
namespace: u.namespace,
|
|
768
|
+
estimated_tokens: u.estimated_tokens,
|
|
769
|
+
chunk_count: u.chunks.size
|
|
770
|
+
}
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
722
774
|
def write_dependency_graph
|
|
723
775
|
graph_data = @dependency_graph.to_h
|
|
724
776
|
graph_data[:pagerank] = @dependency_graph.pagerank
|
|
@@ -745,24 +797,35 @@ module Woods
|
|
|
745
797
|
)
|
|
746
798
|
end
|
|
747
799
|
|
|
748
|
-
def write_manifest
|
|
800
|
+
def write_manifest(incremental: false)
|
|
749
801
|
# Worktree-aware git provenance. In a linked worktree +.git+ is a file
|
|
750
802
|
# pointing at the real git dir; when that dir is unreachable (e.g. an
|
|
751
803
|
# unmounted host path inside a container) this resolves to "unknown"
|
|
752
804
|
# rather than a stale GIT_BRANCH/GIT_SHA build arg. See GitProvenance (#137).
|
|
753
805
|
provenance = GitProvenance.new(root: Rails.root).to_h
|
|
754
806
|
|
|
807
|
+
# Incremental runs never populate @results — deriving counts from
|
|
808
|
+
# memory would clobber a good manifest with zeros. Recompute from the
|
|
809
|
+
# persisted per-type _index.json files instead.
|
|
810
|
+
counts, total_chunks =
|
|
811
|
+
if incremental
|
|
812
|
+
persisted_counts
|
|
813
|
+
else
|
|
814
|
+
[@results.transform_values(&:size),
|
|
815
|
+
@results.sum { |_, units| units.sum { |u| u.chunks.size } }]
|
|
816
|
+
end
|
|
817
|
+
|
|
755
818
|
manifest = {
|
|
756
819
|
extracted_at: Time.current.iso8601,
|
|
757
820
|
rails_version: Rails.version,
|
|
758
821
|
ruby_version: RUBY_VERSION,
|
|
759
822
|
|
|
760
823
|
# Counts by type
|
|
761
|
-
counts:
|
|
824
|
+
counts: counts,
|
|
762
825
|
|
|
763
826
|
# Total stats
|
|
764
|
-
total_units:
|
|
765
|
-
total_chunks:
|
|
827
|
+
total_units: counts.values.sum,
|
|
828
|
+
total_chunks: total_chunks,
|
|
766
829
|
|
|
767
830
|
# Git provenance (branch/sha), or "unknown" when unresolvable
|
|
768
831
|
git_sha: provenance[:git_sha],
|
|
@@ -779,6 +842,30 @@ module Woods
|
|
|
779
842
|
)
|
|
780
843
|
end
|
|
781
844
|
|
|
845
|
+
# Unit and chunk counts derived from the per-type _index.json files on
|
|
846
|
+
# disk — the source of truth after an incremental run, where only the
|
|
847
|
+
# affected units were re-extracted.
|
|
848
|
+
#
|
|
849
|
+
# @return [Array(Hash{Symbol => Integer}, Integer)] counts by type, total chunk count
|
|
850
|
+
def persisted_counts
|
|
851
|
+
counts = {}
|
|
852
|
+
chunks = 0
|
|
853
|
+
|
|
854
|
+
Dir[@output_dir.join('*/_index.json').to_s].each do |index_path|
|
|
855
|
+
entries = JSON.parse(File.read(index_path))
|
|
856
|
+
counts[File.basename(File.dirname(index_path)).to_sym] = entries.size
|
|
857
|
+
chunks += entries.sum { |e| e['chunk_count'].to_i }
|
|
858
|
+
rescue JSON::ParserError => e
|
|
859
|
+
# An unreadable index silently drops that whole type from the manifest
|
|
860
|
+
# counts — warn rather than undercount without a trace.
|
|
861
|
+
type = File.basename(File.dirname(index_path))
|
|
862
|
+
Rails.logger.warn("[Woods] Skipping unreadable #{type}/_index.json in manifest counts: #{e.message}")
|
|
863
|
+
next
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
[counts, chunks]
|
|
867
|
+
end
|
|
868
|
+
|
|
782
869
|
# Capture a temporal snapshot after extraction completes.
|
|
783
870
|
#
|
|
784
871
|
# Reads the manifest and computes per-unit content hashes, then delegates
|
|
@@ -915,7 +1002,10 @@ module Woods
|
|
|
915
1002
|
identifier: data['identifier'],
|
|
916
1003
|
file_path: data['file_path'],
|
|
917
1004
|
namespace: data['namespace'],
|
|
918
|
-
estimated_tokens
|
|
1005
|
+
# Unit JSON has no estimated_tokens field (ExtractedUnit#to_h
|
|
1006
|
+
# doesn't emit one) — recompute it, or every unit of a type
|
|
1007
|
+
# touched by an incremental run would index as null.
|
|
1008
|
+
estimated_tokens: estimated_tokens_from(data),
|
|
919
1009
|
chunk_count: (data['chunks'] || []).size
|
|
920
1010
|
}
|
|
921
1011
|
end
|
|
@@ -926,6 +1016,20 @@ module Woods
|
|
|
926
1016
|
)
|
|
927
1017
|
end
|
|
928
1018
|
|
|
1019
|
+
# Token estimate for a unit parsed back from JSON, mirroring
|
|
1020
|
+
# ExtractedUnit#estimated_tokens (see docs/TOKEN_BENCHMARK.md).
|
|
1021
|
+
#
|
|
1022
|
+
# @param data [Hash] Parsed unit JSON (string keys)
|
|
1023
|
+
# @return [Integer]
|
|
1024
|
+
def estimated_tokens_from(data)
|
|
1025
|
+
source = data['source_code']
|
|
1026
|
+
metadata = data['metadata'] || {}
|
|
1027
|
+
|
|
1028
|
+
source_tokens = source ? TokenUtils.estimate_tokens(source) : 0
|
|
1029
|
+
metadata_tokens = metadata.any? ? TokenUtils.estimate_tokens(metadata.to_json) : 0
|
|
1030
|
+
source_tokens + metadata_tokens
|
|
1031
|
+
end
|
|
1032
|
+
|
|
929
1033
|
# ──────────────────────────────────────────────────────────────────────
|
|
930
1034
|
# Helpers
|
|
931
1035
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -1023,12 +1127,20 @@ module Woods
|
|
|
1023
1127
|
|
|
1024
1128
|
return unless unit
|
|
1025
1129
|
|
|
1026
|
-
# Update dependency graph
|
|
1130
|
+
# Update dependency graph. Register BEFORE normalizing the path —
|
|
1131
|
+
# the graph's file_map stores absolute paths (affected_by matches
|
|
1132
|
+
# changed files against them), exactly as full extraction registers
|
|
1133
|
+
# in Phase 1 and only normalizes in Phase 4.5.
|
|
1027
1134
|
@dependency_graph.register(unit)
|
|
1028
1135
|
|
|
1029
1136
|
# Track which type was affected
|
|
1030
1137
|
affected_types&.add(extractor_key)
|
|
1031
1138
|
|
|
1139
|
+
# Unit JSON carries Rails.root-relative paths (full extraction's
|
|
1140
|
+
# Phase 4.5); writing the raw absolute source_location here would
|
|
1141
|
+
# leak container-absolute paths into the index after incremental runs.
|
|
1142
|
+
unit.file_path = normalize_file_path(unit.file_path)
|
|
1143
|
+
|
|
1032
1144
|
# Write updated unit
|
|
1033
1145
|
type_dir = @output_dir.join(extractor_key.to_s)
|
|
1034
1146
|
|
|
@@ -48,10 +48,8 @@ module Woods
|
|
|
48
48
|
#
|
|
49
49
|
# @return [Array<ExtractedUnit>] List of concern units
|
|
50
50
|
def extract_all
|
|
51
|
-
@directories.
|
|
52
|
-
|
|
53
|
-
extract_concern_file(file)
|
|
54
|
-
end
|
|
51
|
+
find_files_in_directories(@directories).filter_map do |file|
|
|
52
|
+
extract_concern_file(file)
|
|
55
53
|
end
|
|
56
54
|
end
|
|
57
55
|
|
|
@@ -285,7 +283,7 @@ module Woods
|
|
|
285
283
|
deps.concat(scan_service_dependencies(source))
|
|
286
284
|
deps.concat(scan_job_dependencies(source))
|
|
287
285
|
|
|
288
|
-
deps
|
|
286
|
+
consolidate_dependencies(deps)
|
|
289
287
|
end
|
|
290
288
|
end
|
|
291
289
|
end
|
|
@@ -36,10 +36,8 @@ module Woods
|
|
|
36
36
|
#
|
|
37
37
|
# @return [Array<ExtractedUnit>] List of configuration units
|
|
38
38
|
def extract_all
|
|
39
|
-
units = @directories.
|
|
40
|
-
|
|
41
|
-
extract_configuration_file(file)
|
|
42
|
-
end
|
|
39
|
+
units = find_files_in_directories(@directories).filter_map do |file|
|
|
40
|
+
extract_configuration_file(file)
|
|
43
41
|
end
|
|
44
42
|
|
|
45
43
|
profile = BehavioralProfile.new.extract
|
|
@@ -212,7 +210,7 @@ module Woods
|
|
|
212
210
|
|
|
213
211
|
deps.concat(scan_service_dependencies(source))
|
|
214
212
|
|
|
215
|
-
deps
|
|
213
|
+
consolidate_dependencies(deps)
|
|
216
214
|
end
|
|
217
215
|
end
|
|
218
216
|
end
|
|
@@ -320,7 +320,7 @@ module Woods
|
|
|
320
320
|
deps.concat(scan_navigation_dependencies(source, via_type: :redirect_to))
|
|
321
321
|
end
|
|
322
322
|
|
|
323
|
-
deps
|
|
323
|
+
consolidate_dependencies(deps)
|
|
324
324
|
end
|
|
325
325
|
|
|
326
326
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -49,10 +49,8 @@ module Woods
|
|
|
49
49
|
#
|
|
50
50
|
# @return [Array<ExtractedUnit>] List of decorator units
|
|
51
51
|
def extract_all
|
|
52
|
-
@directories.
|
|
53
|
-
|
|
54
|
-
extract_decorator_file(file)
|
|
55
|
-
end
|
|
52
|
+
find_files_in_directories(@directories).filter_map do |file|
|
|
53
|
+
extract_decorator_file(file)
|
|
56
54
|
end
|
|
57
55
|
end
|
|
58
56
|
|
|
@@ -246,7 +244,7 @@ module Woods
|
|
|
246
244
|
|
|
247
245
|
deps.concat(scan_common_dependencies(source))
|
|
248
246
|
|
|
249
|
-
deps
|
|
247
|
+
consolidate_dependencies(deps)
|
|
250
248
|
end
|
|
251
249
|
end
|
|
252
250
|
end
|
|
@@ -42,7 +42,7 @@ module Woods
|
|
|
42
42
|
def extract_all
|
|
43
43
|
event_map = {}
|
|
44
44
|
|
|
45
|
-
@directories
|
|
45
|
+
find_files_in_directories(@directories).each do |file_path|
|
|
46
46
|
scan_file(file_path, event_map)
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -204,7 +204,7 @@ module Woods
|
|
|
204
204
|
# @return [Array<Hash>]
|
|
205
205
|
def build_dependencies(combined_source)
|
|
206
206
|
deps = scan_common_dependencies(combined_source)
|
|
207
|
-
deps
|
|
207
|
+
consolidate_dependencies(deps)
|
|
208
208
|
end
|
|
209
209
|
end
|
|
210
210
|
end
|