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
@@ -601,6 +601,8 @@ module Woods
601
601
  respond.call(renderer.render(:search, payload))
602
602
  rescue Retrieval::Scope::InvalidScopeError => e
603
603
  respond_err.call(e.message, code: :unsupported_argument, tool: 'search', argument: 'scope')
604
+ rescue ArgumentError => e
605
+ respond_err.call(e.message, code: :invalid_params, tool: 'search', argument: 'types')
604
606
  rescue IOError, SystemCallError, JSON::ParserError, EncodingError
605
607
  respond_err.call(
606
608
  'Search completeness: unknown (unreadable_or_corrupt_source). ' \
@@ -1169,6 +1171,10 @@ module Woods
1169
1171
  }
1170
1172
  ) do |entry_point:, server_context:, depth: nil|
1171
1173
  max_depth = coerce_int.call(depth) || 3
1174
+ unless reader.find_unit(entry_point.split('#', 2).first)
1175
+ next respond_err.call('Flow entry unit not found; use search and lookup to select an indexed unit.',
1176
+ code: :not_found, tool: 'trace_flow', entry_point: entry_point)
1177
+ end
1172
1178
 
1173
1179
  # Prefer the precomputed flow JSON written by FlowPrecomputer during
1174
1180
  # extraction (gated on `config.precompute_flows`) — it avoids
@@ -1316,6 +1322,7 @@ module Woods
1316
1322
  require_relative '../extractor' unless defined?(Woods::Extractor)
1317
1323
  extractor = Woods::Extractor.new(output_dir: output_dir)
1318
1324
  incremental ? extractor.extract_changed(files) : extractor.extract_all
1325
+ extractor.raise_on_publication_failure!
1319
1326
  end
1320
1327
  end
1321
1328
 
@@ -1893,7 +1900,7 @@ module Woods
1893
1900
 
1894
1901
  def define_snapshot_tools(server, snapshot_store, respond, respond_err, snap_missing)
1895
1902
  define_list_snapshots_tool(server, snapshot_store, respond, snap_missing)
1896
- define_snapshot_diff_tool(server, snapshot_store, respond, snap_missing)
1903
+ define_snapshot_diff_tool(server, snapshot_store, respond, respond_err, snap_missing)
1897
1904
  define_unit_history_tool(server, snapshot_store, respond, snap_missing)
1898
1905
  define_snapshot_detail_tool(server, snapshot_store, respond, respond_err, snap_missing)
1899
1906
  end
@@ -1918,7 +1925,7 @@ module Woods
1918
1925
  end
1919
1926
  end
1920
1927
 
1921
- def define_snapshot_diff_tool(server, snapshot_store, respond, snap_missing)
1928
+ def define_snapshot_diff_tool(server, snapshot_store, respond, respond_err, snap_missing)
1922
1929
  server.define_tool(
1923
1930
  name: 'snapshot_diff',
1924
1931
  description: 'Compare two extraction snapshots by git SHA. Returns lists of added, modified, and deleted units.',
@@ -1932,6 +1939,17 @@ module Woods
1932
1939
  ) do |sha_a:, sha_b:, server_context:|
1933
1940
  next snap_missing.call('snapshot_diff') unless snapshot_store
1934
1941
 
1942
+ unless [sha_a, sha_b].all? { |sha| sha.match?(/\A[0-9a-f]+\z/i) }
1943
+ next respond_err.call('Snapshot SHAs must be hexadecimal identifiers from list_snapshots.',
1944
+ code: :invalid_params, tool: 'snapshot_diff')
1945
+ end
1946
+ sha_a = sha_a.downcase
1947
+ sha_b = sha_b.downcase
1948
+ unless snapshot_store.find(sha_a) && snapshot_store.find(sha_b)
1949
+ next respond_err.call('One or both snapshots were not found; use list_snapshots for available SHAs.',
1950
+ code: :not_found, tool: 'snapshot_diff')
1951
+ end
1952
+
1935
1953
  result = snapshot_store.diff(sha_a, sha_b)
