woods 1.5.0 → 1.6.1

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 +171 -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 +153 -32
  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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'json'
5
+ require 'securerandom'
5
6
 
6
7
  module Woods
7
8
  module Coordination
@@ -38,12 +39,13 @@ module Woods
38
39
  def acquire
39
40
  FileUtils.mkdir_p(@lock_dir)
40
41
 
41
- # Check for stale lock first (separate from atomic creation)
42
42
  if File.exist?(@lock_path)
43
43
  return false unless stale?
44
-
45
- # Remove stale lock
46
- FileUtils.rm_f(@lock_path)
44
+ # Retire the stale lock atomically. A bare rm_f + create here is
45
+ # a TOCTOU race: two processes passing the stale check together
46
+ # could each delete-and-create, the second deleting the first's
47
+ # FRESH lock — both would then "hold" it.
48
+ return false unless retire_stale_lock
47
49
  end
48
50
 
49
51
  # Atomic lock creation: File::EXCL ensures this fails if file already exists
@@ -58,10 +60,37 @@ module Woods
58
60
 
59
61
  # Release the lock.
60
62
  #
63
+ # Deletes the lock file only if it still carries this instance's
64
+ # token — a run that outlived stale_timeout may have been
65
+ # legitimately taken over, and deleting unconditionally would drop
66
+ # the new holder's lock.
67
+ #
61
68
  # @return [void]
62
69
  def release
63
- FileUtils.rm_f(@lock_path) if @held
70
+ return unless @held
71
+
72
+ # Clear @held up front so no later failure can leave this instance
73
+ # believing it still holds the lock.
64
74
  @held = false
75
+
76
+ # Rename first, then inspect: a plain read-then-unlink is a TOCTOU —
77
+ # after we read our own token a takeover could replace the file, and
78
+ # our unlink would then delete the NEW holder's lock. Renaming
79
+ # atomically captures whatever is at the path.
80
+ graveyard = "#{@lock_path}.release.#{Process.pid}.#{SecureRandom.hex(4)}"
81
+ begin
82
+ File.rename(@lock_path, graveyard)
83
+ rescue Errno::ENOENT
84
+ return # already gone
85
+ end
86
+
87
+ if own_lock?(graveyard)
88
+ FileUtils.rm_f(graveyard)
89
+ else
90
+ # We were legitimately taken over — put the successor's lock back
91
+ # without clobbering a still-newer holder (see {#restore_lock}).
92
+ restore_lock(graveyard)
93
+ end
65
94
  end
66
95
 
67
96
  # Execute a block while holding the lock.
@@ -100,9 +129,85 @@ module Woods
100
129
  true
101
130
  end
102
131
 
