woods 2.0.0 → 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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +135 -10
  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 -18
  10. data/docs/CONSOLE_MCP_SETUP.md +141 -12
  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 +59 -2
  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 +184 -9
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/exe/woods-mcp-http +16 -9
  33. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  34. data/lib/tasks/woods.rake +23 -7
  35. data/lib/tasks/woods_checks.rake +2 -2
  36. data/lib/woods/agent_configuration/cli.rb +1 -1
  37. data/lib/woods/agent_configuration/layout.rb +16 -2
  38. data/lib/woods/agent_configuration/plan.rb +13 -3
  39. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  40. data/lib/woods/agent_configuration/preflight.rb +5 -3
  41. data/lib/woods/builder.rb +17 -57
  42. data/lib/woods/cache/cache_middleware.rb +68 -41
  43. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  44. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  45. data/lib/woods/console/adapter_family.rb +39 -0
  46. data/lib/woods/console/connection_manager.rb +56 -3
  47. data/lib/woods/console/credential_index.rb +33 -3
  48. data/lib/woods/console/embedded_executor.rb +431 -48
  49. data/lib/woods/console/model_validator.rb +8 -0
  50. data/lib/woods/console/rack_middleware.rb +68 -11
  51. data/lib/woods/console/redactor.rb +24 -10
  52. data/lib/woods/console/safe_context.rb +44 -7
  53. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  54. data/lib/woods/console/sql_table_scanner.rb +45 -34
  55. data/lib/woods/console/sql_validator.rb +37 -2
  56. data/lib/woods/dependency_graph.rb +34 -10
  57. data/lib/woods/embedding/fake.rb +12 -0
  58. data/lib/woods/embedding/indexer.rb +195 -98
  59. data/lib/woods/embedding/input_budget.rb +67 -0
  60. data/lib/woods/embedding/openai.rb +70 -20
  61. data/lib/woods/embedding/provider.rb +37 -25
  62. data/lib/woods/embedding/text_preparer.rb +76 -32
  63. data/lib/woods/embedding/token_counter.rb +18 -81
  64. data/lib/woods/embedding/vector_configuration.rb +48 -0
  65. data/lib/woods/extraction_identities.rb +175 -0
  66. data/lib/woods/extractor.rb +304 -107
  67. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  68. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  69. data/lib/woods/extractors/class_declarations.rb +121 -0
  70. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  71. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  72. data/lib/woods/extractors/event_extractor.rb +8 -0
  73. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  74. data/lib/woods/extractors/job_extractor.rb +5 -1
  75. data/lib/woods/extractors/lib_extractor.rb +132 -15
  76. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  77. data/lib/woods/extractors/manager_extractor.rb +7 -21
  78. data/lib/woods/extractors/migration_declaration.rb +87 -0
  79. data/lib/woods/extractors/migration_extractor.rb +5 -39
  80. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  81. data/lib/woods/extractors/policy_extractor.rb +9 -5
  82. data/lib/woods/extractors/poro_extractor.rb +112 -53
  83. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  84. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  85. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  86. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  87. data/lib/woods/extractors/source_nesting.rb +142 -106
  88. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  89. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  90. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  91. data/lib/woods/flow_assembler.rb +4 -1
  92. data/lib/woods/generation.rb +25 -0
  93. data/lib/woods/hooks/context_hint.rb +7 -2
  94. data/lib/woods/mcp/bearer_auth.rb +1 -1
  95. data/lib/woods/mcp/bootstrapper.rb +33 -7
  96. data/lib/woods/mcp/config_resolver.rb +26 -7
  97. data/lib/woods/mcp/index_reader.rb +125 -24
  98. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  99. data/lib/woods/mcp/origin_guard.rb +24 -77
  100. data/lib/woods/mcp/origin_policy.rb +124 -0
  101. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  102. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  103. data/lib/woods/mcp/search_results.rb +7 -1
  104. data/lib/woods/mcp/server.rb +24 -4
  105. data/lib/woods/module_reconciliation.rb +151 -0
  106. data/lib/woods/path_dispatcher.rb +7 -2
  107. data/lib/woods/railtie_support.rb +8 -0
  108. data/lib/woods/rake_helpers.rb +43 -11
  109. data/lib/woods/release.rb +1 -1
  110. data/lib/woods/resilience/index_validator.rb +8 -3
  111. data/lib/woods/resilience/retryable_provider.rb +18 -1
  112. data/lib/woods/resolved_config.rb +68 -8
  113. data/lib/woods/retrieval/context_assembler.rb +3 -3
  114. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  115. data/lib/woods/retrieval/scope.rb +18 -2
  116. data/lib/woods/retrieval/source_evidence.rb +14 -2
  117. data/lib/woods/source_contributor_validation.rb +78 -0
  118. data/lib/woods/source_contributors.rb +116 -0
  119. data/lib/woods/source_inputs/handoff.rb +37 -0
  120. data/lib/woods/source_inputs/launcher.rb +53 -13
  121. data/lib/woods/source_inputs/manifest.rb +84 -3
  122. data/lib/woods/source_inputs/private_key.rb +44 -12
  123. data/lib/woods/source_inputs/scanner.rb +98 -27
  124. data/lib/woods/source_inputs/scopes.rb +1 -1
  125. data/lib/woods/source_inputs/session.rb +147 -15
  126. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  127. data/lib/woods/source_inputs/status.rb +40 -8
  128. data/lib/woods/source_inputs/verifier.rb +28 -5
  129. data/lib/woods/source_path_encoding.rb +33 -0
  130. data/lib/woods/source_references/cache.rb +284 -0
  131. data/lib/woods/source_references/collector.rb +120 -0
  132. data/lib/woods/source_references/extraction.rb +185 -0
  133. data/lib/woods/source_references/inputs.rb +134 -0
  134. data/lib/woods/source_references/parser_adapter.rb +134 -0
  135. data/lib/woods/source_references/pass.rb +152 -0
  136. data/lib/woods/source_references/prism_adapter.rb +116 -0
  137. data/lib/woods/source_references/registry.rb +178 -0
  138. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  139. data/lib/woods/source_references/value_class.rb +82 -0
  140. data/lib/woods/storage/metadata_store.rb +4 -1
  141. data/lib/woods/storage/qdrant.rb +2 -2
  142. data/lib/woods/unblocked/client.rb +12 -7
  143. data/lib/woods/unblocked/document_builder.rb +4 -1
  144. data/lib/woods/unblocked/exporter.rb +127 -37
  145. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  146. data/lib/woods/unblocked/uri_migration.rb +105 -0
  147. data/lib/woods/util/host_guard.rb +3 -2
  148. data/lib/woods/version.rb +1 -1
  149. data/lib/woods/watch/catch_up.rb +138 -0
  150. data/lib/woods/watch/claim_lease.rb +150 -0
  151. data/lib/woods/watch/cli.rb +26 -2
  152. data/lib/woods/watch/daemon.rb +80 -59
  153. data/lib/woods/watch/installation/options.rb +1 -1
  154. data/lib/woods/watch/installation/receipt.rb +6 -1
  155. data/lib/woods/watch/managed_child.rb +1 -1
  156. data/lib/woods/watch/supervisor.rb +1 -1
  157. data/lib/woods/watch/tree_scan.rb +14 -2
  158. data/plugin/.claude-plugin/plugin.json +1 -1
  159. data/plugin/hooks/adapters/normalize.rb +3 -2
  160. data/plugin/hooks/woods-input-rules.sh +4 -0
  161. data/plugin/hooks/woods-refresh.sh +15 -7
  162. data/plugin/hooks/woods-session-start.sh +60 -3
  163. data/plugin/skills/woods-diagnose/SKILL.md +336 -2
  164. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  165. data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
  166. data/plugin/skills/woods-setup/SKILL.md +56 -1
  167. metadata +34 -5
