woods 2.0.1 → 2.1.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -7
  3. data/CONTRIBUTING.md +134 -19
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -25
  10. data/docs/CONSOLE_MCP_SETUP.md +82 -30
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +20 -15
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +153 -38
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  33. data/lib/tasks/woods.rake +23 -7
  34. data/lib/tasks/woods_checks.rake +2 -2
  35. data/lib/woods/agent_configuration/cli.rb +1 -1
  36. data/lib/woods/agent_configuration/layout.rb +16 -2
  37. data/lib/woods/agent_configuration/plan.rb +13 -3
  38. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  39. data/lib/woods/agent_configuration/preflight.rb +5 -3
  40. data/lib/woods/builder.rb +17 -57
  41. data/lib/woods/cache/cache_middleware.rb +56 -30
  42. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  43. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  44. data/lib/woods/console/connection_manager.rb +56 -3
  45. data/lib/woods/console/embedded_executor.rb +30 -5
  46. data/lib/woods/console/rack_middleware.rb +29 -1
  47. data/lib/woods/dependency_graph.rb +34 -10
  48. data/lib/woods/embedding/fake.rb +12 -0
  49. data/lib/woods/embedding/indexer.rb +195 -98
  50. data/lib/woods/embedding/input_budget.rb +67 -0
  51. data/lib/woods/embedding/openai.rb +70 -20
  52. data/lib/woods/embedding/provider.rb +37 -25
  53. data/lib/woods/embedding/text_preparer.rb +76 -32
  54. data/lib/woods/embedding/token_counter.rb +18 -81
  55. data/lib/woods/embedding/vector_configuration.rb +48 -0
  56. data/lib/woods/extraction_identities.rb +175 -0
  57. data/lib/woods/extractor.rb +304 -107
  58. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  59. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  60. data/lib/woods/extractors/class_declarations.rb +121 -0
  61. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  62. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  63. data/lib/woods/extractors/event_extractor.rb +8 -0
  64. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  65. data/lib/woods/extractors/job_extractor.rb +5 -1
  66. data/lib/woods/extractors/lib_extractor.rb +132 -15
  67. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  68. data/lib/woods/extractors/manager_extractor.rb +7 -21
  69. data/lib/woods/extractors/migration_declaration.rb +87 -0
  70. data/lib/woods/extractors/migration_extractor.rb +5 -39
  71. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  72. data/lib/woods/extractors/policy_extractor.rb +9 -5
  73. data/lib/woods/extractors/poro_extractor.rb +112 -53
  74. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  75. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  76. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  77. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  78. data/lib/woods/extractors/source_nesting.rb +142 -106
  79. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  80. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  81. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  82. data/lib/woods/flow_assembler.rb +4 -1
  83. data/lib/woods/generation.rb +25 -0
  84. data/lib/woods/hooks/context_hint.rb +7 -2
  85. data/lib/woods/mcp/bootstrapper.rb +33 -7
  86. data/lib/woods/mcp/config_resolver.rb +26 -7
  87. data/lib/woods/mcp/index_reader.rb +125 -24
  88. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  89. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  90. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  91. data/lib/woods/mcp/search_results.rb +7 -1
  92. data/lib/woods/mcp/server.rb +24 -4
  93. data/lib/woods/module_reconciliation.rb +151 -0
  94. data/lib/woods/path_dispatcher.rb +7 -2
  95. data/lib/woods/rake_helpers.rb +43 -11
  96. data/lib/woods/release.rb +1 -1
  97. data/lib/woods/resilience/index_validator.rb +8 -3
  98. data/lib/woods/resilience/retryable_provider.rb +18 -1
  99. data/lib/woods/resolved_config.rb +68 -8
  100. data/lib/woods/retrieval/context_assembler.rb +3 -3
  101. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  102. data/lib/woods/retrieval/scope.rb +18 -2
  103. data/lib/woods/retrieval/source_evidence.rb +14 -2
  104. data/lib/woods/source_contributor_validation.rb +78 -0
  105. data/lib/woods/source_contributors.rb +116 -0
  106. data/lib/woods/source_inputs/handoff.rb +37 -0
  107. data/lib/woods/source_inputs/launcher.rb +53 -13
  108. data/lib/woods/source_inputs/manifest.rb +84 -3
  109. data/lib/woods/source_inputs/private_key.rb +44 -12
  110. data/lib/woods/source_inputs/scanner.rb +98 -27
  111. data/lib/woods/source_inputs/scopes.rb +1 -1
  112. data/lib/woods/source_inputs/session.rb +147 -15
  113. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  114. data/lib/woods/source_inputs/status.rb +40 -8
  115. data/lib/woods/source_inputs/verifier.rb +28 -5
  116. data/lib/woods/source_path_encoding.rb +33 -0
  117. data/lib/woods/source_references/cache.rb +284 -0
  118. data/lib/woods/source_references/collector.rb +120 -0
  119. data/lib/woods/source_references/extraction.rb +185 -0
  120. data/lib/woods/source_references/inputs.rb +134 -0
  121. data/lib/woods/source_references/parser_adapter.rb +134 -0
  122. data/lib/woods/source_references/pass.rb +152 -0
  123. data/lib/woods/source_references/prism_adapter.rb +116 -0
  124. data/lib/woods/source_references/registry.rb +178 -0
  125. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  126. data/lib/woods/source_references/value_class.rb +82 -0
  127. data/lib/woods/storage/metadata_store.rb +4 -1
  128. data/lib/woods/storage/qdrant.rb +2 -2
  129. data/lib/woods/unblocked/client.rb +12 -7
  130. data/lib/woods/unblocked/document_builder.rb +4 -1
  131. data/lib/woods/unblocked/exporter.rb +127 -37
  132. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  133. data/lib/woods/unblocked/uri_migration.rb +105 -0
  134. data/lib/woods/util/host_guard.rb +3 -2
  135. data/lib/woods/version.rb +1 -1
  136. data/lib/woods/watch/catch_up.rb +138 -0
  137. data/lib/woods/watch/claim_lease.rb +150 -0
  138. data/lib/woods/watch/cli.rb +26 -2
  139. data/lib/woods/watch/daemon.rb +80 -59
  140. data/lib/woods/watch/installation/options.rb +1 -1
  141. data/lib/woods/watch/installation/receipt.rb +6 -1
  142. data/lib/woods/watch/managed_child.rb +1 -1
  143. data/lib/woods/watch/supervisor.rb +1 -1
  144. data/lib/woods/watch/tree_scan.rb +14 -2
  145. data/plugin/.claude-plugin/plugin.json +1 -1
  146. data/plugin/hooks/adapters/normalize.rb +3 -2
  147. data/plugin/hooks/woods-input-rules.sh +4 -0
  148. data/plugin/hooks/woods-refresh.sh +15 -7
  149. data/plugin/hooks/woods-session-start.sh +60 -3
  150. data/plugin/skills/woods-diagnose/SKILL.md +334 -11
  151. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  152. data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
  153. data/plugin/skills/woods-setup/SKILL.md +53 -6
  154. metadata +32 -5
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'chunk'
4
+ require_relative 'contributor_chunks'
4
5
 