1936
1954
  respond.call(JSON.pretty_generate({
1937
1955
  sha_a: sha_a, sha_b: sha_b,
@@ -1981,6 +1999,7 @@ module Woods
1981
1999
  ) do |git_sha:, server_context:|
1982
2000
  next snap_missing.call('snapshot_detail') unless snapshot_store
1983
2001
 
2002
+ git_sha = git_sha.downcase
1984
2003
  snapshot = snapshot_store.find(git_sha)
1985
2004
  if snapshot
1986
2005
  respond.call(JSON.pretty_generate(snapshot))
@@ -2497,8 +2516,9 @@ module Woods
2497
2516
 
2498
2517
  target = URI::DEFAULT_PARSER.unescape(raw_target).force_encoding(Encoding::UTF_8)
2499
2518
  return unless target.valid_encoding?
2500
- return if target.match?(%r{[%\\/\x00-\x1f\x7f]})
2501
- return if %w[. ..].include?(target)
2519
+ return if target.match?(/[%\\\x00-\x1f\x7f]/) || target.start_with?('/')
2520
+ return if target.split('/').any? { |part| %w[. ..].include?(part) }
2521
+ return if parsed.host == 'type' && target.include?('/')
2502
2522
 
2503
2523
  [parsed.host.to_sym, target]
2504
2524
  rescue URI::InvalidURIError
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ # Keeps callable app/models modules under one extractor owner as model
5
+ # inclusions change. Partial runtime discovery cannot establish removals.
6
+ module ModuleReconciliation # rubocop:disable Metrics/ModuleLength -- one ownership/provenance policy across writers
7
+ private
8
+
9
+ # @param affected_types [Set<Symbol>] extractor keys requiring index updates
10
+ # @return [Set<String>] added, migrated or removed module identifiers
11
+ def reconcile_model_mixins(affected_types)
12
+ concerns = extractor_for(:concerns)
13
+ return Set.new unless concerns.respond_to?(:runtime_model_mixins)
14
+
15
+ live = concerns.runtime_model_mixins
16
+ live_names = live.values.flatten.to_set(&:name)
17
+ known = @dependency_graph.units_of_type(:concern).to_set
18
+ added = newly_claimed_concerns(concerns, live, known)
19
+ touched = register_and_write(:concerns, added, affected_types)
20
+ touched.merge(remove_claimed_standalone_modules(live_names, affected_types))
21
+ touched.merge(remove_unclaimed_concerns(concerns, known - live_names, affected_types)) if @eager_load_complete
22
+ touched.merge(add_standalone_modules(affected_types))
23
+ end
24
+
25
+ def newly_claimed_concerns(concerns, live, known)
26
+ live.flat_map do |path, modules|
27
+ next [] if modules.all? { |mod| known.include?(mod.name) }
28
+
29
+ checked_extraction(:concerns, concerns) do
30
+ Array(concerns.extract_model_mixin_file(path)).reject { |unit| known.include?(unit.identifier) }
31
+ end || []
32
+ end
33
+ end
34
+
35
+ def remove_claimed_standalone_modules(live_names, affected_types)
36
+ live_names.each_with_object(Set.new) do |identifier, touched|
37
+ next unless standalone_module_identity?(identifier)
38
+
39
+ touched.add(identifier) if remove_unit(identifier, affected_types, type: :poro)
40
+ end
41
+ end
42
+
43
+ def remove_unclaimed_concerns(concerns, stale, affected_types)
44
+ stale.each_with_object(Set.new) do |identifier, touched|
45
+ path = @dependency_graph.node(identifier, type: :concern)[:file_path]
46
+ next if concerns.conventional_concern_path?(path)
47
+
48
+ touched.add(identifier) if remove_unit(identifier, affected_types, type: :concern)
49
+ end
50
+ end
51
+
52
+ def runtime_model_mixin_file?(extractor, type, path)
53
+ type == :concern && extractor.respond_to?(:extract_model_mixin_file) &&
54
+ extractor.respond_to?(:conventional_concern_path?) && !extractor.conventional_concern_path?(path)
55
+ end
56
+
57
+ def add_standalone_modules(affected_types)
58
+ poros = extractor_for(:poros)
59
+ return Set.new unless poros.respond_to?(:standalone_modules)
60
+
61
+ discovered = checked_extraction(:poros, poros) { poros.standalone_modules }
62
+ return Set.new unless discovered
63
+
64
+ added = discovered.values.flatten.reject do |unit|
65
+ @dependency_graph.node(unit.identifier, type: :poro) ||
66
+ @dependency_graph.node(unit.identifier, type: :concern)
67
+ end
68
+ register_and_write(:poros, added, affected_types)
69
+ end
70
+
71
+ def standalone_module_identity?(identifier)
72
+ return false unless @dependency_graph.node(identifier, type: :poro)
73
+
74
+ path = payload_dir.join('poros', collision_safe_filename(identifier))
75
+ return false unless path.file?
76
+
77
+ JSON.parse(AtomicFile.read(path)).fetch('metadata', {})['ruby_kind'] == 'module'
78
+ end
79
+
80
+ # A missing includer during fallback boot does not transfer its previously
81
+ # published concern to the PORO extractor. Filter fresh candidates too.
82
+ def authoritative_module_units(units)
83
+ return units if @eager_load_complete
84
+
85
+ units.reject do |unit|
86
+ unit.type == :poro && unit.metadata[:ruby_kind] == 'module' &&
87
+ @dependency_graph.node(unit.identifier, type: :concern)
88
+ end
89
+ end
90
+
91
+ # @param identifier [String] published typed identity
92
+ # @param type [Symbol] current owner
93
+ # @return [Boolean] whether incomplete loading requires retaining this unit
94
+ def retain_partial_module?(identifier, type)
95
+ return false if @eager_load_complete
96
+
97
+ return false unless runtime_module_identity?(identifier, type)
98
+ return false if type == :poro && live_concern_identity?(identifier)
99
+
100
+ verify_retained_module_source!(identifier, type)
101
+ true
102
+ end
103
+
104
+ def runtime_module_identity?(identifier, type)
105
+ return standalone_module_identity?(identifier) if type == :poro
106
+ return false unless type == :concern
107
+
108
+ concerns = extractor_for(:concerns)
109
+ path = @dependency_graph.node(identifier, type: type)[:file_path]
110
+ concerns.respond_to?(:conventional_concern_path?) && !concerns.conventional_concern_path?(path)
111
+ end
112
+
113
+ def live_concern_identity?(identifier)
114
+ concerns = extractor_for(:concerns)
115
+ concerns.respond_to?(:runtime_model_mixins) &&
116
+ concerns.runtime_model_mixins.values.any? { |modules| modules.any? { |mod| mod.name == identifier } }
117
+ end
118
+
119
+ # Cache identities predate every consumption update in this run. A freshly
120
+ # written shared-file sibling must not certify retained module metadata as
121
+ # having consumed changed source during incomplete eager loading.
122
+ def verify_retained_module_source!(identifier, type)
123
+ return unless @source_inputs
124
+
125
+ path = @dependency_graph.node(identifier, type: type)[:file_path]
126
+ relative = File.expand_path(path, Rails.root).delete_prefix("#{File.expand_path(Rails.root)}/")
127
+ before = @source_reference_baseline&.dig('files', relative, 'identity')
128
+ return if before && before == @source_inputs.source_identity(path)
129
+
130
+ raise SourceReferences::RebuildRequired,
131
+ "Cannot refresh retained #{type}:#{identifier} after incomplete eager loading. " \
132
+ 'Retry in a fresh Rails process with a complete eager load; the previous generation remains active.'
133
+ end
134
+
135
+ def consume_module_aware_refresh(key, units)
136
+ if !@eager_load_complete && %i[poros concerns].include?(key)
137
+ @source_inputs&.unverified("extractor:#{key}")
138
+ else
139
+ @source_inputs&.consume_extractor(key, units)
140
+ end
141
+ end
142
+
143
+ # These formerly file-only families now retain runtime-discovered modules
144
+ # on a partial boot, so their replacement authority follows the same gate.
145
+ def replacement_discovery_complete?(key)
146
+ return @eager_load_complete if %i[poros concerns].include?(key)
147
+
148
+ super
149
+ end
150
+ end
151
+ end
@@ -165,7 +165,7 @@ module Woods
165
165
  file_rule(:migrations, :extract_migration_file, %w[db/migrate], recursive: false),
166
166
  # POROs are app/models classes that are *not* ActiveRecord models;
167
167
  # the extractor makes that call itself given ar_names.
168
- file_rule(:poros, :extract_poro_file, %w[app/models], exclude: %w[/concerns/]),
168
+ file_rule(:poros, :extract_poro_units, %w[app/models], exclude: %w[/concerns/]),
169
169
  file_rule(:libs, :extract_lib_file, %w[lib], exclude: ex::LibExtractor::EXCLUDED_SEGMENTS),
170
170
  file_rule(:test_mappings, :extract_test_file, %w[spec], extensions: %w[_spec.rb]),
171
171
  file_rule(:test_mappings, :extract_test_file, %w[test], extensions: %w[_test.rb])
@@ -184,11 +184,16 @@ module Woods
184
184
  Woods::Extractors::ViewTemplateExtractor::ENGINES.flat_map { |k| k.new.extensions }.uniq
185
185
  end
186
186
 
187
+ def aggregate_source_rules
188
+ [whole_app_rule(:libs, %w[lib], extensions: %w[.rb], exclude: Woods::Extractors::LibExtractor::EXCLUDED_SEGMENTS),
189
+ whole_app_rule(:rake_tasks, Woods::Extractors::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake])]
190
+ end
191
+
187
192
  def build_whole_app_rules
188
193
  [
189
194
  # A task may combine definitions from several files; any change or
190
195
  # deletion must reconcile the complete task set, not its primary file.
191
- whole_app_rule(:rake_tasks, Woods::Extractors::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake]),
196
+ *aggregate_source_rules,
192
197
  whole_app_rule(:routes, %w[config/routes], exact_paths: %w[config/routes.rb]),
193
198
  whole_app_rule(:engines, %w[config/routes], exact_paths: %w[config/routes.rb Gemfile.lock]),
194
199
  whole_app_rule(:middleware, %w[config/initializers config/environments],
@@ -114,6 +114,7 @@ module Woods
114
114
  warn_late_console_path(config)
115
115
  return unless config.console_mcp_enabled && config.console_mcp_http_enabled
116
116
 
117
+ verify_console_origins!
117
118
  require 'woods/mcp/bearer_auth'
118
119
  token = config.console_mcp_token.to_s
119
120
  if token.empty?
@@ -147,6 +148,13 @@ module Woods
147
148
 
148
149
  private
149
150
 
151
+ def verify_console_origins!
152
+ require 'woods/mcp/origin_policy'
153
+ Woods::MCP::OriginPolicy.new(allowed_origins: config.console_mcp_allowed_origins)
154
+ rescue ArgumentError => e
155
+ raise Woods::ConfigurationError, "[Woods Console] #{e.message}"
156
+ end
157
+
150
158
  # Warn when the configured console path no longer matches the path the
151
159
  # stack was mounted at — the path was set after railtie initializers
152
160
  # captured the middleware arguments.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'woods/atomic_file'
4
+ require 'woods/change_set'
4
5
  require 'woods/generation'
5
6
  require 'woods/git_command'
6
7
 
@@ -9,6 +10,16 @@ module Woods
9
10
  module RakeHelpers # rubocop:disable Metrics/ModuleLength -- existing task helpers grouped without changing behavior
10
11
  module_function
11
12
 
13
+ # @return [Float, nil] blank values leave the raw task resident
14
+ def woods_watch_idle_timeout
15
+ value = ENV['WOODS_WATCH_IDLE_TIMEOUT'].to_s.strip
16
+ return if value.empty?
17
+
18
+ Float(value)
19
+ rescue ArgumentError, TypeError
20
+ raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be a number of seconds or blank'
21
+ end
22
+
12
23
  # ── Multi-instance helpers (#164 phase 4) ────────────────────────────────
13
24
  #
14
25
  # Worktrees are disjoint by construction (each has its own Rails.root and
@@ -59,6 +70,11 @@ module Woods
59
70
  end
60
71
 
61
72
  def woods_with_extraction_lock(output_dir, wait: nil, raise_on_timeout: false, &block)
73
+ require 'woods/source_inputs/handoff'
74
+ if ENV[Woods::SourceInputs::Handoff::ENV_KEY]
75
+ Woods::SourceInputs::Handoff.validate_output!(root: woods_task_root, output_dir: output_dir)
76
+ end
77
+
62
78
  # Requires first. The default wait reads a constant from the daemon, so
63
79
  # resolving it above these lines NameError'd every write task — the same
64
80
  # load-order bug as the missing require in `woods:watch`, reintroduced one
@@ -195,7 +211,8 @@ module Woods
195
211
  def woods_sweep_index_dir(output_dir, lock_name)
196
212
  preserved = [
197
213
  output_dir.join("#{lock_name}.lock"),
198
- output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name))
214
+ output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name)),
215
+ *['', '.lock', '.lease'].map { |suffix| output_dir.join("#{Woods::Watch::ClaimLease::CLAIM}#{suffix}") }
199
216
  ]
