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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +208 -0
  3. data/CONTRIBUTING.md +63 -1
  4. data/README.md +35 -2
  5. data/lib/tasks/woods.rake +52 -2
  6. data/lib/woods/atomic_file.rb +43 -0
  7. data/lib/woods/cache/cache_middleware.rb +18 -1
  8. data/lib/woods/console/embedded_executor.rb +20 -2
  9. data/lib/woods/console/eval_guard.rb +8 -2
  10. data/lib/woods/console/sql_noise_stripper.rb +113 -0
  11. data/lib/woods/console/sql_table_scanner.rb +22 -9
  12. data/lib/woods/console/sql_validator.rb +1 -2
  13. data/lib/woods/coordination/pipeline_lock.rb +112 -7
  14. data/lib/woods/dependency_graph.rb +38 -1
  15. data/lib/woods/embedding/text_preparer.rb +6 -1
  16. data/lib/woods/export/unit_facts.rb +80 -0
  17. data/lib/woods/extractor.rb +152 -31
  18. data/lib/woods/extractors/concern_extractor.rb +3 -5
  19. data/lib/woods/extractors/configuration_extractor.rb +3 -5
  20. data/lib/woods/extractors/controller_extractor.rb +1 -1
  21. data/lib/woods/extractors/database_view_extractor.rb +1 -1
  22. data/lib/woods/extractors/decorator_extractor.rb +3 -5
  23. data/lib/woods/extractors/event_extractor.rb +2 -2
  24. data/lib/woods/extractors/factory_extractor.rb +2 -4
  25. data/lib/woods/extractors/graphql_extractor.rb +1 -1
  26. data/lib/woods/extractors/i18n_extractor.rb +6 -4
  27. data/lib/woods/extractors/job_extractor.rb +4 -6
  28. data/lib/woods/extractors/lib_extractor.rb +1 -1
  29. data/lib/woods/extractors/mailer_extractor.rb +1 -1
  30. data/lib/woods/extractors/manager_extractor.rb +3 -5
  31. data/lib/woods/extractors/migration_extractor.rb +1 -1
  32. data/lib/woods/extractors/model_extractor.rb +26 -10
  33. data/lib/woods/extractors/phlex_extractor.rb +1 -1
  34. data/lib/woods/extractors/policy_extractor.rb +3 -5
  35. data/lib/woods/extractors/poro_extractor.rb +1 -1
  36. data/lib/woods/extractors/pundit_extractor.rb +3 -5
  37. data/lib/woods/extractors/rake_task_extractor.rb +2 -4
  38. data/lib/woods/extractors/serializer_extractor.rb +4 -6
  39. data/lib/woods/extractors/service_extractor.rb +3 -5
  40. data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
  41. data/lib/woods/extractors/shared_utility_methods.rb +14 -0
  42. data/lib/woods/extractors/state_machine_extractor.rb +2 -4
  43. data/lib/woods/extractors/validator_extractor.rb +3 -5
  44. data/lib/woods/extractors/view_component_extractor.rb +1 -1
  45. data/lib/woods/filename_utils.rb +31 -2
  46. data/lib/woods/flow_assembler.rb +45 -28
  47. data/lib/woods/flow_precomputer.rb +2 -1
  48. data/lib/woods/git_provenance.rb +121 -0
  49. data/lib/woods/index_artifact.rb +8 -4
  50. data/lib/woods/mcp/bootstrapper.rb +6 -5
  51. data/lib/woods/mcp/config_resolver.rb +42 -21
  52. data/lib/woods/mcp/errors.rb +5 -3
  53. data/lib/woods/mcp/server.rb +68 -15
  54. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  55. data/lib/woods/model_name_cache.rb +6 -1
  56. data/lib/woods/notion/client.rb +4 -1
  57. data/lib/woods/notion/exporter.rb +7 -1
  58. data/lib/woods/obsidian/errors.rb +11 -0
  59. data/lib/woods/obsidian/name_mapper.rb +132 -0
  60. data/lib/woods/obsidian/note_builder.rb +260 -0
  61. data/lib/woods/obsidian/vault_assets.rb +104 -0
  62. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  63. data/lib/woods/operator/error_escalator.rb +15 -4
  64. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  65. data/lib/woods/resolved_config.rb +4 -1
  66. data/lib/woods/retrieval/ranker.rb +45 -7
  67. data/lib/woods/retry_after.rb +36 -0
  68. data/lib/woods/temporal/json_snapshot_store.rb +4 -1
  69. data/lib/woods/temporal/snapshot_store.rb +4 -1
  70. data/lib/woods/unblocked/client.rb +4 -1
  71. data/lib/woods/unblocked/document_builder.rb +21 -27
  72. data/lib/woods/update_check.rb +190 -0
  73. data/lib/woods/version.rb +1 -1
  74. data/lib/woods.rb +24 -0
  75. 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 do |dir|
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.uniq { |d| [d[:type], d[:target]] }
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 |dir|
56
- Dir[dir.join('**/*.rb')].each do |file|
57
- unit = extract_serializer_file(file)
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.uniq { |d| [d[:type], d[:target]] }
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.flat_map do |dir|
48
- Dir[dir.join('**/*.rb')].filter_map do |file|
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.uniq { |d| [d[:type], d[:target]] }
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.uniq { |d| [d[:type], d[:target]] }
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
- source.scan(ModelNameCache.model_names_regex).each { |m| targets << m }
50
- extract_constantize_targets(source).each { |t| targets << t }
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
- deps = []
141
- deps.concat(scan_model_dependencies(source))
142
- deps.concat(scan_service_dependencies(source))
143
- deps.concat(scan_job_dependencies(source))
144
- deps.concat(scan_mailer_dependencies(source))
145
- deps.uniq { |d| [d[:type], d[:target]] }
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 do |dir|
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.uniq { |d| [d[:type], d[:target]] }
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.flat_map do |dir|
45
- Dir[dir.join('**/*.rb')].filter_map do |file|
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.uniq { |d| [d[:type], d[:target]] }
205
+ consolidate_dependencies(deps)
208
206
  end
