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
@@ -8,8 +8,10 @@ require 'pathname'
8
8
  require 'set'
9
9
 
10
10
  require_relative 'filename_utils'
11
+ require_relative 'token_utils'
11
12
  require_relative 'extracted_unit'
12
13
  require_relative 'dependency_graph'
14
+ require_relative 'git_provenance'
13
15
  require_relative 'extractors/model_extractor'
14
16
  require_relative 'extractors/controller_extractor'
15
17
  require_relative 'extractors/phlex_extractor'
@@ -248,12 +250,6 @@ module Woods
248
250
  Rails.logger.info '[Woods] Analyzing dependency graph...'
249
251
  @graph_analysis = GraphAnalyzer.new(@dependency_graph).analyze
250
252
 
251
- # Phase 3.5: Precompute request flows (opt-in)
252
- if Woods.configuration.precompute_flows
253
- Rails.logger.info '[Woods] Precomputing request flows...'
254
- precompute_flows
255
- end
256
-
257
253
  # Phase 4: Enrich with git data
258
254
  Rails.logger.info '[Woods] Enriching with git data...'
259
255
  enrich_with_git_data
@@ -265,6 +261,17 @@ module Woods
265
261
  # Phase 5: Write output
266
262
  Rails.logger.info '[Woods] Writing output...'
267
263
  write_results
264
+
265
+ # Phase 5.5: Precompute request flows (opt-in). Must run AFTER
266
+ # write_results — FlowAssembler loads unit JSON from disk, so running
267
+ # earlier assembled every flow from absent (fresh output dir) or
268
+ # stale (previous run's) data. precompute_flows re-writes the
269
+ # controller units it annotates with metadata[:flow_paths].
270
+ if Woods.configuration.precompute_flows
271
+ Rails.logger.info '[Woods] Precomputing request flows...'
272
+ precompute_flows
273
+ end
274
+
268
275
  write_dependency_graph
269
276
  write_graph_analysis
270
277
  write_manifest
@@ -315,11 +322,17 @@ module Woods
315
322
  regenerate_type_index(type_key)
316
323
  end
317
324
 
318
- # Update graph, manifest, and summary
325
+ # Update graph, manifest, and summary. No capture_snapshot here:
326
+ # snapshots must hash the FULL unit set, and incremental runs only
327
+ # re-extract affected units (@results stays empty) — capturing would
328
+ # record a snapshot whose diff reports every unit as deleted.
329
+ # Snapshots are captured on full extraction only.
319
330
  write_dependency_graph
320
- write_manifest
331
+ write_manifest(incremental: true)
321
332
  write_structural_summary
322
- capture_snapshot
333
+ if Woods.configuration.enable_snapshots
334
+ Rails.logger.info '[Woods] Skipping snapshot capture — snapshots are captured on full extraction only'
335
+ end
323
336
 
324
337
  affected_ids
325
338
  end
@@ -505,11 +518,42 @@ module Woods
505
518
  all_units = @results.values.flatten(1)
506
519
  precomputer = FlowPrecomputer.new(units: all_units, graph: @dependency_graph, output_dir: @output_dir.to_s)
507
520
  flow_map = precomputer.precompute
521
+ rewrite_flow_annotated_units
508
522
  Rails.logger.info "[Woods] Precomputed #{flow_map.size} request flows"
509
523
  rescue StandardError => e
510
524
  Rails.logger.error "[Woods] Flow precomputation failed: #{e.message}"
511
525
  end
512
526
 
527
+ # Precompute runs after write_results (FlowAssembler reads unit JSON
528
+ # from disk), so units annotated in memory with metadata[:flow_paths]
529
+ # must be re-written and their type index refreshed to pick up the
530
+ # annotation.
531
+ #
532
+ # The index is rebuilt from the in-memory `units` (the authoritative
533
+ # full-extraction `@results`), NOT from a disk glob: this is a full
534
+ # extraction, the output dir is never wiped, and globbing would
535
+ # resurrect stale unit files for app classes deleted since the last run.
536
+ # (The incremental path, which only holds changed units in memory, still
537
+ # rebuilds from disk via {#regenerate_type_index}.)
538
+ def rewrite_flow_annotated_units
539
+ @results.each do |type, units|
540
+ annotated = units.select { |u| u.metadata[:flow_paths] }
541
+ next if annotated.empty?
542
+
543
+ type_dir = @output_dir.join(type.to_s)
544
+ annotated.each do |unit|
545
+ File.write(
546
+ type_dir.join(collision_safe_filename(unit.identifier)),
547
+ json_serialize(unit.to_h)
548
+ )
549
+ end
550
+ File.write(
551
+ type_dir.join('_index.json'),
552
+ json_serialize(type_index_entries(units))
553
+ )
554
+ end
555
+ end
556
+
513
557
  # ──────────────────────────────────────────────────────────────────────
