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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/CONTRIBUTING.md +30 -1
  4. data/README.md +31 -0
  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 +138 -26
  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/index_artifact.rb +5 -1
  49. data/lib/woods/mcp/server.rb +68 -15
  50. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  51. data/lib/woods/model_name_cache.rb +6 -1
  52. data/lib/woods/notion/client.rb +4 -1
  53. data/lib/woods/notion/exporter.rb +7 -1
  54. data/lib/woods/obsidian/errors.rb +11 -0
  55. data/lib/woods/obsidian/name_mapper.rb +132 -0
  56. data/lib/woods/obsidian/note_builder.rb +260 -0
  57. data/lib/woods/obsidian/vault_assets.rb +104 -0
  58. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  59. data/lib/woods/operator/error_escalator.rb +15 -4
  60. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  61. data/lib/woods/resolved_config.rb +4 -1
  62. data/lib/woods/retrieval/ranker.rb +45 -7
  63. data/lib/woods/retry_after.rb +36 -0
  64. data/lib/woods/unblocked/client.rb +4 -1
  65. data/lib/woods/unblocked/document_builder.rb +21 -27
  66. data/lib/woods/update_check.rb +190 -0
  67. data/lib/woods/version.rb +1 -1
  68. data/lib/woods.rb +24 -0
  69. metadata +12 -2
@@ -35,9 +35,7 @@ module Woods
35
35
  #
36
36
  # @return [Array<ExtractedUnit>] List of factory units
37
37
  def extract_all
38
- @directories.flat_map do |dir|
39
- Dir[dir.join('**/*.rb')].flat_map { |file| extract_factory_file(file) }
40
- end
38
+ find_files_in_directories(@directories).flat_map { |file| extract_factory_file(file) }
41
39
  end
42
40
 
43
41
  # Extract factory definitions from a single factory file.
@@ -282,7 +280,7 @@ module Woods
282
280
  deps << { type: :factory, target: assoc, via: :factory_association }
283
281
  end
284
282
 
285
- deps.uniq { |d| [d[:type], d[:target]] }
283
+ consolidate_dependencies(deps)
286
284
  end
287
285
  end
288
286
  end
@@ -770,7 +770,7 @@ module Woods
770
770
  deps << { type: :graphql_resolver, target: resolver, via: :field_resolver }
771
771
  end
772
772
 
773
- deps.uniq { |d| [d[:type], d[:target]] }
773
+ consolidate_dependencies(deps)
774
774
  end
775
775
 
776
776
  # ──────────────────────────────────────────────────────────────────────
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'yaml'
4
4
 
5
+ require_relative 'shared_utility_methods'
6
+
5
7
  module Woods
6
8
  module Extractors
7
9
  # I18nExtractor handles internationalization locale file extraction.
@@ -16,6 +18,8 @@ module Woods
16
18
  # en = units.find { |u| u.identifier == "en.yml" }
17
19
  #
18
20
  class I18nExtractor
21
+ include SharedUtilityMethods
22
+
19
23
  # Directories to scan for locale files