103
- # @return [String] Lock file content (JSON with PID and timestamp)
132
+ # Atomically retire a stale lock file via rename. Rename is atomic on
133
+ # POSIX: of any processes racing to take over the same stale lock,
134
+ # exactly one rename succeeds; the losers get ENOENT and back off.
135
+ # Winning the rename does NOT guarantee winning the lock — another
136
+ # process may O_EXCL-create between our rename and our create, which
137
+ # the caller's EEXIST rescue handles.
138
+ #
139
+ # Rename alone is not enough, though: a competitor that already passed
140
+ # `stale?` on the SAME original file may have retired it and created a
141
+ # FRESH lock before we run. Our rename would then move that fresh lock
142
+ # aside — and both processes would "hold" the lock. So after winning
143
+ # the rename we re-check the retired file's age; if it turns out to be
144
+ # fresh (someone beat us to the takeover), we put it back and lose the
145
+ # race instead of clobbering a live holder.
146
+ #
147
+ # @return [Boolean] true if this process retired a genuinely stale lock
148
+ def retire_stale_lock
149
+ graveyard = "#{@lock_path}.stale.#{Process.pid}.#{SecureRandom.hex(4)}"
150
+ File.rename(@lock_path, graveyard)
151
+
152
+ unless stale_file?(graveyard)
153
+ # We grabbed a lock that is no longer stale — a competitor already
154
+ # took over. Restore it (without clobbering a still-newer holder)
155
+ # and back off.
156
+ restore_lock(graveyard)
157
+ return false
158
+ end
159
+
160
+ FileUtils.rm_f(graveyard)
161
+ true
162
+ rescue Errno::ENOENT
163
+ false
164
+ end
165
+
166
+ # Whether the lock file at +path+ carries this instance's token.
167
+ #
168
+ # @param path [String]
169
+ # @return [Boolean] true when the token matches, or the file is corrupt
170
+ # (an unparseable lock we already renamed aside is treated as ours to
171
+ # discard rather than restore).
172
+ def own_lock?(path)
173
+ JSON.parse(File.read(path))['token'] == @token
174
+ rescue JSON::ParserError
175
+ true
176
+ end
177
+
178
+ # Put a lock file we renamed aside back at @lock_path WITHOUT clobbering
179
+ # a lock another process may have O_EXCL-created in the meantime.
180
+ # `File.link` is atomic and fails with EEXIST if @lock_path already
181
+ # exists, so a newer holder always wins; the aside copy is discarded
182
+ # either way. A plain `File.rename` back would overwrite that newer
183
+ # holder's lock — reintroducing a double-hold.
184
+ #
185
+ # @param graveyard [String] path of the renamed-aside lock file
186
+ # @return [void]
187
+ def restore_lock(graveyard)
188
+ File.link(graveyard, @lock_path)
189
+ rescue Errno::EEXIST
190
+ # A newer holder already claimed the path — our copy is obsolete.
191
+ nil
192
+ ensure
193
+ FileUtils.rm_f(graveyard)
194
+ end
195
+
196
+ # Whether the file at +path+ is older than the stale timeout.
197
+ #
198
+ # @param path [String]
199
+ # @return [Boolean]
200
+ def stale_file?(path)
201
+ Time.now - File.mtime(path) > @stale_timeout
202
+ rescue Errno::ENOENT
203
+ false
204
+ end
205
+
206
+ # @return [String] Lock file content (JSON with PID, timestamp, and
207
+ # an ownership token release verifies before deleting)
104
208
  def lock_content
105
- JSON.generate(pid: Process.pid, locked_at: Time.now.iso8601, name: @name)
209
+ @token = SecureRandom.hex(8)
210
+ JSON.generate(pid: Process.pid, locked_at: Time.now.iso8601, name: @name, token: @token)
106
211
  end
107
212
  end
108
213
  end
@@ -31,12 +31,20 @@ module Woods
31
31
  @to_h = nil
32
32
  end
33
33
 
34
- # Register a unit in the graph
34
+ # Register a unit in the graph.
35
+ #
36
+ # Re-registering an identifier (incremental extraction registers into a
37
+ # graph loaded from disk) first removes the previous registration's
38
+ # reverse edges, file-map entry, and type-index entry — otherwise stale
39
+ # dependents accumulate across incremental runs and get persisted back
40
+ # to dependency_graph.json.
35
41
  #
36
42
  # @param unit [ExtractedUnit] The unit to register
37
43
  def register(unit)
38
44
  @to_h = nil
39
45
 
