woods 1.4.1 → 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 +208 -0
- data/CONTRIBUTING.md +63 -1
- data/README.md +35 -2
- 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 +152 -31
- 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/git_provenance.rb +121 -0
- data/lib/woods/index_artifact.rb +8 -4
- data/lib/woods/mcp/bootstrapper.rb +6 -5
- data/lib/woods/mcp/config_resolver.rb +42 -21
- data/lib/woods/mcp/errors.rb +5 -3
- 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/temporal/json_snapshot_store.rb +4 -1
- data/lib/woods/temporal/snapshot_store.rb +4 -1
- 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 +15 -4
|
@@ -34,9 +34,7 @@ module Woods
|
|
|
34
34
|
#
|
|
35
35
|
# @return [Array<ExtractedUnit>] List of rake task units
|
|
36
36
|
def extract_all
|
|
37
|
-
@directories.flat_map
|
|
38
|
-
Dir[dir.join('**/*.rake')].flat_map { |file| extract_rake_file(file) }
|
|
39
|
-
end
|
|
37
|
+
find_files_in_directories(@directories, '**/*.rake').flat_map { |file| extract_rake_file(file) }
|
|
40
38
|
end
|
|
41
39
|
|
|
42
40
|
# Extract rake tasks from a single .rake file.
|
|
@@ -336,7 +334,7 @@ module Woods
|
|
|
336
334
|
deps << { type: :rake_task, target: dep, via: :task_dependency }
|
|
337
335
|
end
|
|
338
336
|
|
|
339
|
-
deps
|
|
337
|
+
consolidate_dependencies(deps)
|
|
340
338
|
end
|
|
341
339
|
end
|
|
342
340
|
end
|
|
@@ -52,11 +52,9 @@ module Woods
|
|
|
52
52
|
units = []
|
|
53
53
|
|
|
54
54
|
# File-based discovery (catches everything in known directories)
|
|
55
|
-
@directories.each do |
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
units << unit if unit
|
|
59
|
-
end
|
|
55
|
+
find_files_in_directories(@directories).each do |file|
|
|
56
|
+
unit = extract_serializer_file(file)
|
|
57
|
+
units << unit if unit
|
|
60
58
|
end
|
|
61
59
|
|
|
62
60
|
# Class-based discovery for loaded gems
|
|
@@ -332,7 +330,7 @@ module Woods
|
|
|
332
330
|
|
|
333
331
|
deps.concat(scan_service_dependencies(source))
|
|
334
332
|
|
|
335
|
-
deps
|
|
333
|
+
consolidate_dependencies(deps)
|
|
336
334
|
end
|
|
337
335
|
end
|
|
338
336
|
end
|
|
@@ -44,10 +44,8 @@ module Woods
|
|
|
44
44
|
#
|
|
45
45
|
# @return [Array<ExtractedUnit>] List of service units
|
|
46
46
|
def extract_all
|
|
47
|
-
@directories.
|
|
48
|
-
|
|
49
|
-
extract_service_file(file)
|
|
50
|
-
end
|
|
47
|
+
find_files_in_directories(@directories).filter_map do |file|
|
|
48
|
+
extract_service_file(file)
|
|
51
49
|
end
|
|
52
50
|
end
|
|
53
51
|
|
|
@@ -210,7 +208,7 @@ module Woods
|
|
|
210
208
|
deps << { type: :infrastructure, target: :redis, via: :code_reference }
|
|
211
209
|
end
|
|
212
210
|
|
|
213
|
-
deps
|
|
211
|
+
consolidate_dependencies(deps)
|
|
214
212
|
end
|
|
215
213
|
end
|
|
216
214
|
end
|
|
@@ -23,7 +23,7 @@ module Woods
|
|
|
23
23
|
# def extract_dependencies(source)
|
|
24
24
|
# deps = scan_common_dependencies(source)
|
|
25
25
|
# deps << { type: :custom, target: "Bar", via: :special }
|
|
26
|
-
# deps
|
|
26
|
+
# consolidate_dependencies(deps)
|
|
27
27
|
# end
|
|
28
28
|
# end
|
|
29
29
|
#
|
|
@@ -45,22 +45,27 @@ module Woods
|
|
|
45
45
|
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
46
46
|
# @return [Array<Hash>] Dependency hashes with :type, :target, :via
|
|
47
47
|
def scan_model_dependencies(source, via: :code_reference)
|
|
48
|
+
# Strip `#` line comments before scanning so references inside
|
|
49
|
+
# YARD docstrings / TODO comments don't generate ghost edges.
|
|
50
|
+
# Applied to ALL passes — a commented `Library::Book` should not
|
|
51
|
+
# produce an edge through the full-name pass. Stripping is
|
|
52
|
+
# string-literal-aware: a `#` inside a `"..."`/`'...'` literal is
|
|
53
|
+
# NOT a comment, so a line like `link_to "Tag #ruby", Article.recent`
|
|
54
|
+
# keeps its `Article` reference (a plain `#...` regex would have
|
|
55
|
+
# eaten the rest of the line and dropped the edge). String
|
|
56
|
+
# interpolation (`"Book: #{Library::Book.new}"`) is preserved for the
|
|
57
|
+
# same reason — the `#{...}` lives inside the literal.
|
|
58
|
+
scannable = strip_ruby_line_comments(source)
|
|
59
|
+
|
|
48
60
|
targets = Set.new
|
|
49
|
-
|
|
50
|
-
extract_constantize_targets(
|
|
61
|
+
scannable.scan(ModelNameCache.model_names_regex).each { |m| targets << m }
|
|
62
|
+
extract_constantize_targets(scannable).each { |t| targets << t }
|
|
51
63
|
|
|
52
64
|
# Short-name + constantize resolution are additive passes guarded
|
|
53
65
|
# by `respond_to?` so partial test doubles that only stub
|
|
54
66
|
# `model_names_regex` still work. Real extraction runs always
|
|
55
67
|
# have the full API.
|
|
56
68
|
if ModelNameCache.respond_to?(:short_names_regex) && ModelNameCache.respond_to?(:resolve_short_name)
|
|
57
|
-
# Strip `#` line comments before scanning so references inside
|
|
58
|
-
# YARD docstrings / TODO comments don't generate ghost edges.
|
|
59
|
-
# The negative lookahead `(?!\{)` keeps Ruby's `#{...}` string
|
|
60
|
-
# interpolation intact — stripping blindly would eat every model
|
|
61
|
-
# reference inside `"Book: #{Library::Book.new}"` etc., which
|
|
62
|
-
# is a common ERB/Phlex/string pattern.
|
|
63
|
-
scannable = source.gsub(/#(?!\{)[^\n]*/, '')
|
|
64
69
|
scannable.scan(ModelNameCache.short_names_regex).each do |short|
|
|
65
70
|
resolved = ModelNameCache.resolve_short_name(short)
|
|
66
71
|
targets << resolved if resolved
|
|
@@ -70,6 +75,58 @@ module Woods
|
|
|
70
75
|
targets.map { |model_name| { type: :model, target: model_name, via: via } }
|
|
71
76
|
end
|
|
72
77
|
|
|
78
|
+
# Remove `#` line comments from Ruby source without touching `#`
|
|
79
|
+
# characters that sit inside single- or double-quoted string literals.
|
|
80
|
+
#
|
|
81
|
+
# A naive `gsub(/#.*/, '')` truncates lines like
|
|
82
|
+
# `redirect "/posts#comments"; Post.touch` at the in-string `#`,
|
|
83
|
+
# silently dropping the `Post` reference. This scanner walks each line
|
|
84
|
+
# tracking quote state so only a genuine (unquoted) `#` starts a
|
|
85
|
+
# comment. Escapes (`\"`, `\'`) inside literals are honored. Heredocs,
|
|
86
|
+
# `%`-literals, and character literals whose char is a quote (`?'`,
|
|
87
|
+
# `?"`) are not modeled — these are rare in the constant-bearing code
|
|
88
|
+
# this scans, and mis-reading one only risks a spurious edge (a comment
|
|
89
|
+
# left unstripped) or a missed edge, never a crash or a dropped-but-real
|
|
90
|
+
# reference outside those constructs.
|
|
91
|
+
#
|
|
92
|
+
# @param source [String] Ruby source code
|
|
93
|
+
# @return [String] source with unquoted `#` comments removed
|
|
94
|
+
def strip_ruby_line_comments(source)
|
|
95
|
+
source.each_line.map { |line| strip_line_comment(line) }.join
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Strip a trailing `#` comment from a single line, ignoring `#` inside
|
|
99
|
+
# string literals. Preserves the line's trailing newline.
|
|
100
|
+
#
|
|
101
|
+
# @param line [String]
|
|
102
|
+
# @return [String]
|
|
103
|
+
def strip_line_comment(line)
|
|
104
|
+
in_single = false
|
|
105
|
+
in_double = false
|
|
106
|
+
i = 0
|
|
107
|
+
len = line.length
|
|
108
|
+
while i < len
|
|
109
|
+
ch = line[i]
|
|
110
|
+
if (in_single || in_double) && ch == '\\'
|
|
111
|
+
i += 2 # skip escaped char inside a literal
|
|
112
|
+
next
|
|
113
|
+
elsif in_single
|
|
114
|
+
in_single = false if ch == "'"
|
|
115
|
+
elsif in_double
|
|
116
|
+
in_double = false if ch == '"'
|
|
117
|
+
elsif ch == "'"
|
|
118
|
+
in_single = true
|
|
119
|
+
elsif ch == '"'
|
|
120
|
+
in_double = true
|
|
121
|
+
elsif ch == '#'
|
|
122
|
+
trailing = line[i..].end_with?("\n") ? "\n" : ''
|
|
123
|
+
return line[0...i] + trailing
|
|
124
|
+
end
|
|
125
|
+
i += 1
|
|
126
|
+
end
|
|
127
|
+
line
|
|
128
|
+
end
|
|
129
|
+
|
|
73
130
|
# Extract string-literal arguments passed to `.constantize` or
|
|
74
131
|
# `const_get(...)`. Matches both `"Library::Book".constantize`
|
|
75
132
|
# and `Object.const_get("Library::Book")` / `const_get("...")`.
|
|
@@ -137,12 +194,27 @@ module Woods
|
|
|
137
194
|
# @param source [String] Ruby source code to scan
|
|
138
195
|
# @return [Array<Hash>] Deduplicated dependency hashes
|
|
139
196
|
def scan_common_dependencies(source)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
197
|
+
consolidate_dependencies(
|
|
198
|
+
scan_model_dependencies(source),
|
|
199
|
+
scan_service_dependencies(source),
|
|
200
|
+
scan_job_dependencies(source),
|
|
201
|
+
scan_mailer_dependencies(source)
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Merge dependency arrays and deduplicate by +[type, target]+.
|
|
206
|
+
#
|
|
207
|
+
# Centralizes the `deps.uniq { |d| [d[:type], d[:target]] }` chain
|
|
208
|
+
# duplicated at the end of most extractors' +extract_dependencies+
|
|
209
|
+
# methods. Arrays are flattened one level and nils removed; the first
|
|
210
|
+
# occurrence of each +[type, target]+ pair wins, so the first +:via+
|
|
211
|
+
# label recorded is preserved — identical to the inline chains this
|
|
212
|
+
# replaces.
|
|
213
|
+
#
|
|
214
|
+
# @param dependency_arrays [Array<Array<Hash>>] One or more dependency arrays
|
|
215
|
+
# @return [Array<Hash>] Flattened, nil-free, deduplicated dependency hashes
|
|
216
|
+
def consolidate_dependencies(*dependency_arrays)
|
|
217
|
+
dependency_arrays.flatten(1).compact.uniq { |d| [d[:type], d[:target]] }
|
|
146
218
|
end
|
|
147
219
|
|
|
148
220
|
# Match _path/_url route helpers anywhere in source.
|
|
@@ -20,6 +20,20 @@ module Woods
|
|
|
20
20
|
# end
|
|
21
21
|
#
|
|
22
22
|
module SharedUtilityMethods
|
|
23
|
+
# Glob files matching a pattern across a list of directories.
|
|
24
|
+
#
|
|
25
|
+
# Centralizes the `Dir[dir.join('**/*.rb')]` loop duplicated across
|
|
26
|
+
# the directory-scanning extractors. Directories are globbed in order
|
|
27
|
+
# and the per-directory results concatenated, matching the original
|
|
28
|
+
# per-directory iteration semantics.
|
|
29
|
+
#
|
|
30
|
+
# @param directories [Array<Pathname>] Directories to glob
|
|
31
|
+
# @param pattern [String] Glob pattern relative to each directory
|
|
32
|
+
# @return [Array<String>] Matching file paths
|
|
33
|
+
def find_files_in_directories(directories, pattern = '**/*.rb')
|
|
34
|
+
directories.flat_map { |dir| Dir[dir.join(pattern).to_s] }
|
|
35
|
+
end
|
|
36
|
+
|
|
23
37
|
# Check whether a path points to application source (under app_root, but
|
|
24
38
|
# not inside vendor/ or node_modules/ directories).
|
|
25
39
|
#
|
|
@@ -36,9 +36,7 @@ module Woods
|
|
|
36
36
|
#
|
|
37
37
|
# @return [Array<ExtractedUnit>] List of state machine units
|
|
38
38
|
def extract_all
|
|
39
|
-
@directories.flat_map
|
|
40
|
-
Dir[dir.join('**/*.rb')].flat_map { |file| extract_model_file(file) }
|
|
41
|
-
end
|
|
39
|
+
find_files_in_directories(@directories).flat_map { |file| extract_model_file(file) }
|
|
42
40
|
end
|
|
43
41
|
|
|
44
42
|
# Extract state machine definitions from a single model file.
|
|
@@ -391,7 +389,7 @@ module Woods
|
|
|
391
389
|
deps = [{ type: :model, target: class_name, via: :state_machine }]
|
|
392
390
|
deps.concat(scan_service_dependencies(source, via: :state_machine_callback))
|
|
393
391
|
deps.concat(scan_job_dependencies(source, via: :state_machine_callback))
|
|
394
|
-
deps
|
|
392
|
+
consolidate_dependencies(deps)
|
|
395
393
|
end
|
|
396
394
|
end
|
|
397
395
|
end
|
|
@@ -41,10 +41,8 @@ module Woods
|
|
|
41
41
|
#
|
|
42
42
|
# @return [Array<ExtractedUnit>] List of validator units
|
|
43
43
|
def extract_all
|
|
44
|
-
@directories.
|
|
45
|
-
|
|
46
|
-
extract_validator_file(file)
|
|
47
|
-
end
|
|
44
|
+
find_files_in_directories(@directories).filter_map do |file|
|
|
45
|
+
extract_validator_file(file)
|
|
48
46
|
end
|
|
49
47
|
end
|
|
50
48
|
|
|
@@ -204,7 +202,7 @@ module Woods
|
|
|
204
202
|
deps << { type: :validator, target: validator, via: :code_reference }
|
|
205
203
|
end
|
|
206
204
|
|
|
207
|
-
deps
|
|
205
|
+
consolidate_dependencies(deps)
|
|
208
206
|
end
|
|
209
207
|
end
|
|
210
208
|
end
|
data/lib/woods/filename_utils.rb
CHANGED
|
@@ -8,12 +8,41 @@ module Woods
|
|
|
8
8
|
# Used by Extractor (writing) and IndexValidator (reading) to ensure
|
|
9
9
|
# filename generation is consistent across both sides.
|
|
10
10
|
module FilenameUtils
|
|
11
|
+
# Normalize a single identifier segment for use in a filename: replace
|
|
12
|
+
# `::` with `__` and every other non-`[a-zA-Z0-9_-]` character with `_`.
|
|
13
|
+
# This is the one character transform shared by {#safe_filename},
|
|
14
|
+
# {#collision_safe_filename}, and the precomputed-flow reader/writer
|
|
15
|
+
# (via {.flow_filename}) so the sides of each filename contract cannot
|
|
16
|
+
# drift. Also usable as a module method (`FilenameUtils.safe_segment`)
|
|
17
|
+
# for callers that don't mix the module in.
|
|
18
|
+
#
|
|
19
|
+
# @param identifier [String]
|
|
20
|
+
# @return [String]
|
|
21
|
+
def self.safe_segment(identifier)
|
|
22
|
+
identifier.to_s.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Filename for a precomputed request-flow document, combining the
|
|
26
|
+
# controller identifier and action name — each normalized via
|
|
27
|
+
# {.safe_segment}. Used by both {Woods::FlowPrecomputer} (writing) and the
|
|
28
|
+
# MCP server's `trace_flow` (reading) so a name containing an
|
|
29
|
+
# out-of-charset character is written and looked up under the SAME file
|
|
30
|
+
# (the writer previously left the action raw while the reader allow-listed
|
|
31
|
+
# it, so such flows were written under one name and never found).
|
|
32
|
+
#
|
|
33
|
+
# @param controller_id [String] e.g. "Admin::UsersController"
|
|
34
|
+
# @param action [String] e.g. "index"
|
|
35
|
+
# @return [String] e.g. "Admin__UsersController_index.json"
|
|
36
|
+
def self.flow_filename(controller_id, action)
|
|
37
|
+
"#{safe_segment(controller_id)}_#{safe_segment(action)}.json"
|
|
38
|
+
end
|
|
39
|
+
|
|
11
40
|
# Convert an identifier to a safe filename (legacy format).
|
|
12
41
|
#
|
|
13
42
|
# @param identifier [String] The unit identifier (e.g., "Admin::UsersController")
|
|
14
43
|
# @return [String] A filesystem-safe filename (e.g., "Admin__UsersController.json")
|
|
15
44
|
def safe_filename(identifier)
|
|
16
|
-
"#{
|
|
45
|
+
"#{FilenameUtils.safe_segment(identifier)}.json"
|
|
17
46
|
end
|
|
18
47
|
|
|
19
48
|
# Convert an identifier to a collision-safe filename (current format).
|
|
@@ -24,7 +53,7 @@ module Woods
|
|
|
24
53
|
# @param identifier [String] The unit identifier
|
|
25
54
|
# @return [String] Collision-safe filename (e.g., "Admin__UsersController_a1b2c3d4.json")
|
|
26
55
|
def collision_safe_filename(identifier)
|
|
27
|
-
base =
|
|
56
|
+
base = FilenameUtils.safe_segment(identifier)
|
|
28
57
|
digest = Digest::SHA256.hexdigest(identifier)[0, 8]
|
|
29
58
|
"#{base}_#{digest}.json"
|
|
30
59
|
end
|
data/lib/woods/flow_assembler.rb
CHANGED
|
@@ -60,19 +60,26 @@ module Woods
|
|
|
60
60
|
|
|
61
61
|
# Recursively expand a unit into flow steps.
|
|
62
62
|
#
|
|
63
|
+
# A cycle is a re-encounter on the CURRENT recursion path (tracked in
|
|
64
|
+
# +path+, popped on exit — the gray set of a DFS). A unit already
|
|
65
|
+
# expanded via a sibling branch (a DAG diamond: two services calling
|
|
66
|
+
# the same model) is plain dedup, not a cycle — it is skipped silently
|
|
67
|
+
# rather than mislabeled with a cycle marker.
|
|
68
|
+
#
|
|
63
69
|
# @param identifier [String] Unit identifier (may include #method)
|
|
64
70
|
# @param steps [Array<Hash>] Accumulator for step hashes
|
|
65
|
-
# @param visited [Set<String>]
|
|
71
|
+
# @param visited [Set<String>] Already-expanded unit identifiers (dedup)
|
|
66
72
|
# @param depth [Integer] Current recursion depth
|
|
67
73
|
# @param max_depth [Integer] Maximum recursion depth
|
|
68
|
-
|
|
74
|
+
# @param path [Set<String>] Identifiers on the current recursion path
|
|
75
|
+
def expand(identifier, steps, visited, depth:, max_depth:, path: Set.new)
|
|
69
76
|
return if depth > max_depth
|
|
70
77
|
|
|
71
78
|
# Parse identifier into unit name and optional method
|
|
72
79
|
unit_id, method_name = parse_identifier(identifier)
|
|
73
80
|
|
|
74
|
-
if
|
|
75
|
-
#
|
|
81
|
+
if path.include?(unit_id)
|
|
82
|
+
# Genuine cycle — the unit is an ancestor of itself on this path.
|
|
76
83
|
steps << {
|
|
77
84
|
unit: unit_id,
|
|
78
85
|
type: 'cycle',
|
|
@@ -81,34 +88,44 @@ module Woods
|
|
|
81
88
|
return
|
|
82
89
|
end
|
|
83
90
|
|
|
91
|
+
# Already expanded through another branch — dedup, not a cycle.
|
|
92
|
+
return if visited.include?(unit_id)
|
|
93
|
+
|
|
84
94
|
visited.add(unit_id)
|
|
95
|
+
path.add(unit_id)
|
|
85
96
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
begin
|
|
98
|
+
# Load the unit data from disk
|
|
99
|
+
unit_data = load_unit(unit_id)
|
|
100
|
+
return unless unit_data
|
|
89
101
|
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
source_code = unit_data[:source_code]
|
|
103
|
+
return unless source_code && !source_code.empty?
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
metadata = unit_data[:metadata] || {}
|
|
106
|
+
unit_type = unit_data[:type]&.to_s
|
|
107
|
+
file_path = unit_data[:file_path]
|
|
96
108
|
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
# Extract operations from the relevant method
|
|
110
|
+
operations = extract_operations(source_code, method_name, metadata, unit_type)
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
step = {
|
|
113
|
+
unit: identifier,
|
|
114
|
+
type: unit_type,
|
|
115
|
+
file_path: file_path,
|
|
116
|
+
operations: operations
|
|
117
|
+
}
|
|
106
118
|
|
|
107
|
-
|
|
119
|
+
steps << step
|
|
108
120
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
# Recursively expand targets that resolve to known units
|
|
122
|
+
operations.each do |op|
|
|
123
|
+
expand_operation(op, identifier, steps, visited, depth: depth, max_depth: max_depth, path: path)
|
|
124
|
+
end
|
|
125
|
+
ensure
|
|
126
|
+
# Pop on every exit (including the early returns above) — a unit
|
|
127
|
+
# left on the path would make sibling branches report false cycles.
|
|
128
|
+
path.delete(unit_id)
|
|
112
129
|
end
|
|
113
130
|
end
|
|
114
131
|
|
|
@@ -178,7 +195,7 @@ module Woods
|
|
|
178
195
|
# @param visited [Set<String>] Visited unit identifiers for cycle detection
|
|
179
196
|
# @param depth [Integer] Current recursion depth
|
|
180
197
|
# @param max_depth [Integer] Maximum recursion depth
|
|
181
|
-
def expand_operation(op, current_unit, steps, visited, depth:, max_depth:)
|
|
198
|
+
def expand_operation(op, current_unit, steps, visited, depth:, max_depth:, path: Set.new)
|
|
182
199
|
case op[:type]
|
|
183
200
|
when :call, :async
|
|
184
201
|
target = op[:target]
|
|
@@ -187,14 +204,14 @@ module Woods
|
|
|
187
204
|
candidate = resolve_target(target)
|
|
188
205
|
return unless candidate
|
|
189
206
|
|
|
190
|
-
expand(candidate, steps, visited, depth: depth + 1, max_depth: max_depth)
|
|
207
|
+
expand(candidate, steps, visited, depth: depth + 1, max_depth: max_depth, path: path)
|
|
191
208
|
when :transaction
|
|
192
209
|
(op[:nested] || []).each do |nested_op|
|
|
193
|
-
expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth)
|
|
210
|
+
expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
|
|
194
211
|
end
|
|
195
212
|
when :conditional
|
|
196
213
|
((op[:then_ops] || []) + (op[:else_ops] || [])).each do |branch_op|
|
|
197
|
-
expand_operation(branch_op, current_unit, steps, visited, depth: depth, max_depth: max_depth)
|
|
214
|
+
expand_operation(branch_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
|
|
198
215
|
end
|
|
199
216
|
end
|
|
200
217
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require_relative 'filename_utils'
|
|
5
6
|
require_relative 'flow_assembler'
|
|
6
7
|
|
|
7
8
|
module Woods
|
|
@@ -80,7 +81,7 @@ module Woods
|
|
|
80
81
|
def assemble_and_write(assembler, entry_point, controller_id, action)
|
|
81
82
|
flow = assembler.assemble(entry_point, max_depth: @max_depth)
|
|
82
83
|
|
|
83
|
-
filename =
|
|
84
|
+
filename = Woods::FilenameUtils.flow_filename(controller_id, action)
|
|
84
85
|
flow_path = File.join(@flows_dir, filename)
|
|
85
86
|
|
|
86
87
|
File.write(flow_path, canonical_json(flow.to_h))
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
# Resolves git provenance (branch + commit SHA) for the extracted codebase,
|
|
7
|
+
# correctly handling linked git worktrees where +.git+ is a *file* containing a
|
|
8
|
+
# +gitdir:+ pointer rather than a directory.
|
|
9
|
+
#
|
|
10
|
+
# The reported failure (#137): in a worktree, +.git+ points at a real git
|
|
11
|
+
# directory that may be an absolute host path. When extraction runs somewhere
|
|
12
|
+
# that path cannot be resolved — e.g. inside a container where the host path is
|
|
13
|
+
# not mounted — plain +git rev-parse+ fails silently and the manifest used to
|
|
14
|
+
# fall back to a baked-in +GIT_BRANCH+/+GIT_SHA+ build arg, reporting a stale,
|
|
15
|
+
# misleading branch.
|
|
16
|
+
#
|
|
17
|
+
# Resolution precedence:
|
|
18
|
+
#
|
|
19
|
+
# 1. +git -C <root> rev-parse+ — independent of the process working directory,
|
|
20
|
+
# and resolves a worktree's +.git+-file pointer when the target git directory
|
|
21
|
+
# is reachable. This covers normal checkouts and reachable worktrees.
|
|
22
|
+
# 2. If that fails, {fallback} decides:
|
|
23
|
+
# - A +.git+ entry exists at the root but git couldn't resolve the ref (an
|
|
24
|
+
# unmounted worktree gitdir, a corrupted repo): emit {UNKNOWN}. A working
|
|
25
|
+
# tree whose ref can't be determined must not be papered over with a
|
|
26
|
+
# possibly-stale build arg — that is the #137 bug.
|
|
27
|
+
# - No +.git+ at the root at all (a source tarball, or a Docker +COPY+ that
|
|
28
|
+
# excludes +.git+), or no +git+ binary: the +GIT_BRANCH+/+GIT_SHA+ env vars
|
|
29
|
+
# are the only provenance signal, so honor them if set, else {UNKNOWN}.
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# provenance = Woods::GitProvenance.new(root: Rails.root)
|
|
33
|
+
# provenance.branch # => "main" (or "unknown")
|
|
34
|
+
# provenance.sha # => "a1b2c3..." (or "unknown")
|
|
35
|
+
class GitProvenance
|
|
36
|
+
# Sentinel emitted when provenance cannot be determined, in preference to a
|
|
37
|
+
# stale or misleading fallback value.
|
|
38
|
+
UNKNOWN = 'unknown'
|
|
39
|
+
|
|
40
|
+
# @param root [String, Pathname] repository root (typically +Rails.root+)
|
|
41
|
+
# @param env [Hash] environment source (default +ENV+; overridable in specs)
|
|
42
|
+
def initialize(root:, env: ENV)
|
|
43
|
+
@root = root.to_s
|
|
44
|
+
@env = env
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] current branch name, or {UNKNOWN}
|
|
48
|
+
def branch
|
|
49
|
+
from_git = rev_parse('--abbrev-ref', 'HEAD')
|
|
50
|
+
# 'HEAD' means detached — not a branch name.
|
|
51
|
+
return from_git if present?(from_git) && from_git != 'HEAD'
|
|
52
|
+
|
|
53
|
+
fallback('GIT_BRANCH')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [String] current commit SHA, or {UNKNOWN}
|
|
57
|
+
def sha
|
|
58
|
+
from_git = rev_parse('HEAD')
|
|
59
|
+
return from_git if present?(from_git)
|
|
60
|
+
|
|
61
|
+
fallback('GIT_SHA')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Hash{Symbol => String}] +{ git_branch:, git_sha: }+ for the manifest
|
|
65
|
+
def to_h
|
|
66
|
+
{ git_branch: branch, git_sha: sha }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def present?(value)
|
|
72
|
+
value && !value.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Run git rooted at +@root+ so the result is independent of the process
|
|
76
|
+
# working directory and resolves a worktree's +.git+-file pointer when the
|
|
77
|
+
# target git directory is reachable.
|
|
78
|
+
#
|
|
79
|
+
# @return [String] stripped output, or empty string on any failure
|
|
80
|
+
def rev_parse(*args)
|
|
81
|
+
out, status = Open3.capture2('git', '-C', @root, 'rev-parse', *args)
|
|
82
|
+
status.success? ? out.strip : ''
|
|
83
|
+
rescue StandardError
|
|
84
|
+
''
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Decide what to emit when git resolution produced nothing. A baked +GIT_*+
|
|
88
|
+
# env var is honored only when this checkout is not a git working tree (no
|
|
89
|
+
# +.git+ at the root) or git is unavailable. When git is present AND a +.git+
|
|
90
|
+
# exists but the ref couldn't be resolved (unmounted worktree gitdir), emit
|
|
91
|
+
# {UNKNOWN} rather than a possibly-stale value (#137).
|
|
92
|
+
def fallback(env_key)
|
|
93
|
+
return UNKNOWN if git_available? && git_working_tree?
|
|
94
|
+
|
|
95
|
+
value = @env[env_key]
|
|
96
|
+
present?(value) ? value : UNKNOWN
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# True when the +git+ command itself can run, independent of any repository.
|
|
100
|
+
# Memoized — probed at most once per instance even though both +branch+ and
|
|
101
|
+
# +sha+ may reach {fallback}.
|
|
102
|
+
def git_available?
|
|
103
|
+
return @git_available if defined?(@git_available)
|
|
104
|
+
|
|
105
|
+
@git_available = begin
|
|
106
|
+
_out, status = Open3.capture2('git', '--version')
|
|
107
|
+
status.success?
|
|
108
|
+
rescue StandardError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# True when +<root>/.git+ exists — a directory for a normal checkout, or a
|
|
114
|
+
# file for a linked worktree. Distinguishes "git couldn't resolve this
|
|
115
|
+
# working tree" (emit UNKNOWN) from "there is no working tree here" (the
|
|
116
|
+
# env-var build-arg path is legitimate).
|
|
117
|
+
def git_working_tree?
|
|
118
|
+
File.exist?(File.join(@root, '.git'))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/woods/index_artifact.rb
CHANGED
|
@@ -57,9 +57,9 @@ module Woods
|
|
|
57
57
|
# Returns true when the artifact has never been populated — +woods.json+
|
|
58
58
|
# is absent AND the +dumps/latest+ pointer does not exist.
|
|
59
59
|
#
|
|
60
|
-
# Once either file is present the artifact is considered non-fresh
|
|
61
|
-
# Bootstrapper
|
|
62
|
-
#
|
|
60
|
+
# Once either file is present the artifact is considered non-fresh and the
|
|
61
|
+
# Bootstrapper loads from it. A fresh artifact boots in pattern-only mode by
|
|
62
|
+
# default, raising {Woods::MCP::MissingArtifact} only under WOODS_REQUIRE_INDEX=1.
|
|
63
63
|
#
|
|
64
64
|
# @return [Boolean]
|
|
65
65
|
def fresh?
|
|
@@ -127,7 +127,11 @@ module Woods
|
|
|
127
127
|
target_real = target.exist? ? target.realpath.to_s : ''
|
|
128
128
|
# Resolve symlinks on both sides before comparing (handles macOS /tmp → /private/var)
|
|
129
129
|
root_resolved = Pathname.new(root_real).exist? ? Pathname.new(root_real).realpath.to_s : root_real
|
|
130
|
-
|
|
130
|
+
# Prefix must end at a path boundary — a bare start_with? would accept
|
|
131
|
+
# sibling directories like "dumps-backup" or "dumpsevil", and the
|
|
132
|
+
# basename-only pointer written below would then name a directory that
|
|
133
|
+
# doesn't exist under dumps_root.
|
|
134
|
+
unless target.exist? && target_real.start_with?("#{root_resolved}#{File::SEPARATOR}")
|
|
131
135
|
raise ArgumentError,
|
|
132
136
|
'dump_dir must exist inside dumps_root. ' \
|
|
133
137
|
"Got: #{dump_dir.inspect}, dumps_root: #{dumps_root}"
|
|
@@ -84,9 +84,10 @@ module Woods
|
|
|
84
84
|
#
|
|
85
85
|
# Flow:
|
|
86
86
|
# 1. Wrap output_dir in an IndexArtifact (owns path semantics).
|
|
87
|
-
# 2. If woods.json is present, resolve config from it; otherwise
|
|
88
|
-
#
|
|
89
|
-
#
|
|
87
|
+
# 2. If woods.json is present, resolve config from it; otherwise fall
|
|
88
|
+
# back to env-var auto-detect by default (pattern/structural mode when
|
|
89
|
+
# nothing is found). Set WOODS_REQUIRE_INDEX=1 to fail closed instead
|
|
90
|
+
# (raise MissingArtifact). See #138.
|
|
90
91
|
# 3. Build provider + stores from config (no mutation of
|
|
91
92
|
# Woods.configuration — the host's initializer stays intact).
|
|
92
93
|
# 4. Hydrate in-memory stores from dumps (stubs in PR 2; real in PR 3).
|
|
@@ -103,8 +104,8 @@ module Woods
|
|
|
103
104
|
# When nil, uses Woods.configuration.output_dir.
|
|
104
105
|
# @return [Array(Woods::Retriever, Woods::MCP::BootstrapState)]
|
|
105
106
|
# @raise [Woods::MCP::BootstrapError] on config-invalid (missing
|
|
106
|
-
# credentials, dimension mismatch, unsupported artifact, missing
|
|
107
|
-
# artifact
|
|
107
|
+
# credentials, dimension mismatch, unsupported artifact, or a missing
|
|
108
|
+
# artifact under WOODS_REQUIRE_INDEX=1).
|
|
108
109
|
def self.build_retriever(index_dir: nil)
|
|
109
110
|
state = BootstrapState.new
|
|
110
111
|
state.mark(:hydrating)
|