5
6
  module Woods
6
7
  module Chunking
@@ -181,11 +182,8 @@ module Woods
181
182
  # Default token threshold below which units stay whole.
182
183
  DEFAULT_THRESHOLD = 200
183
184
 
184
- # Minimum chars-per-slice budget during tokenizer-driven recursive
185
- # splitting. Prevents unbounded halving on pathological content
186
- # (e.g., a single 2000-char regex line that tokenizes into 6000
187
- # tokens because BERT WordPiece fragments every `\w+` boundary).
188
- MIN_SLICE_CHARS = 256
185
+ # Stop at one Unicode character and refuse if even that cannot fit.
186
+ MIN_SLICE_CHARS = 1
189
187
  private_constant :MIN_SLICE_CHARS
190
188
 
191
189
  # @param threshold [Integer] Token count threshold for chunking
@@ -222,7 +220,8 @@ module Woods
222
220
  # @return [Array<Chunk>] Ordered list of chunks
223
221
  def chunk(unit)
224
222
  return [] if unit.source_code.nil? || unit.source_code.strip.empty?
225
- return [build_whole_chunk(unit)] if unit.estimated_tokens <= @threshold
223
+ return enforce_char_limit(ContributorChunks.chunks(unit), unit) if SourceContributors.multiple?(unit)
224
+ return enforce_char_limit([build_whole_chunk(unit)], unit) if unit.estimated_tokens <= @threshold
226
225
 
