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
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
34
|
-
|
|
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 |
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
44
|
-
|
|
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
|
|
182
|
+
consolidate_dependencies(deps)
|
|
185
183
|
end
|
|
186
184
|
end
|
|
187
185
|
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
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
935
|
-
|
|
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
|
-
|
|
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
|
|
@@ -43,10 +43,8 @@ module Woods
|
|
|
43
43
|
#
|
|
44
44
|
# @return [Array<ExtractedUnit>] List of policy units
|
|
45
45
|
def extract_all
|
|
46
|
-
@directories.
|
|
47
|
-
|
|
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
|
|
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
|
|
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.
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|