46
+ unregister(unit.identifier) if @nodes.key?(unit.identifier)
47
+
40
48
  @nodes[unit.identifier] = {
41
49
  type: unit.type,
42
50
  file_path: unit.file_path,
@@ -56,6 +64,35 @@ module Woods
56
64
  end
57
65
  end
58
66
 
67
+ # Remove an identifier's registration side effects: its contribution to
68
+ # the reverse indexes (derived from its recorded forward edges), its
69
+ # file-map entry, and its type-index entry. Forward node/edge data is
70
+ # overwritten by the caller (register), so it is not cleared here.
71
+ #
72
+ # @param identifier [String] Previously-registered unit identifier
73
+ # @return [void]
74
+ def unregister(identifier)
75
+ (@edges[identifier] || []).each do |edge|
76
+ if (set = @reverse[edge[:target]])
77
+ set.delete(identifier)
78
+ @reverse.delete(edge[:target]) if set.empty?
79
+ end
80
+
81
+ via_key = [edge[:target], edge[:via]]
82
+ next unless (set = @reverse_via[via_key])
83
+
84
+ set.delete(identifier)
85
+ @reverse_via.delete(via_key) if set.empty?
86
+ end
87
+
88
+ old_node = @nodes[identifier]
89
+ return unless old_node
90
+
91
+ old_path = old_node[:file_path]
92
+ @file_map.delete(old_path) if old_path && @file_map[old_path] == identifier
93
+ @type_index[old_node[:type]]&.delete(identifier)
94
+ end
95
+
59
96
  # Find all units affected by changes to given files
60
97
  # Uses BFS to find transitive dependents
61
98
  #
@@ -95,7 +95,12 @@ module Woods
95
95
  def append_dependency_line(lines, dependencies)
96
96
  return unless dependencies&.any?
97
97
 
98
- dep_names = dependencies.map { |d| d[:target] }.compact.first(10)
98
+ # Dependency hashes arrive symbol-keyed from the extractor's
99
+ # in-memory units but string-keyed from the indexer (Indexer#build_unit
100
+ # reads JSON and does not symbolize dependency keys, unlike chunks).
101
+ # Read both forms or the whole "dependencies:" prefix silently
102
+ # vanishes from every embedded document on the indexing path.
103
+ dep_names = dependencies.filter_map { |d| d[:target] || d['target'] }.first(10)
99
104
  lines << "dependencies: #{dep_names.join(', ')}" if dep_names.any?
100
105
  end
101
106
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ # Shared building blocks for the export formatters (Unblocked DocumentBuilder,
5
+ # Obsidian NoteBuilder).
6
+ module Export
7
+ # Format-agnostic extraction of facts from a unit's metadata.
8
+ #
9
+ # Both the Unblocked and Obsidian exporters need the same *facts* about a
10
+ # unit — its associations grouped by macro, its schema highlights — but
11
+ # render them differently (plain text + GitHub URIs vs. wikilinks +
12
+ # frontmatter). This value object owns the one definition of "what a unit's
13
+ # metadata says"; each exporter is a thin formatter over it. The returned
14
+ # structures are deliberately raw (no truncation, no ordering imposed beyond
15
+ # the source array): formatting decisions — sort order, top-N caps — belong
16
+ # to each formatter so neither exporter's output is constrained by the other.
17
+ class UnitFacts
18
+ # @param unit [Hash] parsed unit JSON
19
+ def initialize(unit)
20
+ @meta = (unit && unit['metadata']) || {}
21
+ end
22
+
23
+ # Associations grouped by macro, each as a structured entry. Group keys are
24
+ # whatever macros are present (insertion order from the source array); the
25
+ # formatter picks the display order.
26
+ #
27
+ # @return [Hash{String=>Array<Hash>}] macro => [{ target:, dependent: }]
28
+ def associations_by_type
29
+ associations.group_by { |assoc| assoc['type'] }.transform_values do |items|
30
+ items.map { |a| { target: a['target'] || a['name'], dependent: a.dig('options', 'dependent') } }
31
+ end
32
+ end
33
+
34
+ # @return [Integer] total association count across all macros
35
+ def association_count
36
+ associations.size
37
+ end
38
+
39
+ # @return [Hash] { enums:, scopes:, concerns:, callbacks: } — raw, untruncated
40
+ def schema_highlights
41
+ { enums: enums, scopes: scopes, concerns: concerns, callbacks: callbacks }
42
+ end
43
+
44
+ def enums
45
+ @meta['enums'].is_a?(Hash) ? @meta['enums'] : {}
46
+ end
47
+
48
+ def scopes
49
+ @meta['scopes'].is_a?(Array) ? @meta['scopes'].filter_map { |s| s['name'] } : []
50
+ end
51
+
52
+ def concerns
53
+ @meta['inlined_concerns'].is_a?(Array) ? @meta['inlined_concerns'] : []
54
+ end
55
+
56
+ def callbacks
57
+ return [] unless @meta['callbacks'].is_a?(Array)
58
+
59
+ @meta['callbacks'].map { |cb| { type: cb['type'], filter: cb['filter'] } }
60
+ end
61
+
62
+ # Blast-radius bucket for a dependent count.
63
+ #
64
+ # @param dependent_count [Integer]
65
+ # @return [Symbol] :high (>50), :moderate (>20), or :none
66
+ def self.blast_radius(dependent_count)
67
+ return :high if dependent_count > 50
68
+ return :moderate if dependent_count > 20
69
+
70
+ :none
71
+ end
72
+
73
+ private
74
+
75
+ def associations
76
+ @meta['associations'].is_a?(Array) ? @meta['associations'] : []
77
+ end
78
+ end
79
+ end
80
+ end
@@ -8,6 +8,7 @@ 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'
13
14
  require_relative 'git_provenance'
@@ -249,12 +250,6 @@ module Woods
249
250
  Rails.logger.info '[Woods] Analyzing dependency graph...'
250
251
  @graph_analysis = GraphAnalyzer.new(@dependency_graph).analyze
251
252
 
252
- # Phase 3.5: Precompute request flows (opt-in)
253
- if Woods.configuration.precompute_flows
254
- Rails.logger.info '[Woods] Precomputing request flows...'
255
- precompute_flows
256
- end
257
-
258
253
  # Phase 4: Enrich with git data
259
254
  Rails.logger.info '[Woods] Enriching with git data...'
260
255
  enrich_with_git_data
@@ -266,6 +261,17 @@ module Woods
266
261
  # Phase 5: Write output
267
262
  Rails.logger.info '[Woods] Writing output...'
268
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
+
269
275
  write_dependency_graph
270
276
  write_graph_analysis
271
277
  write_manifest
@@ -316,11 +322,17 @@ module Woods
316
322
  regenerate_type_index(type_key)
317
323
  end
318
324
 
319
- # 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.
320
330
  write_dependency_graph
321
- write_manifest
331
+ write_manifest(incremental: true)
322
332
  write_structural_summary
323
- 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
324
336
 
325
337
  affected_ids
326
338
  end
@@ -506,11 +518,42 @@ module Woods
506
518
  all_units = @results.values.flatten(1)
507
519
  precomputer = FlowPrecomputer.new(units: all_units, graph: @dependency_graph, output_dir: @output_dir.to_s)
508
520
  flow_map = precomputer.precompute
521
+ rewrite_flow_annotated_units
509
522
  Rails.logger.info "[Woods] Precomputed #{flow_map.size} request flows"
510
523
  rescue StandardError => e
511
524
  Rails.logger.error "[Woods] Flow precomputation failed: #{e.message}"
512
525
  end
513
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
+
514
557
  # ──────────────────────────────────────────────────────────────────────
515
558
  # Git Enrichment
516
559
  # ──────────────────────────────────────────────────────────────────────
@@ -702,23 +745,32 @@ module Woods
702
745
  end
703
746
 
704
747
  # Also write a type index for fast lookups
705
- index = units.map do |u|
706
- {
707
- identifier: u.identifier,
708
- file_path: u.file_path,
709
- namespace: u.namespace,
710
- estimated_tokens: u.estimated_tokens,
711
- chunk_count: u.chunks.size
712
- }
713
- end
714
-
715
748
  File.write(
716
749
  type_dir.join('_index.json'),
717
- json_serialize(index)
750
+ json_serialize(type_index_entries(units))
718
751
  )
719
752
  end
720
753
  end
721
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
+
722
774
  def write_dependency_graph
723
775
  graph_data = @dependency_graph.to_h
724
776
  graph_data[:pagerank] = @dependency_graph.pagerank
@@ -745,24 +797,35 @@ module Woods
745
797
  )