227
226
  enforce_char_limit(chunks_for(unit), unit)
228
227
  end
@@ -247,6 +246,11 @@ module Woods
247
246
  unit.chunks = unit.chunks.flat_map { |chunk| split_oversize_hash_chunk(chunk) }
248
247
  end
249
248
 
249
+ def preparation_identity
250
+ { 'class' => self.class.name, 'contributors_version' => 1, 'threshold' => @threshold, 'max_chars' => @max_chars,
251
+ 'max_tokens' => @max_tokens, 'counter' => @token_counter&.class&.name }
252
+ end
253
+
250
254
  private
251
255
 
252
256
  # True when either the char ceiling or the token-based verifier is
@@ -270,12 +274,20 @@ module Woods
270
274
  # @param chunk [Hash] a unit-chunk hash (symbol or string keys)
271
275
  # @return [Array<Hash>]
272
276
  def split_oversize_hash_chunk(chunk)
273
- content = chunk[:content] || chunk['content']
277
+ chunk = chunk.transform_keys(&:to_sym)
278
+ content = chunk[:content]
274
279
  return [chunk] if content.nil? || !oversize?(content)
275
280
 
276
- chunk_type = chunk[:chunk_type] || chunk['chunk_type'] || :whole
281
+ chunk_type = chunk[:chunk_type] || :whole
282
+ offset = SourceContributors.field(chunk[:embedding_slice], :start_byte) || 0
283
+ initial_offset = offset
277
284
  verified_slices(content).each_with_index.map do |slice, idx|
278
- { content: slice, chunk_type: :"#{chunk_type}_part_#{idx}" }
285
+ first = offset
286
+ offset += slice.bytesize
287
+ metadata = ContributorChunks.slice_metadata(chunk[:metadata], content, first - initial_offset,
288
+ offset - initial_offset)
289
+ chunk.merge(content: slice, chunk_type: :"#{chunk_type}_part_#{idx}", metadata: metadata,
290
+ embedding_slice: { start_byte: first, end_byte: offset })
279
291
  end
280
292
  end
281
293
 
@@ -332,12 +344,16 @@ module Woods
332
344
  def split_oversize_chunk(chunk, unit)
333
345
  return [chunk] unless oversize?(chunk.content)
334
346
 
347
+ offset = 0
335
348
  verified_slices(chunk.content).each_with_index.map do |slice, idx|
349
+ first = offset
350
+ offset += slice.bytesize
336
351
  Chunk.new(
337
352
  content: slice,
338
353
  chunk_type: :"#{chunk.chunk_type}_part_#{idx}",
339
354
  parent_identifier: unit.identifier,
340
- parent_type: unit.type
355
+ parent_type: unit.type,
356
+ metadata: ContributorChunks.slice_metadata(chunk.metadata, chunk.content, first, offset)
341
357
  )
342
358
  end
343
359
  end
@@ -359,9 +375,8 @@ module Woods
359
375
  end
360
376
 
361
377
  # Ensure a single post-line-split slice fits the token budget.
362
- # Halves the char limit and reslices if it doesn't. Stops at
363
- # {MIN_SLICE_CHARS} to avoid unbounded recursion on content that
364
- # cannot be split line-wise (minified output, huge regex literals).
378
+ # Halves the character limit and reslices if necessary. A minimum-sized
379
+ # slice that still exceeds the counter's limit is refused, never emitted.
365
380
  #
366
381
  # @param slice [String]
367
382
  # @param char_limit [Integer]
@@ -370,7 +385,9 @@ module Woods
370
385
  return [slice] unless @token_counter.count(slice) > @max_tokens
371
386
 
372
387
  smaller = char_limit / 2