200
217
  output_dir.children.each do |entry|
201
218
  FileUtils.rm_rf(entry) unless preserved.include?(entry)
@@ -235,8 +252,9 @@ module Woods
235
252
  # naming both.
236
253
  #
237
254
  # The diff is rooted at the extracted application (`git -C Rails.root`),
238
- # consistent with {Woods::GitProvenance} (#262): extraction launched from
239
- # another checkout must not diff that checkout's history.
255
+ # consistent with {Woods::GitProvenance} (#262). Explicit `--relative` and
256
+ # the current-directory pathspec keep a nested application's paths relative
257
+ # to Rails.root and exclude sibling applications in the same repository.
240
258
  #
241
259
  # The child status is carried out, not discarded (M1): `Open3.capture2`
242
260
  # turned an unresolvable range — a GitLab zero-SHA, an unfetched GitHub base
@@ -245,7 +263,7 @@ module Woods
245
263
  # never ran.
246
264
  #
247
265
  # @param range [String] a git diff range/revision expression
248
- # @param root [Pathname, String] repository root the diff runs against
266
+ # @param root [Pathname, String] application root the diff runs against
249
267
  # @return [Array(Array<String>, String, nil)] the changed paths (both halves
250
268
  # of any rename included), or nil paths plus a human-readable failure when