514
558
  # Git Enrichment
515
559
  # ──────────────────────────────────────────────────────────────────────
@@ -701,23 +745,32 @@ module Woods
701
745
  end
702
746
 
703
747
  # Also write a type index for fast lookups
704
- index = units.map do |u|
705
- {
706
- identifier: u.identifier,
707
- file_path: u.file_path,
708
- namespace: u.namespace,
709
- estimated_tokens: u.estimated_tokens,
710
- chunk_count: u.chunks.size
711
- }
712
- end
713
-
714
748
  File.write(
715
749
  type_dir.join('_index.json'),
716
- json_serialize(index)
750
+ json_serialize(type_index_entries(units))
717
751
  )
718
752
  end
719
753
  end
720
754
 
755
+ # Build the `_index.json` entry list for a set of in-memory units.
756
+ # Shared by {#write_results} and {#rewrite_flow_annotated_units} so both
757
+ # emit the index from the authoritative in-memory `@results` rather than
758
+ # re-deriving it from disk.
759
+ #
760
+ # @param units [Array<ExtractedUnit>]
761
+ # @return [Array<Hash>]
762
+ def type_index_entries(units)
763
+ units.map do |u|
764
+ {
765
+ identifier: u.identifier,
766
+ file_path: u.file_path,
767
+ namespace: u.namespace,
768
+ estimated_tokens: u.estimated_tokens,
769
+ chunk_count: u.chunks.size
770
+ }
771
+ end
772
+ end
773
+
721
774
  def write_dependency_graph
722
775
  graph_data = @dependency_graph.to_h
723
776
  graph_data[:pagerank] = @dependency_graph.pagerank
@@ -744,23 +797,39 @@ module Woods
744
797
  )
745
798
  end
746
799
 
747
- def write_manifest
800
+ def write_manifest(incremental: false)
801
+ # Worktree-aware git provenance. In a linked worktree +.git+ is a file
802
+ # pointing at the real git dir; when that dir is unreachable (e.g. an
803
+ # unmounted host path inside a container) this resolves to "unknown"
804
+ # rather than a stale GIT_BRANCH/GIT_SHA build arg. See GitProvenance (#137).
805
+ provenance = GitProvenance.new(root: Rails.root).to_h
806
+
807
+ # Incremental runs never populate @results — deriving counts from
808
+ # memory would clobber a good manifest with zeros. Recompute from the
809
+ # persisted per-type _index.json files instead.
810
+ counts, total_chunks =
811
+ if incremental
812
+ persisted_counts
813
+ else
814
+ [@results.transform_values(&:size),
815
+ @results.sum { |_, units| units.sum { |u| u.chunks.size } }]
816
+ end
817
+
748
818
  manifest = {
749
819
  extracted_at: Time.current.iso8601,
750
820
  rails_version: Rails.version,
751
821
  ruby_version: RUBY_VERSION,
752
822
 
753
823
  # Counts by type
754
- counts: @results.transform_values(&:size),
824
+ counts: counts,
755
825
 
756
826
  # Total stats
757
- total_units: @results.values.sum(&:size),
758
- total_chunks: @results.sum { |_, units| units.sum { |u| u.chunks.size } },
827
+ total_units: counts.values.sum,
828
+ total_chunks: total_chunks,
759
829
 
760
- # Git info fall back to env vars for Docker/worktree environments
761
- # where the git repo may not be directly accessible
762
- git_sha: run_git('rev-parse', 'HEAD').presence || ENV['GIT_SHA'].presence,
763
- git_branch: run_git('rev-parse', '--abbrev-ref', 'HEAD').presence || ENV['GIT_BRANCH'].presence,
830
+ # Git provenance (branch/sha), or "unknown" when unresolvable
831
+ git_sha: provenance[:git_sha],
832
+ git_branch: provenance[:git_branch],
764
833
 
765
834
  # For change detection
766
835
  gemfile_lock_sha: gemfile_lock_sha,
@@ -773,6 +842,30 @@ module Woods
773
842
  )