373
- return [slice] if smaller < MIN_SLICE_CHARS
388
+ if smaller < MIN_SLICE_CHARS || slice.length <= 1
389
+ raise ArgumentError, 'Chunk input limit cannot fit one source character'
390
+ end
374
391
 
375
392
  slice_by_lines(slice, smaller).flat_map { |sub| verify_slice(sub, smaller) }
376
393
  end
@@ -385,13 +402,15 @@ module Woods
385
402
  end
386
403
 
387
404
  # Greedy line-based slicing that respects a supplied `limit`.
388
- # Lines longer than `limit` are hard-cut (lossy — but such lines
389
- # are already pathological: minified JSON dumps, long regexes).
405
+ # Lines longer than `limit` are cut at Unicode character boundaries;
406
+ # concatenating the slices preserves every source character.
390
407
  #
391
408
  # @param content [String]
392
409
  # @param limit [Integer]
393
410
  # @return [Array<String>]
394
411
  def slice_by_lines(content, limit = @max_chars)
412
+ raise ArgumentError, 'Chunk size must be positive' unless limit.positive?
413
+
395
414
  slices = []
396
415
  current = String.new
397
416
  content.each_line do |line|
@@ -658,6 +677,8 @@ module Woods
658
677
  include ChunkBuilder
659
678
  include LineDepthTracking
660
679
 