251
269
  # the range could not be resolved
@@ -254,7 +272,7 @@ module Woods
254
272
  output, error, status = Open3.capture3(
255
273
  *Woods::GitCommand.argv(
256
274
  root, '-c', 'core.quotePath=false',
257
- 'diff', '--name-status', '-z', '--no-renames', range
275
+ 'diff', '--relative', '--name-status', '-z', '--no-renames', range, '--', '.'
258
276
  )
259
277
  )
260
278
  return [woods_parse_git_diff_name_status(output), nil] if status.success?
@@ -289,11 +307,11 @@ module Woods
289
307
  # @return [Array<String>] changed paths
290
308
  def woods_incremental_changed_paths(output_dir)
291
309
  explicit = ENV.fetch('CHANGED_FILES', nil)
292
- return explicit.split(',').map(&:strip) if explicit
310
+ return woods_normalize_incremental_paths(explicit.split(',')) if explicit
293
311
 
294
312
  range = woods_incremental_range
295
313
  changed_files, failure = woods_changed_paths_for_range(range)
296
- return changed_files unless failure
314
+ return woods_normalize_incremental_paths(changed_files) unless failure
297
315
 
298
316
  if woods_daemon_coverage(output_dir) == :running
299
317
  puts "Could not resolve the git diff range #{range.inspect} (#{failure})."