@@ -115,7 +115,7 @@ module Woods
115
115
  # `/*!NNNNN...*/`), capturing the body. Only scanned by
116
116
  # {#lock_clause_views} — {SqlNoiseStripper} deliberately leaves these
117
117
  # markers in place because their meaning is version-dependent.
118
- EXECUTABLE_COMMENT_PATTERN = %r{/\*!(?:\d{5})?(.*?)\*/}m
118
+ EXECUTABLE_COMMENT_PATTERN = %r{/\*(?:M)?!(?:\d{5,6})?(.*?)\*/}m
119
119
 
120
120
  # Forbidden statement prefixes (case-insensitive).
121
121
  #
@@ -321,6 +321,8 @@ module Woods
321
321
 
322
322
  SqliteReadGuard.validate!(sql) if dialect == :sqlite
323
323
  normalized = sql.strip
324
+ check_balanced_delimiters!(normalized)
325
+ check_supported_identifier_syntax!(normalized)
324
326
 
325
327
  # Reject multiple statements (semicolons not inside string literals)
326
328
  if contains_multiple_statements?(normalized)
@@ -372,6 +374,35 @@ module Woods
372
374
 
373
375
  private
374
376
 
377
+ # The policy scanners retain ordinary quoted identifiers but do not
378
+ # decode PostgreSQL Unicode escapes. Refuse that grammar before the
379
+ # adapter can resolve an identifier differently from the policy gates.
380
+ def check_supported_identifier_syntax!(sql)
381
+ return if @dialect && @dialect != :postgres
382
+
383
+ stripped = SqlNoiseStripper.strip_noise(sql, dialect: :postgres)
384
+ tokens = stripped.scan(/"(?:[^"]|"")*"|(?<![A-Za-z0-9_$\u0080-\u{10ffff}])[uU]&"/)
385
+ return unless tokens.any? { |token| token.start_with?('U&"', 'u&"') }
386
+
387
+ raise SqlValidationError,
388
+ 'Rejected: PostgreSQL escaped identifiers are unsupported; use ordinary quoted identifiers ' \
389
+ 'or a structured Console tool.'
390
+ end
391
+
392
+ # A fragment must not escape a later row-limit wrapper. Noise and
393
+ # quoted identifiers shield their punctuation from the balance check.
394
+ # This is a structural boundary check, not a complete SQL parser.
395
+ def check_balanced_delimiters!(sql)
396
+ stripped = strip_validation_noise(sql)
397
+ depth = 0
398
+ stripped.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]/).each do |token|
399
+ depth += 1 if token == '('
400
+ depth -= 1 if token == ')'
401
+ raise SqlValidationError, 'Rejected: unbalanced SQL parentheses' if depth.negative?
402
+ end
403
+ raise SqlValidationError, 'Rejected: unbalanced SQL parentheses' unless depth.zero?
404
+ end
405
+
375
406
  def unknown_grammar?
376
407
  dialect.nil? || (dialect == :mysql && @mysql_modes.nil?)
377
408
  end
@@ -388,7 +419,11 @@ module Woods
388
419
  end
389
420
 
390
421
  def strip_validation_noise(sql)
391
- SqlNoiseStripper.strip_noise(sql, dialect: validation_dialect, **(@mysql_modes || {}))
422
+ SqlNoiseStripper.strip_noise(sql, dialect: validation_dialect, **(@mysql_modes || {})) do |comment|
423
+ next unless comment.match?(/['"`$\\]/)
424
+
425
+ raise SqlValidationError, 'Rejected: quoted executable comments have ambiguous SQL grammar; use ordinary SQL.'
426
+ end
392
427
  end
393
428
 
394
429
  def validation_dialect
@@ -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