774
843
  end
775
844
 
845
+ # Unit and chunk counts derived from the per-type _index.json files on
846
+ # disk — the source of truth after an incremental run, where only the
847
+ # affected units were re-extracted.
848
+ #
849
+ # @return [Array(Hash{Symbol => Integer}, Integer)] counts by type, total chunk count
850
+ def persisted_counts
851
+ counts = {}
852
+ chunks = 0
853
+
854
+ Dir[@output_dir.join('*/_index.json').to_s].each do |index_path|
855
+ entries = JSON.parse(File.read(index_path))
856
+ counts[File.basename(File.dirname(index_path)).to_sym] = entries.size
857
+ chunks += entries.sum { |e| e['chunk_count'].to_i }
858
+ rescue JSON::ParserError => e
859
+ # An unreadable index silently drops that whole type from the manifest
860
+ # counts — warn rather than undercount without a trace.
861
+ type = File.basename(File.dirname(index_path))
862
+ Rails.logger.warn("[Woods] Skipping unreadable #{type}/_index.json in manifest counts: #{e.message}")
863
+ next
864
+ end
865
+
866
+ [counts, chunks]
867
+ end
868
+
776
869
  # Capture a temporal snapshot after extraction completes.
777
870
  #
778
871
  # Reads the manifest and computes per-unit content hashes, then delegates
@@ -787,7 +880,10 @@ module Woods
787
880
  return unless manifest_path.exist?
788
881
 
789
882
  manifest = JSON.parse(File.read(manifest_path))
790
- return unless manifest['git_sha']
883
+ # Snapshots are keyed on the commit SHA — an unresolvable provenance
884
+ # ("unknown", see GitProvenance/#137) must not key or collide a snapshot.
885
+ git_sha = manifest['git_sha']
886
+ return if git_sha.nil? || git_sha == Woods::GitProvenance::UNKNOWN
791
887
 
792
888
  store = build_snapshot_store
793
889
  return unless store
@@ -906,7 +1002,10 @@ module Woods
906
1002
  identifier: data['identifier'],
907
1003
  file_path: data['file_path'],
908
1004
  namespace: data['namespace'],
909
- estimated_tokens: data['estimated_tokens'],
1005
+ # Unit JSON has no estimated_tokens field (ExtractedUnit#to_h
1006
+ # doesn't emit one) — recompute it, or every unit of a type
1007
+ # touched by an incremental run would index as null.
1008
+ estimated_tokens: estimated_tokens_from(data),
910
1009
  chunk_count: (data['chunks'] || []).size
911
1010
  }
912
1011
  end
@@ -917,6 +1016,20 @@ module Woods
917
1016
  )
918
1017
  end
919
1018
 
1019
+ # Token estimate for a unit parsed back from JSON, mirroring
1020
+ # ExtractedUnit#estimated_tokens (see docs/TOKEN_BENCHMARK.md).
1021
+ #
1022
+ # @param data [Hash] Parsed unit JSON (string keys)
1023
+ # @return [Integer]
1024
+ def estimated_tokens_from(data)
1025
+ source = data['source_code']
1026
+ metadata = data['metadata'] || {}
1027
+
1028
+ source_tokens = source ? TokenUtils.estimate_tokens(source) : 0
1029
+ metadata_tokens = metadata.any? ? TokenUtils.estimate_tokens(metadata.to_json) : 0
1030
+ source_tokens + metadata_tokens
1031
+ end
1032
+
920
1033
  # ──────────────────────────────────────────────────────────────────────
921
1034
  # Helpers
922
1035
  # ──────────────────────────────────────────────────────────────────────
@@ -1014,12 +1127,20 @@ module Woods
1014
1127
 
1015
1128
  return unless unit
1016
1129
 
1017
- # Update dependency graph
1130
+ # Update dependency graph. Register BEFORE normalizing the path —
1131
+ # the graph's file_map stores absolute paths (affected_by matches
1132
+ # changed files against them), exactly as full extraction registers
1133
+ # in Phase 1 and only normalizes in Phase 4.5.
1018
1134
  @dependency_graph.register(unit)
1019
1135
 
1020
1136
  # Track which type was affected
1021
1137
  affected_types&.add(extractor_key)
1022
1138
 