209
207
  end
210
208
  end
@@ -315,7 +315,7 @@ module Woods
315
315
  deps << { type: :route, target: route, via: :url_helper }
316
316
  end
317
317
 
318
- deps.uniq { |d| [d[:type], d[:target]] }
318
+ consolidate_dependencies(deps)
319
319
  end
320
320
  end
321
321
  end
@@ -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
- "#{identifier.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')}.json"
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 = identifier.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
56
+ base = FilenameUtils.safe_segment(identifier)
28
57
  digest = Digest::SHA256.hexdigest(identifier)[0, 8]
29
58
  "#{base}_#{digest}.json"
30
59
  end
@@ -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>] Visited unit identifiers for cycle detection
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
- def expand(identifier, steps, visited, depth:, max_depth:)
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 visited.include?(unit_id)
75
- # Cycle detected - emit a marker step
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
- # Load the unit data from disk
87
- unit_data = load_unit(unit_id)
88
- return unless unit_data
97
+ begin
98
+ # Load the unit data from disk
99
+ unit_data = load_unit(unit_id)
100
+ return unless unit_data
89
101
 
90
- source_code = unit_data[:source_code]
91
- return unless source_code && !source_code.empty?
102
+ source_code = unit_data[:source_code]
103
+ return unless source_code && !source_code.empty?
92
104
 
93
- metadata = unit_data[:metadata] || {}
94
- unit_type = unit_data[:type]&.to_s
95
- file_path = unit_data[:file_path]
105
+ metadata = unit_data[:metadata] || {}
106
+ unit_type = unit_data[:type]&.to_s
107
+ file_path = unit_data[:file_path]
96
108
 
97
- # Extract operations from the relevant method
98
- operations = extract_operations(source_code, method_name, metadata, unit_type)
109
+ # Extract operations from the relevant method
110
+ operations = extract_operations(source_code, method_name, metadata, unit_type)
99
111
 
100
- step = {
101
- unit: identifier,
102
- type: unit_type,
103
- file_path: file_path,
104
- operations: operations
105
- }
112
+ step = {
113
+ unit: identifier,
114
+ type: unit_type,
115
+ file_path: file_path,
116
+ operations: operations
117
+ }
106
118
 
107
- steps << step
119
+ steps << step
108
120
 
109
- # Recursively expand targets that resolve to known units
110
- operations.each do |op|
111
- expand_operation(op, identifier, steps, visited, depth: depth, max_depth: max_depth)
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 = "#{controller_id.gsub('::', '__')}_#{action}.json"
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
@@ -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; the
61
- # Bootstrapper uses this to decide whether to raise {Woods::MCP::MissingArtifact}
62
- # or proceed with loading.
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
- unless target.exist? && target_real.start_with?(root_resolved)
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
- # either raise MissingArtifact or, if WOODS_ALLOW_AUTODETECT=1,
89
- # fall back to env-var auto-detect (deprecated path).
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 with autodetect off).
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)