746
798
  end
747
799
 
748
- def write_manifest
800
+ def write_manifest(incremental: false)
749
801
  # Worktree-aware git provenance. In a linked worktree +.git+ is a file
750
802
  # pointing at the real git dir; when that dir is unreachable (e.g. an
751
803
  # unmounted host path inside a container) this resolves to "unknown"
752
804
  # rather than a stale GIT_BRANCH/GIT_SHA build arg. See GitProvenance (#137).
753
805
  provenance = GitProvenance.new(root: Rails.root).to_h
754
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
+
755
818
  manifest = {
756
819
  extracted_at: Time.current.iso8601,
757
820
  rails_version: Rails.version,
758
821
  ruby_version: RUBY_VERSION,
759
822
 
760
823
  # Counts by type
761
- counts: @results.transform_values(&:size),
824
+ counts: counts,
762
825
 
763
826
  # Total stats
764
- total_units: @results.values.sum(&:size),
765
- total_chunks: @results.sum { |_, units| units.sum { |u| u.chunks.size } },
827
+ total_units: counts.values.sum,
828
+ total_chunks: total_chunks,
766
829
 
767
830
  # Git provenance (branch/sha), or "unknown" when unresolvable
768
831
  git_sha: provenance[:git_sha],
@@ -779,6 +842,30 @@ module Woods
779
842
  )