1139
+ # Unit JSON carries Rails.root-relative paths (full extraction's
1140
+ # Phase 4.5); writing the raw absolute source_location here would
1141
+ # leak container-absolute paths into the index after incremental runs.
1142
+ unit.file_path = normalize_file_path(unit.file_path)
1143
+
1023
1144
  # Write updated unit
1024
1145
  type_dir = @output_dir.join(extractor_key.to_s)
1025
1146
 
@@ -48,10 +48,8 @@ module Woods
48
48
  #
49
49
  # @return [Array<ExtractedUnit>] List of concern units
50
50
  def extract_all
51
- @directories.flat_map do |dir|
52
- Dir[dir.join('**/*.rb')].filter_map do |file|
53
- extract_concern_file(file)
54
- end
51
+ find_files_in_directories(@directories).filter_map do |file|
52
+ extract_concern_file(file)
55
53
  end
56
54
  end
57
55
 
@@ -285,7 +283,7 @@ module Woods
285
283
  deps.concat(scan_service_dependencies(source))
286
284
  deps.concat(scan_job_dependencies(source))
287
285
 
288
- deps.uniq { |d| [d[:type], d[:target]] }
286
+ consolidate_dependencies(deps)
289
287
  end
290
288
  end
291
289
  end
@@ -36,10 +36,8 @@ module Woods
36
36
  #
37
37
  # @return [Array<ExtractedUnit>] List of configuration units
38
38
  def extract_all
39
- units = @directories.flat_map do |dir|
40
- Dir[dir.join('**/*.rb')].filter_map do |file|
41
- extract_configuration_file(file)
42
- end
39
+ units = find_files_in_directories(@directories).filter_map do |file|
40
+ extract_configuration_file(file)
43
41
  end
44
42
 
45
43
  profile = BehavioralProfile.new.extract
@@ -212,7 +210,7 @@ module Woods
212
210
 
213
211
  deps.concat(scan_service_dependencies(source))
214
212
 
215
- deps.uniq { |d| [d[:type], d[:target]] }
213
+ consolidate_dependencies(deps)
216
214
  end
217
215
  end
218
216
  end
@@ -320,7 +320,7 @@ module Woods
320
320
  deps.concat(scan_navigation_dependencies(source, via_type: :redirect_to))
321
321
  end
322
322
 
323
- deps.uniq { |d| [d[:type], d[:target]] }
323
+ consolidate_dependencies(deps)
324
324
  end
325
325
 
326
326
  # ──────────────────────────────────────────────────────────────────────
@@ -271,7 +271,7 @@ module Woods
271
271
  deps << { type: :model, target: model_name, via: :table_name }
272
272
  end
273
273
 
274
- deps.uniq { |d| [d[:type], d[:target]] }
274
+ consolidate_dependencies(deps)
275
275
  end
276
276
  end
277
277
  end
@@ -49,10 +49,8 @@ module Woods
49
49
  #
50
50
  # @return [Array<ExtractedUnit>] List of decorator units
51
51
  def extract_all
52
- @directories.flat_map do |dir|
53
- Dir[dir.join('**/*.rb')].filter_map do |file|
54
- extract_decorator_file(file)
55
- end
52
+ find_files_in_directories(@directories).filter_map do |file|
53
+ extract_decorator_file(file)
56
54
  end
57
55
  end
58
56
 
@@ -246,7 +244,7 @@ module Woods
246
244
 
247
245
  deps.concat(scan_common_dependencies(source))
248
246
 
249
- deps.uniq { |d| [d[:type], d[:target]] }
247
+ consolidate_dependencies(deps)
250
248
  end
251
249
  end
252
250
  end
@@ -42,7 +42,7 @@ module Woods
42
42
  def extract_all
43
43
  event_map = {}
44
44
 
45
- @directories.flat_map { |dir| Dir[dir.join('**/*.rb')] }.each do |file_path|
45
+ find_files_in_directories(@directories).each do |file_path|
46
46
  scan_file(file_path, event_map)
47
47
  end
48
48
 
@@ -204,7 +204,7 @@ module Woods
204
204
  # @return [Array<Hash>]
205
205
  def build_dependencies(combined_source)
206
206
  deps = scan_common_dependencies(combined_source)
207
- deps.uniq { |d| [d[:type], d[:target]] }
207
+ consolidate_dependencies(deps)
208
208
  end
209
209
  end
210
210
  end
@@ -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