@@ -309,17 +327,31 @@ module Woods
309
327
  exit 1
310
328
  end
311
329
 
330
+ # Normalize before the task filters against application-relative rules.
331
+ # ChangeSet retains outside-root paths as absolute; exclude those here so
332
+ # traversal spellings cannot reach extraction as application source. This
333
+ # is lexical containment, preserving deleted files and symlink spellings.
334
+ #
335
+ # @param paths [Array<String>] absolute or application-relative paths
336
+ # @return [Array<String>] unique, contained application-relative paths
337
+ def woods_normalize_incremental_paths(paths)
338
+ ChangeSet.new(paths: paths, root: Rails.root).relative_paths.reject { |path| Pathname.new(path).absolute? }
339
+ end
340
+
312
341
  # The git range `woods:incremental` diffs, from the CI environment or the
313
342
  # last-commit default.
314
343
  #
315
344
  # @return [String]
316
345
  def woods_incremental_range
317
- if ENV['CI_COMMIT_BEFORE_SHA']
346
+ before = ENV['CI_COMMIT_BEFORE_SHA'].to_s.strip
347
+ current = ENV['CI_COMMIT_SHA'].to_s.strip
348
+ base = ENV['GITHUB_BASE_REF'].to_s.strip
349
+ if !before.empty?
318
350
  # GitLab CI