20
24
  I18N_DIRECTORIES = %w[
21
25
  config/locales
@@ -30,10 +34,8 @@ module Woods
30
34
  #
31
35
  # @return [Array<ExtractedUnit>] List of i18n units
32
36
  def extract_all
33
- @directories.flat_map do |dir|
34
- Dir[dir.join('**/*.yml')].filter_map do |file|
35
- extract_i18n_file(file)
36
- end
37
+ find_files_in_directories(@directories, '**/*.yml').filter_map do |file|
38
+ extract_i18n_file(file)
37
39
  end
38
40
  end
39
41
 
@@ -46,11 +46,9 @@ module Woods
46
46
  units = []
47
47
 
48
48
  # File-based discovery (catches everything)
49
- @directories.each do |dir|
50
- Dir[dir.join('**/*.rb')].each do |file|
51
- unit = extract_job_file(file)
52
- units << unit if unit
53
- end
49
+ find_files_in_directories(@directories).each do |file|
50
+ unit = extract_job_file(file)
51
+ units << unit if unit
54
52
  end
55
53
 
56
54
  # Also try class-based discovery for ActiveJob
@@ -356,7 +354,7 @@ module Woods
356
354
 
357
355
  deps << { type: :infrastructure, target: :redis, via: :code_reference } if source.match?(/Redis\.current|REDIS/)
358
356
 
359
- deps.uniq { |d| [d[:type], d[:target]] }
357
+ consolidate_dependencies(deps)
360
358
  end
361
359
 
362
360
  # Scan source for job class enqueue calls and return the list of enqueued job names.
@@ -211,7 +211,7 @@ module Woods
211
211
  # @return [Array<Hash>] Dependency hashes with :type, :target, :via
212
212
  def extract_dependencies(source)
213
213
  deps = scan_common_dependencies(source)
214
- deps.uniq { |d| [d[:type], d[:target]] }
214
+ consolidate_dependencies(deps)
215
215
  end
216
216
  end
217
217
  end
@@ -243,7 +243,7 @@ module Woods
243
243
  deps << { type: :route, target: route, via: :url_helper }
244
244
  end
245
245
 
246
- deps.uniq { |d| [d[:type], d[:target]] }
246
+ consolidate_dependencies(deps)
247
247
  end
248
248
 
249
249
  # ──────────────────────────────────────────────────────────────────────
@@ -40,10 +40,8 @@ module Woods
40
40
  #
41
41
  # @return [Array<ExtractedUnit>] List of manager units
42
42
  def extract_all
43
- @directories.flat_map do |dir|
44
- Dir[dir.join('**/*.rb')].filter_map do |file|
45
- extract_manager_file(file)
46
- end
43
+ find_files_in_directories(@directories).filter_map do |file|
44
+ extract_manager_file(file)
47
45
  end
48
46
  end
49
47
 
@@ -181,7 +179,7 @@ module Woods
181
179
 
182
180
  deps.concat(scan_common_dependencies(source))
183
181
 
184
- deps.uniq { |d| [d[:type], d[:target]] }
182
+ consolidate_dependencies(deps)
185
183
  end
186
184
  end
187
185
  end
@@ -462,7 +462,7 @@ module Woods
462
462
  # Scan data migration code for common dependencies
463
463
  deps.concat(scan_common_dependencies(source))
464
464
 
465
- deps.uniq { |d| [d[:type], d[:target]] }
465
+ consolidate_dependencies(deps)
466
466
  end
467
467
  end
468
468
  end
@@ -501,7 +501,7 @@ module Woods
501
501
  options: v.options.except(:if, :unless, :on),
502
502
  conditions: format_validation_conditions(v)
503
503
  }
504
- entry[:implicit_belongs_to] = true if implicit_belongs_to_validator?(v)
504
+ entry[:implicit_belongs_to] = true if implicit_belongs_to_validator?(model, v)
505
505
  entry
506
506
  end
507
507
  end
@@ -654,7 +654,7 @@ module Woods
654
654
  end
655
655
  end
656
656
 
657
- deps.uniq { |d| [d[:type], d[:target]] }
657
+ consolidate_dependencies(deps)
658
658
  end
659
659
 
660
660
  # Enrich callback metadata with side-effect analysis.
@@ -922,17 +922,30 @@ module Woods
922
922
  conditions
923
923
  end
924
924
 
925
- # Detect Rails-generated implicit belongs_to presence validators
925
+ # Detect Rails-generated implicit belongs_to presence validators.
926
926
  #
927
+ # `belongs_to` (with belongs_to_required_by_default, Rails 5+)
928
+ # registers an ActiveRecord PresenceValidator on the association
929
+ # attribute. Reflection offers no declaration provenance — the
930
+ # validator class and its #validate source_location are identical for
931
+ # an explicit `validates :assoc, presence: true` — so this flags any
932
+ # AR presence validator whose attributes are all belongs_to
933
+ # association names. (The previous source_location heuristic matched
934
+ # EVERY AR presence validator, since PresenceValidator#validate is
935
+ # always defined in the gem, never under Rails.root.)
936
+ #
937
+ # @param model [Class] The ActiveRecord model class
927
938
  # @param validator [ActiveModel::Validator]