780
843
  end
781
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
+
782
869
  # Capture a temporal snapshot after extraction completes.
783
870
  #
784
871
  # Reads the manifest and computes per-unit content hashes, then delegates
@@ -915,7 +1002,10 @@ module Woods
915
1002
  identifier: data['identifier'],
916
1003
  file_path: data['file_path'],
917
1004
  namespace: data['namespace'],
918
- 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),
919
1009
  chunk_count: (data['chunks'] || []).size
920
1010
  }
921
1011
  end
@@ -926,6 +1016,20 @@ module Woods
926
1016
  )
927
1017
  end
928
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
+
929
1033
  # ──────────────────────────────────────────────────────────────────────
930
1034
  # Helpers
931
1035
  # ──────────────────────────────────────────────────────────────────────
@@ -1023,19 +1127,36 @@ module Woods
1023
1127
 
1024
1128
  return unless unit
1025
1129
 
1026
- # Update dependency graph
1027
- @dependency_graph.register(unit)
1130
+ # File-based extractors can return several units from one file (a .rake
1131
+ # file defining multiple tasks, etc.); class-based extractors return one.
1132
+ # Normalize to an array so every unit is registered and written — passing
1133
+ # an Array straight to DependencyGraph#register crashes on unit.identifier.
1134
+ units = unit.is_a?(Array) ? unit : [unit]
1135
+ return if units.empty?
1028
1136
 
1029
1137
  # Track which type was affected
1030
1138
  affected_types&.add(extractor_key)
1031
1139
 
1032
- # Write updated unit
1033
1140
  type_dir = @output_dir.join(extractor_key.to_s)
1034
1141
 
1035
- File.write(
1036
- type_dir.join(collision_safe_filename(unit.identifier)),
1037
- json_serialize(unit.to_h)
1038
- )
1142
+ units.each do |extracted|
1143
+ # Update dependency graph. Register BEFORE normalizing the path —
1144
+ # the graph's file_map stores absolute paths (affected_by matches
1145
+ # changed files against them), exactly as full extraction registers
1146
+ # in Phase 1 and only normalizes in Phase 4.5.
1147
+ @dependency_graph.register(extracted)
1148
+
1149
+ # Unit JSON carries Rails.root-relative paths (full extraction's
1150
+ # Phase 4.5); writing the raw absolute source_location here would
1151
+ # leak container-absolute paths into the index after incremental runs.
1152
+ extracted.file_path = normalize_file_path(extracted.file_path)
1153
+
1154
+ # Write updated unit
1155
+ File.write(
1156
+ type_dir.join(collision_safe_filename(extracted.identifier)),
1157
+ json_serialize(extracted.to_h)
1158
+ )
1159
+ end
1039
1160
 
1040
1161
  Rails.logger.info "[Woods] Re-extracted #{unit_id}"
1041
1162
  end
@@ -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