319
- "#{ENV['CI_COMMIT_BEFORE_SHA']}..#{ENV.fetch('CI_COMMIT_SHA', nil)}"
320
- elsif ENV['GITHUB_BASE_REF']
351
+ "#{before}..#{current.empty? ? 'HEAD' : current}"
352
+ elsif !base.empty?
321
353
  # GitHub Actions PR
322
- "origin/#{ENV['GITHUB_BASE_REF']}...HEAD"
354
+ "origin/#{base}...HEAD"
323
355
  else
324
356
  # Default: changes since last commit
325
357
  'HEAD~1'
data/lib/woods/release.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Woods
4
4
  # Maintainer-only release machinery, deliberately not packaged with the gem.
5
- # These files back `release:prepare` and `release:reopen`, which only ever run
5
+ # These files back `release:prepare`, `release:reopen`, and `release:retarget`, which run
6
6
  # from a source checkout of this repository.
7
7
  module Release
8
8
  # Every refusal the release flow raises. `lib/tasks/release.rake` rescues
@@ -132,19 +132,24 @@ module Woods
132
132
 
133
133
  warn_unresolvable_paths(warnings, unresolvable)
134
134
  validate_dependency_graph(payload, errors)
135
- validate_source_inputs(payload, errors)
135
+ validate_source_inputs(payload, errors, warnings)
136
136
  end
137
137
 
138
138
  # Optional for old generations; malformed new provenance is an artifact
139
139
  # integrity error. Source drift itself is advisory and belongs to status.
140
- def validate_source_inputs(payload, errors)
140
+ def validate_source_inputs(payload, errors, warnings)
141
141
  path = File.join(payload, SourceInputs::Manifest::FILE_NAME)
142
142
  return unless File.exist?(path)
143
143
 
144
144
  File.open(path, File::RDONLY | File::NONBLOCK) do |file|
145
145
  raise SourceInputs::Manifest::Invalid unless file.stat.file?
146
146
 
147
- SourceInputs::Manifest.parse(file.read(SourceInputs::Manifest::MAX_BYTES + 1))
147
+ manifest = SourceInputs::Manifest.parse(file.read(SourceInputs::Manifest::MAX_BYTES + 1))
148
+ if manifest.unavailable?
149
+ evidence = manifest.data.fetch('unavailable')
150
+ warnings << "Source freshness unavailable: #{evidence['reason']} (#{evidence['size_bytes']} bytes, " \
151
+ "limit #{evidence['limit_bytes']}); the code index remains usable"
152
+ end
148
153
  end
149
154
  rescue SourceInputs::Manifest::Invalid, SystemCallError, IOError
150
155
  errors << 'Invalid source_inputs.json provenance artifact'
@@ -81,6 +81,23 @@ module Woods
81
81
  @provider.model_name
82
82
  end
83
83
 
84
+ # Optional pure configuration contracts; neither may probe the provider.
85
+ def cache_identity
86
+ @provider.cache_identity if @provider.respond_to?(:cache_identity)
87
+ end
88
+
89
+ def input_budget
90
+ @provider.input_budget if @provider.respond_to?(:input_budget)
91
+ end
92
+
93
+ def configured_dimensions
94
+ @provider.configured_dimensions if @provider.respond_to?(:configured_dimensions)
95
+ end
96
+
97
+ def requested_dimensions
98
+ @provider.requested_dimensions if @provider.respond_to?(:requested_dimensions)
99
+ end
100
+
84
101
  # Delegate the per-provider input cap. The retry wrapper does not
85
102
  # change the provider's budget, so just hand through whatever the
86
103
  # inner provider reports. `respond_to?` alone is the wrong guard
@@ -135,7 +152,7 @@ module Woods
135
152
  begin
136
153
  attempt += 1
137
154
  yield
138
- rescue CircuitOpenError, ArgumentError
155
+ rescue CircuitOpenError, ArgumentError, Embedding::InputLimitError
139
156
  raise