928
939
  # @return [Boolean]
929
- def implicit_belongs_to_validator?(validator)
930
- if defined?(ActiveRecord::Validations::PresenceValidator) && !validator.is_a?(ActiveRecord::Validations::PresenceValidator)
931
- return false
932
- end
940
+ def implicit_belongs_to_validator?(model, validator)
941
+ return false unless defined?(ActiveRecord::Validations::PresenceValidator)
942
+ return false unless validator.is_a?(ActiveRecord::Validations::PresenceValidator)
943
+
944
+ belongs_to_names = model.reflect_on_all_associations(:belongs_to).map { |r| r.name.to_sym }
945
+ return false if belongs_to_names.empty?
933
946
 
934
- loc = validator.class.instance_method(:validate).source_location&.first
935
- loc && !loc.start_with?(Rails.root.to_s)
947
+ attributes = validator.respond_to?(:attributes) ? Array(validator.attributes) : []
948
+ attributes.any? && attributes.all? { |a| belongs_to_names.include?(a.to_sym) }
936
949
  rescue StandardError
937
950
  false
938
951
  end
@@ -975,7 +988,10 @@ module Woods
975
988
 
976
989
  def callback_count(model)
977
990
  %i[validation save create update destroy commit rollback].sum do |type|
978
- model.send("_#{type}_callbacks").size
991
+ # CallbackChain includes Enumerable but defines no #size on any
992
+ # supported Rails version — #size raises and the rescue would
993
+ # zero the count. #count is the only correct API here.
994
+ model.send("_#{type}_callbacks").count
979
995
  rescue StandardError
980
996
  0
981
997
  end
@@ -254,7 +254,7 @@ module Woods
254
254
  deps.concat(scan_navigation_dependencies(source))
255
255
  deps.concat(scan_form_dependencies(source))
256
256
 
257
- deps.uniq { |d| [d[:type], d[:target]] }
257
+ consolidate_dependencies(deps)
258
258
  end
259
259
  end
260
260
  end
@@ -43,10 +43,8 @@ module Woods
43
43
  #
44
44
  # @return [Array<ExtractedUnit>] List of policy units
45
45
  def extract_all
46
- @directories.flat_map do |dir|
47
- Dir[dir.join('**/*.rb')].filter_map do |file|
48
- extract_policy_file(file)
49
- end
46
+ find_files_in_directories(@directories).filter_map do |file|
47
+ extract_policy_file(file)
50
48
  end
51
49
  end
52
50
 
@@ -184,7 +182,7 @@ module Woods
184
182
  deps.concat(scan_service_dependencies(source))
185
183
  deps.concat(scan_job_dependencies(source))
186
184
 
187
- deps.uniq { |d| [d[:type], d[:target]] }
185
+ consolidate_dependencies(deps)
188
186
  end
189
187
  end
190
188
  end
@@ -222,7 +222,7 @@ module Woods
222
222
  # @return [Array<Hash>] Dependency hashes with :type, :target, :via
223
223
  def extract_dependencies(source)
224
224
  deps = scan_common_dependencies(source)
225
- deps.uniq { |d| [d[:type], d[:target]] }
225
+ consolidate_dependencies(deps)
226
226
  end
227
227
  end
228
228
  end
@@ -38,10 +38,8 @@ module Woods
38
38
  #
39
39
  # @return [Array<ExtractedUnit>] List of Pundit policy units
40
40
  def extract_all
41
- @directories.flat_map do |dir|
42
- Dir[dir.join('**/*.rb')].filter_map do |file|
43
- extract_pundit_file(file)
44
- end
41
+ find_files_in_directories(@directories).filter_map do |file|
42
+ extract_pundit_file(file)
45
43
  end
46
44
  end
47
45
 
@@ -216,7 +214,7 @@ module Woods
216
214
  deps.concat(scan_model_dependencies(source))
217
215
  deps.concat(scan_service_dependencies(source))
218
216
 
219
- deps.uniq { |d| [d[:type], d[:target]] }
217
+ consolidate_dependencies(deps)
220
218
  end
221
219
  end
222
220
  end
@@ -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