680
+ METHOD_NAME = /\A\s*def\s+((?:self\s*\.\s*)?(?:#{OPERATOR_METHOD_NAMES}|[[:word:]]+[?!=]?))/
681
+
661
682
  # @param unit [ExtractedUnit]
662
683
  def initialize(unit)
663
684
  @unit = unit
@@ -677,7 +698,7 @@ module Woods
677
698
  # @return [Hash]
678
699
  def parse_lines(lines)
679
700
  state = {
680
- summary: [], methods: {}, private_methods: [],
701
+ summary: [], methods: {}, method_occurrences: Hash.new(0), private_methods: [],
681
702
  current_method: nil, depth: 0, in_private: false
682
703
  }
683
704
  lines.each do |line|
@@ -725,14 +746,16 @@ module Woods
725
746
  end
726
747
 
727
748
  def start_method(state, line)
728
- method_name = line[/def\s+(?:self\.)?(\w+)/, 1] || operator_def_name(line)
749
+ method_name = line[METHOD_NAME, 1]&.delete(" \t")
729
750
 
730
751
  if state[:in_private]
731
752
  state[:private_methods] << line
732
753
  else
733
- # Preserve insertion order — Hash does this by default, but we
734
- # initialize the entry here so `build_chunks` below walks
735
- # methods in source order.
754
+ # Repeated definitions (nested owners, singleton bodies, or inlined
755
+ # overrides) must never overwrite earlier source. Preserve existing
756
+ # keys for unique methods and number later occurrences in source order.
757
+ occurrence = state[:method_occurrences][method_name] += 1
758
+ method_name = "#{method_name}@#{occurrence}" if occurrence > 1
736
759
  state[:methods][method_name] = [line]
737
760
  end
738
761
 
@@ -17,6 +17,12 @@ module Woods
17
17
  # process with a direct, Docker, or SSH command.
18
18
  class ConnectionManager
19
19
  DEFAULT_COMMAND = 'bundle exec rake woods:console'
20
+ MODE_OPTIONS = {
21
+ 'direct' => %w[mode command directory],
22
+ 'docker' => %w[mode command container],
23
+ 'ssh' => %w[mode command host user]
24
+ }.freeze
25
+ CONFIG_KEYS = MODE_OPTIONS.values.flatten.uniq.freeze
20
26
 
21
27
  # @param config [Hash] Process-launch configuration
22
28
  # @option config [String] 'mode' direct, docker, or ssh (default: direct)
@@ -27,8 +33,9 @@ module Woods
27
33
  # @option config [String] 'user' Optional SSH user
28
34
  def initialize(config:)
29
35
  @config = config
36
+ validate_config!
30
37
  @mode = config.fetch('mode', 'direct')
31
- @embedded_command = config.fetch('command', DEFAULT_COMMAND)
38
+ @embedded_command = config['command']
32
39
  end
33
40
 
34
41
  # Return the argv used to launch the embedded MCP server.
@@ -36,6 +43,7 @@ module Woods
36
43
  # @return [Array<String>]
37
44
  # @raise [ConnectionError] when the mode is invalid or incomplete
38
45
  def command
46
+ validate_mode_options!
39
47
  case @mode
40
48
  when 'direct' then embedded_argv
41
49
  when 'docker' then docker_command
@@ -53,10 +61,11 @@ module Woods
53
61
  # @return [void]
54
62
  # @raise [ConnectionError] when the process cannot be launched
55
63
  def replace_process!
64
+ argv = command
56
65
  if @mode == 'direct' && @config['directory']
57
- Dir.chdir(@config['directory']) { exec(*command) }
66
+ Dir.chdir(@config['directory']) { exec(*argv) }
58
67
  else
59
- exec(*command)
68
+ exec(*argv)
60
69
  end
61
70
  rescue SystemCallError, ArgumentError => e
62
71
  raise ConnectionError, "Failed to launch embedded Console MCP (#{@mode}): #{e.message}"
@@ -64,7 +73,43 @@ module Woods
64
73
 
65
74
  private
66
75
 
76
+ def validate_config!
77
+ raise ConnectionError, 'Console configuration must be a YAML mapping' unless @config.is_a?(Hash)
78
+ raise ConnectionError, 'Console configuration keys must be strings' unless @config.keys.all?(String)
79
+
80
+ unknown = @config.keys - CONFIG_KEYS
81
+ unless unknown.empty?
82
+ raise ConnectionError,
83
+ "Unsupported console.yml keys: #{unknown.map(&:inspect).join(', ')}. " \
84
+ "Use top-level #{CONFIG_KEYS.join(', ')} launch options; " \
85
+ 'configure access controls and redaction in the Rails initializer.'
86
+ end
87
+
88
+ @config.each do |key, value|
89
+ next if valid_launch_string?(value)
90
+
91
+ raise ConnectionError, "Console #{key} must be a non-empty string without NUL bytes"
92
+ end
93
+ end
94
+
95
+ def valid_launch_string?(value)
96
+ value.is_a?(String) && !value.strip.empty? && !value.include?("\0")
97
+ end
98
+
99
+ def validate_mode_options!
100
+ accepted = MODE_OPTIONS[@mode]
101
+ return unless accepted
102
+
103
+ unused = @config.keys - accepted
104
+ return if unused.empty?
105
+
106
+ raise ConnectionError,
107
+ "Console options #{unused.join(', ')} are not used in #{@mode} mode; select the intended mode"
108
+ end
109
+
67
110
  def embedded_argv
111
+ return default_argv unless @embedded_command
112
+
68
113
  argv = @embedded_command.to_s.shellsplit
69
114
  raise ConnectionError, 'Console command must not be empty' if argv.empty?
70
115
 
@@ -73,6 +118,14 @@ module Woods
73
118
  raise ConnectionError, "Invalid console command: #{e.message}"
74
119
  end
75
120
 
121
+ def default_argv
122
+ if @mode == 'direct'
123
+ binstub = File.expand_path('bin/rake', @config.fetch('directory', Dir.pwd))
124
+ return [binstub, 'woods:console'] if File.file?(binstub) && File.executable?(binstub)
125
+ end
126
+ DEFAULT_COMMAND.shellsplit
127
+ end
128
+
76
129
  def docker_command
77
130
  container = @config['container'] || raise(ConnectionError, 'Docker mode requires container name')
78
131
  ['docker', 'exec', '-i', container] + embedded_argv
@@ -620,9 +620,9 @@ module Woods
620
620
 
621
621
  def handle_association_count(params)
622
622
  model = resolve_model(params['model'])
623
- association_name = params['association']
623
+ association_name = params['association'].to_sym
624
624
  requested_scope = params['scope']
625
- reflection = model.reflect_on_association(association_name.to_sym)
625
+ reflection = model.reflect_on_association(association_name)
626
626
 
627
627
  raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}" unless reflection
628
628
 
@@ -637,13 +637,38 @@ module Woods
637
637
  # association's own model before any database I/O runs (not just
638
638
  # before the association is read): `model.find` below is itself a
639
639
  # query, and a request with a bad scope should never reach it.
640
- validate_scope_columns!(requested_scope, reflection.klass.name) if requested_scope
640
+ validate_association_scope!(requested_scope, reflection)
641
641
 
642
642
  record = checked_relation(model).find(params['id'])
643
- scope = record.public_send(association_name)
643
+ association = record.association(association_name)
644
+ return { 'count' => 0 } if empty_polymorphic_association?(association, reflection)
645
+
646
+ scope = association.scope
647
+ return { 'count' => 0 } unless scope
648
+
644
649
  scope = apply_scope(scope, requested_scope, model_name: reflection.klass.name) if requested_scope
645
650
  gate_association_sql!(scope)
646
- { 'count' => scope.count }
651
+ { 'count' => association_count(scope, reflection) }
652
+ end
653
+
654
+ def validate_association_scope!(scope, reflection)
655
+ return unless scope
656
+
657
+ if reflection.respond_to?(:polymorphic?) && reflection.polymorphic?
658
+ raise ValidationError, 'Rejected: scoped polymorphic association counts require a concrete target model.'
659
+ end
660
+
661
+ validate_scope_columns!(scope, reflection.klass.name)
662
+ end
663
+
664
+ def empty_polymorphic_association?(association, reflection)
665
+ reflection.respond_to?(:polymorphic?) && reflection.polymorphic? && association.klass.nil?
666
+ end
667
+
668
+ def association_count(scope, reflection)
669
+ return scope.count if reflection.collection?
670
+
671
+ scope.exists? ? 1 : 0
647
672
  end
648
673
 
649
674
  # Materialize a model's default scope once, inspect the resolved SQL,
@@ -73,6 +73,12 @@ module Woods
73
73
  # disabled when the host requires a narrower database capability.
74
74
  #
75
75
  class RackMiddleware # rubocop:disable Metrics/ClassLength
76
+ EAGER_LOAD_UNAVAILABLE = JSON.generate(
77
+ jsonrpc: '2.0', id: nil,
78
+ error: { code: -32_000,
79
+ message: 'Console unavailable: Rails eager loading failed. ' \
80
+ 'Fix the application load error and restart the server.' }
81
+ ).freeze
76
82
  # @param app [#call] The next Rack app in the middleware stack
77
83
  # @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
78
84
  # @param embedded_read_tools [Boolean, #call] Enable sql/query tools in
@@ -118,6 +124,11 @@ module Woods
118
124
 
119
125
  def handle_request(env)
120
126
  transport = ensure_transport
127
+ unless transport
128
+ return [503, { 'content-type' => 'application/json', 'cache-control' => 'no-store' },
129
+ [EAGER_LOAD_UNAVAILABLE]]
130
+ end
131
+
121
132
  request_env = env.dup
122
133
  request_env.delete('HTTP_MCP_SESSION_ID') if @stateless_mode
123
134
  transport.handle_request(Rack::Request.new(request_env))
@@ -168,14 +179,16 @@ module Woods
168
179
  # @return [::MCP::Server::Transports::StreamableHTTPTransport]
169
180
  def ensure_transport
170
181
  return @transport if @transport
182
+ return if @eager_load_failed
171
183
 
172
184
  @mutex.synchronize do
173
185
  return @transport if @transport
186
+ return if @eager_load_failed
174
187
 
175
188
  check_blocked_tables_config!
176
189
 
177
190
  require 'woods/console/server'
178
- Rails.application.eager_load!
191
+ return unless eager_load_application
179
192
 
180
193
  server = build_embedded_server
181
194
  @stateless_mode = resolve_deferred(@stateless)
@@ -187,6 +200,21 @@ module Woods
187
200
  end
188
201
  end
189
202
 
203
+ # A failed loader may have registered only part of the application.
204
+ # Keep this worker unavailable rather than retrying against partial state.
205
+ def eager_load_application
206
+ Rails.application.eager_load!
207
+ true
208
+ rescue NameError => e
209
+ @eager_load_failed = true
210
+ structured_logger.error(
211
+ 'console.eager_load.failed', error_class: e.class.name,
212
+ remediation: 'Run Rails.application.eager_load! in the application ' \
213
+ 'environment; fix the error and restart.'
214
+ )
215
+ false
216
+ end
217
+
190
218
  # Emit a prominent warning (or raise in production) when the Console MCP
191
219
  # is enabled but no tables are blocked. An empty block list means Layer 1
192
220
  # of the defense stack is fully inactive — every table in the database is
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'set'
4
4
  require 'json'
5
+ require_relative 'source_contributors'
5
6
 
6
7
  module Woods
7
8
  # DependencyGraph tracks relationships between code units for:
@@ -32,7 +33,7 @@ module Woods
32
33
  # enforce_dependencies: package units (Task 7)
33
34
  # commit_count, change_frequency: git enrichment (Task 5)
34
35
  NODE_ATTRIBUTE_KEYS = %i[
35
- database table foreign_key_tables package enforce_dependencies commit_count change_frequency kind
36
+ database table foreign_key_tables package enforce_dependencies commit_count change_frequency kind source_paths
36
37
  ].freeze
37
38
 
38
39
  # These extractors describe a whole source file rather than one constant.
@@ -86,7 +87,9 @@ module Woods
86
87
 
87
88
  (@edges[unit.identifier] ||= {})[unit.type] =
88
89
  self.class.normalize_edges(unit.dependencies, strict: true)
89
- (@file_map[unit.file_path] ||= Set.new).add(unit.identifier) if unit.file_path
90
+ node_paths(@nodes[unit.identifier][unit.type]).each do |path|
91
+ (@file_map[path] ||= Set.new).add(unit.identifier)
92
+ end
90
93
 
91
94
  # Type index for filtering (Set-based for O(1) insert)
92
95
  (@type_index[unit.type] ||= Set.new).add(unit.identifier)
@@ -126,7 +129,7 @@ module Woods
126
129
  old_node = @nodes[identifier]&.[](t)
127
130
  next unless old_node
128
131
 
129
- drop_from_file_map(identifier, old_node[:file_path], withdrawing)
132
+ node_paths(old_node).each { |path| drop_from_file_map(identifier, path, withdrawing) }
130
133
  drop_from_type_index(identifier, old_node[:type])
131
134
  end
132
135
  end
@@ -187,7 +190,7 @@ module Woods
187
190
  return unless path
188
191
 
189
192
  return if (@nodes[identifier] || {}).any? do |type, node|
190
- !withdrawing.include?(type) && node[:file_path] == path
193
+ !withdrawing.include?(type) && node_paths(node).include?(path)
191
194
  end
192
195
 
193
196
  return unless (ids = @file_map[path])
@@ -425,8 +428,9 @@ module Woods
425
428
  def units_for_path(file_path)
426
429
  (@file_map[file_path] || []).flat_map do |identifier|
427
430
  nodes = sorted_nodes(@nodes[identifier] || {})
428
- at_path = nodes.select { |_, node| node[:file_path] == file_path }
429
- (at_path.empty? ? nodes : at_path).map { |type, _| [identifier, type] }
431
+ at_path = nodes.select { |_, node| node_paths(node).include?(file_path) }
432
+ legacy = at_path.empty? && nodes.none? { |_, node| node.key?(:source_paths) }
433
+ (legacy ? nodes : at_path).map { |type, _| [identifier, type] }
430
434
  end
431
435
  end
432
436
 
@@ -640,6 +644,13 @@ module Woods
640
644
  @suffix_groups = groups
641
645
  end
642
646
 
647
+ # @param node [Hash] one typed graph node
648
+ # @return [Array<String>] every registered physical source
649
+ def node_paths(node)
650
+ node[:source_paths] || Array(node[:file_path])
651
+ end
652
+ private :node_paths
653
+
643
654
  # Node attributes a unit's metadata supplies at registration time.
644
655
  #
645
656
  # `table` is taken only from model units and `enforce_dependencies` only
@@ -655,6 +666,11 @@ module Woods
655
666
  attrs[:table] = metadata[:table_name] if unit.type == :model && !metadata[:table_name].nil?
656
667
  tables = Array(metadata[:foreign_keys]).filter_map { |fk| fk[:to_table] || fk['to_table'] if fk.is_a?(Hash) }
657
668
  attrs[:foreign_key_tables] = tables if tables.any?
669
+ if SourceContributors.multiple?(unit)
670
+ attrs[:source_paths] = SourceContributors.paths(unit).map do |path|
671
+ self.class.absolutize(path, self.class.graph_root)
672
+ end
673
+ end
658
674
  attrs[:package] = metadata[:package] unless metadata[:package].nil?
659
675
  if unit.type == :package && !metadata[:enforce_dependencies].nil?
660
676
  attrs[:enforce_dependencies] = metadata[:enforce_dependencies]
@@ -928,11 +944,18 @@ module Woods
928
944
  File.join(root, path)
929
945
  end
930
946
 
947
+ def self.relocate_node_paths(node, &block)
948
+ result = node.dup
949
+ result[:file_path] = yield(node[:file_path]) if node[:file_path]
950
+ result[:source_paths] = node[:source_paths].map(&block) if node[:source_paths]
951
+ result
952
+ end
953
+
931
954
  def self.relativize_nodes(nodes, root)
932
955
  return nodes if root.nil?
933
956
 
934
957
  nodes.transform_values do |node|
935
- node[:file_path] ? node.merge(file_path: relativize(node[:file_path], root)) : node
958
+ relocate_node_paths(node) { |path| relativize(path, root) }
936
959
  end
937
960
  end
938
961
 
@@ -940,7 +963,7 @@ module Woods
940
963
  return nodes if root.nil?
941
964
 
942
965
  nodes.transform_values do |node|
943
- node[:file_path] ? node.merge(file_path: absolutize(node[:file_path], root)) : node
966
+ relocate_node_paths(node) { |path| absolutize(path, root) }
944
967
  end
945
968
  end
946
969
 
@@ -950,7 +973,7 @@ module Woods
950
973
  return variants if root.nil?
951
974
 
952
975
  variants.map do |record|
953
- record[:file_path] ? record.merge(file_path: relativize(record[:file_path], root)) : record
976
+ relocate_node_paths(record) { |path| relativize(path, root) }
954
977
  end
955
978
  end
956
979
 
@@ -989,7 +1012,7 @@ module Woods
989
1012
  # @return [Object]
990
1013
  def self.normalize_node_attribute(key, value)
991
1014
  case key
992
- when :foreign_key_tables then Array(value).map(&:to_s).uniq.sort
1015
+ when :source_paths, :foreign_key_tables then Array(value).map(&:to_s).uniq.sort
993
1016
  when :commit_count then value.to_i
994
1017
  when :enforce_dependencies then [true, false].include?(value) ? value : value.to_s
995
1018
  else value.to_s
@@ -1124,6 +1147,7 @@ module Woods
1124
1147
  file_path: absolutize(record[:file_path] || record['file_path'], root),
1125
1148
  namespace: record[:namespace] || record['namespace']
1126
1149
  }.merge(persisted_node_attributes(record))
1150
+ nodes[identifier][type] = relocate_node_paths(nodes[identifier][type]) { |path| absolutize(path, root) }
1127
1151
  (edges[identifier] ||= {})[type] = normalize_edges(record[:edges] || record['edges'])
1128
1152
  end
1129
1153
  end
@@ -89,6 +89,18 @@ module Woods
89
89
  @dims
90
90
  end
91
91
 
92
+ alias configured_dimensions dimensions
93
+
94
+ # @return [Array] deterministic, non-secret embedding configuration
95
+ def cache_identity
96
+ [self.class.name, @model, @dims]
97
+ end
98
+
99
+ # Pure constructor settings for restoring the deterministic provider.
100
+ def configuration_options
101
+ { model: @model, dims: @dims }
102
+ end
103
+
92
104
  # @return [String] the configured model name (default
93
105
  # {DEFAULT_MODEL_NAME})
94
106
  def model_name