140
157
  rescue StandardError => e
141
158
  raise e if attempt > @max_retries || !retryable_error?(e)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'time'
5
+ require 'uri'
5
6
  require_relative 'mcp/errors'
6
7
 
7
8
  module Woods
@@ -29,6 +30,12 @@ module Woods
29
30
  # The only schema version this gem release can read or write.
30
31
  SCHEMA_VERSION_SUPPORTED = 1
31
32
 
33
+ BUILTIN_PROVIDERS = {
34
+ 'Woods::Embedding::Provider::OpenAI' => :openai,
35
+ 'Woods::Embedding::Provider::Ollama' => :ollama,
36
+ 'Woods::Embedding::Provider::Fake' => :fake
37
+ }.freeze
38
+
32
39
  # @return [Integer]
33
40
  attr_reader :schema_version
34
41
 
@@ -38,7 +45,7 @@ module Woods
38
45
  # @return [Time]
39
46
  attr_reader :created_at
40
47
 
41
- # @return [Hash] Provider details — :class, :model, :host, :num_ctx, :read_timeout, :dimension
48
+ # @return [Hash] Provider details, including observed :dimension and :requested_dimensions
42
49
  attr_reader :embedding_provider
43
50
 
44
51
  # @return [Hash] Store types — :vector_store, :metadata_store, :graph_store (Symbols)
@@ -74,9 +81,9 @@ module Woods
74
81
  # the model, not the config — and doesn't hurt OpenAI, whose provider
75
82
  # exposes the same +#dimensions+ interface.
76
83
  #
77
- # When +provider:+ is omitted, dimension falls back to
78
- # +config.embedding_options[:dimension]+ (useful for specs and for
79
- # offline ResolvedConfig construction where no provider exists).
84
+ # Known injected providers expose pure constructor settings, including a
85
+ # configured or already observed width, without probing. Otherwise an
86
+ # omitted +provider:+ uses the declared embedding options for offline capture.
80
87
  #
81
88
  # @param config [Woods::Configuration]
82
89
  # @param gem_version [String] Defaults to {Woods::VERSION}
@@ -87,7 +94,11 @@ module Woods
87
94
  require_relative 'version'
88
95
 
89
96
  opts = (config.embedding_options || {}).transform_keys(&:to_sym)
90
- declared_dim = opts[:dimensions] || opts[:dimension] || opts[:dims]
97
+ concrete = unwrap_provider(provider || config.embedding_provider)
98
+ if BUILTIN_PROVIDERS.key?(concrete.class.name) && concrete.respond_to?(:configuration_options)
99
+ opts = concrete.configuration_options
100
+ end
101
+ declared_dim = opts[:expected_dimensions] || opts[:dimensions] || opts[:dimension] || opts[:dims]
91
102
  dim = provider.respond_to?(:dimensions) ? provider.dimensions : declared_dim
92
103
  model = provider.respond_to?(:model_name) ? provider.model_name : (opts[:model] || config.embedding_model)
93
104
 
@@ -99,6 +110,11 @@ module Woods
99
110
  num_ctx: opts[:num_ctx],
100
111
  read_timeout: opts[:read_timeout]
101
112
  }.compact
113
+ provider_hash[:requested_dimensions] = if provider.respond_to?(:requested_dimensions)
114
+ provider.requested_dimensions
115
+ else
116
+ opts[:dimensions] || opts[:dimension]
117
+ end
102
118
 
103
119
  new(
104
120
  schema_version: SCHEMA_VERSION_SUPPORTED,
@@ -123,7 +139,7 @@ module Woods
123
139
  @schema_version = schema_version
124
140
  @gem_version = gem_version.to_s.freeze
125
141
  @created_at = created_at
126
- @embedding_provider = deep_freeze(embedding_provider)
142
+ @embedding_provider = deep_freeze(self.class.sanitize_provider(embedding_provider))
127
143
  @stores = deep_freeze(stores)
128
144
  @store_options = deep_freeze(store_options)
129
145
  freeze
@@ -206,6 +222,22 @@ module Woods
206
222
  to_snapshot_json.freeze
207
223
  end
208
224
 
225
+ # Only known built-ins with safely recorded settings can be reconstructed.
226
+ # An explicitly configured host provider may still use this snapshot.
227
+ def requires_host_provider?
228
+ embedding_provider[:requires_host_provider] == true
229
+ end
230
+
231
+ def self.sanitize_provider(provider)
232
+ result = provider.dup
233
+ result[:requires_host_provider] = true unless BUILTIN_PROVIDERS.key?(result[:class])
234
+ if result[:host] && !safe_provider_host?(result[:host])
235
+ result.delete(:host)
236
+ result[:requires_host_provider] = true
237
+ end
238
+ result
239
+ end
240
+
209
241
  private
210
242
 
211
243
  def stringify_nested_keys(value)
@@ -289,7 +321,7 @@ module Woods
289
321
 
290
322
  def parse_provider(raw)
291
323
  data = normalize_keys(raw)
292
- {
324
+ parsed = {
293
325
  class: data[:class].to_s,
294
326
  model: data[:model].to_s,
295
327
  dimension: data[:dimension].to_i,
@@ -297,6 +329,19 @@ module Woods
297
329
  num_ctx: data[:num_ctx],
298
330
  read_timeout: data[:read_timeout]
299
331
  }.compact
332
+ parsed[:requested_dimensions] = data[:requested_dimensions] if data.key?(:requested_dimensions)
333
+ parsed[:requires_host_provider] = true if data[:requires_host_provider] == true
334
+ parsed
335
+ end
336
+
337
+ # Paths and URL metadata can contain credentials. Keep only plain origins;
338
+ # do not strip a prefix/query and silently reconstruct a different service.
339
+ def safe_provider_host?(host)
340
+ uri = URI.parse(host)
341
+ uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment &&
342
+ ['', '/'].include?(uri.path)
343
+ rescue URI::InvalidURIError, ArgumentError, TypeError
344
+ false
300
345
  end
301
346
 
302
347
  def parse_stores(raw)
@@ -338,8 +383,23 @@ module Woods
338
383
  when String then provider
339
384
  when Class then provider.name
340
385
  when nil then ''
341
- else provider.to_s
386
+ else resolve_provider_object_class(provider)
387
+ end
388
+ end
389
+
390
+ def resolve_provider_object_class(provider)
391
+ unwrap_provider(provider).class.name || '(anonymous provider)'
392
+ end
393
+
394
+ def unwrap_provider(provider)
395
+ seen = {}.compare_by_identity
396
+ while %w[Woods::Resilience::RetryableProvider Woods::Cache::CachedEmbeddingProvider].include?(provider.class.name)
397
+ raise Woods::MCP::ConfigMismatch, 'Embedding provider wrappers contain a cycle' if seen[provider]
398
+
399
+ seen[provider] = true
400
+ provider = provider.provider
342
401
  end
402
+ provider
343
403
  end
344
404
  end
345
405
  end
@@ -342,7 +342,7 @@ module Woods
342
342
 
343
343
  def append_compact_candidate(parts, sources, candidate, unit, budget, tokens_used)
344
344
  header = "## #{unit_field(unit, :identifier)} (#{unit_field(unit, :type)})\n" \
345
- "File: #{unit_field(unit, :file_path)}\n\n"
345
+ "#{SourceContributors.label(unit)}\n\n"
346
346
  remaining = budget - tokens_used
347
347
  return tokens_used unless remaining.positive?
348
348
 
@@ -365,12 +365,11 @@ module Woods
365
365
  def format_unit(unit, _candidate)
366
366
  identifier = unit_field(unit, :identifier)
367
367
  type = unit_field(unit, :type)
368
- file_path = unit_field(unit, :file_path)
369
368
  source = unit_field(unit, :source_code) || ''
370
369
 
371
370
  <<~UNIT.strip
372
371
  ## #{identifier} (#{type})
373
- File: #{file_path}
372
+ #{SourceContributors.label(unit)}
374
373
 
375
374
  #{source}
376
375
  UNIT
@@ -386,6 +385,7 @@ module Woods
386
385
  score: candidate.score,
387
386
  file_path: unit_field(unit, :file_path)
388
387
  }
388
+ attribution.merge!(SourceContributors.attribution(unit))
389
389
  attribution[:truncated] = true if truncated
390
390
  attribution
391
391
  end