woods 2.0.0.beta3 → 2.0.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +500 -420
  3. data/CONTRIBUTING.md +29 -17
  4. data/README.md +78 -178
  5. data/docs/AGENT_GUIDE.md +52 -11
  6. data/docs/AGENT_SETUP.md +34 -17
  7. data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
  8. data/docs/BACKEND_MATRIX.md +18 -7
  9. data/docs/CLIENT_HOOKS.md +1 -1
  10. data/docs/CONFIGURATION_REFERENCE.md +105 -29
  11. data/docs/CONSOLE_MCP_SETUP.md +54 -9
  12. data/docs/DOCKER_SETUP.md +16 -1
  13. data/docs/EVALUATION.md +10 -4
  14. data/docs/EXTRACTOR_REFERENCE.md +23 -3
  15. data/docs/FAQ.md +14 -3
  16. data/docs/GETTING_STARTED.md +18 -17
  17. data/docs/INCREMENTAL_EXTRACTION.md +37 -8
  18. data/docs/INDEX_LAYOUT.md +2 -2
  19. data/docs/MCP_SERVERS.md +79 -7
  20. data/docs/MCP_TOOL_COOKBOOK.md +5 -5
  21. data/docs/MCP_WORKTREE_SETUP.md +55 -83
  22. data/docs/PUBLISHED_INDEX.md +17 -0
  23. data/docs/README.md +2 -1
  24. data/docs/RETRIEVAL_GUIDE.md +81 -13
  25. data/docs/SOURCE_FRESHNESS.md +1 -1
  26. data/docs/TOKEN_BENCHMARK.md +16 -10
  27. data/docs/TROUBLESHOOTING.md +142 -47
  28. data/docs/UPGRADING_TO_2.md +12 -6
  29. data/docs/WATCH_DAEMON.md +189 -24
  30. data/docs/WHY_WOODS.md +9 -5
  31. data/exe/woods-console +13 -11
  32. data/exe/woods-mcp-start +14 -9
  33. data/exe/woods-watch +5 -0
  34. data/lib/generators/woods/pgvector_generator.rb +8 -2
  35. data/lib/generators/woods/watch_generator.rb +53 -0
  36. data/lib/puma/plugin/woods.rb +10 -0
  37. data/lib/tasks/woods.rake +14 -0
  38. data/lib/woods/agent_configuration/applier.rb +5 -3
  39. data/lib/woods/agent_configuration/cli.rb +2 -2
  40. data/lib/woods/agent_configuration/layout.rb +13 -0
  41. data/lib/woods/cache/cache_middleware.rb +6 -0
  42. data/lib/woods/console/credential_scanner.rb +4 -3
  43. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  44. data/lib/woods/console/embedded_executor.rb +31 -9
  45. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  46. data/lib/woods/console/sql_table_scanner.rb +47 -7
  47. data/lib/woods/console/sql_validator.rb +49 -9
  48. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  49. data/lib/woods/console/stdio_transport.rb +27 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  51. data/lib/woods/embedding/indexer.rb +24 -14
  52. data/lib/woods/extractor.rb +70 -19
  53. data/lib/woods/extractors/declared_parent.rb +55 -0
  54. data/lib/woods/extractors/graphql_extractor.rb +2 -11
  55. data/lib/woods/extractors/lib_extractor.rb +10 -8
  56. data/lib/woods/extractors/mailer_extractor.rb +6 -10
  57. data/lib/woods/extractors/model_extractor.rb +1 -15
  58. data/lib/woods/extractors/poro_extractor.rb +10 -8
  59. data/lib/woods/extractors/shared_utility_methods.rb +22 -5
  60. data/lib/woods/git_command.rb +6 -7
  61. data/lib/woods/git_provenance.rb +4 -6
  62. data/lib/woods/mcp/bearer_auth.rb +2 -1
  63. data/lib/woods/mcp/bootstrapper.rb +20 -5
  64. data/lib/woods/mcp/config_resolver.rb +2 -1
  65. data/lib/woods/mcp/index_reader.rb +11 -2
  66. data/lib/woods/mcp/initialization_guidance.rb +1 -1
  67. data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
  68. data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
  69. data/lib/woods/mcp/server.rb +63 -37
  70. data/lib/woods/mcp/tool_contract.rb +1 -1
  71. data/lib/woods/mcp/tool_response_renderer.rb +16 -0
  72. data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
  73. data/lib/woods/mcp/traversal_response.rb +22 -0
  74. data/lib/woods/path_dispatcher.rb +6 -5
  75. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  76. data/lib/woods/published_index.rb +2 -2
  77. data/lib/woods/rake_helpers.rb +2 -12
  78. data/lib/woods/retrieval/corpus_status.rb +46 -0
  79. data/lib/woods/retrieval/lexical_assembler.rb +14 -3
  80. data/lib/woods/retrieval/lexical_index.rb +2 -1
  81. data/lib/woods/retriever.rb +19 -7
  82. data/lib/woods/session_tracer/file_store.rb +6 -1
  83. data/lib/woods/source_inputs/consumer_errors.rb +4 -0
  84. data/lib/woods/storage/local_corpus_stats.rb +32 -0
  85. data/lib/woods/storage/metadata_store.rb +20 -0
  86. data/lib/woods/storage/pgvector.rb +6 -2
  87. data/lib/woods/storage/vector_store.rb +10 -0
  88. data/lib/woods/temporal/json_snapshot_store.rb +35 -7
  89. data/lib/woods/version.rb +1 -1
  90. data/lib/woods/watch/child_environment.rb +30 -0
  91. data/lib/woods/watch/cli.rb +91 -0
  92. data/lib/woods/watch/daemon.rb +73 -11
  93. data/lib/woods/watch/event_stream.rb +70 -0
  94. data/lib/woods/watch/guardian.rb +142 -0
  95. data/lib/woods/watch/installation/layout.rb +70 -0
  96. data/lib/woods/watch/installation/options.rb +128 -0
  97. data/lib/woods/watch/installation/planner.rb +128 -0
  98. data/lib/woods/watch/installation/probe.rb +101 -0
  99. data/lib/woods/watch/installation/receipt.rb +77 -0
  100. data/lib/woods/watch/installation/recovery.rb +64 -0
  101. data/lib/woods/watch/installation/templates.rb +58 -0
  102. data/lib/woods/watch/installation.rb +56 -0
  103. data/lib/woods/watch/lifecycle.rb +182 -0
  104. data/lib/woods/watch/managed_child.rb +113 -0
  105. data/lib/woods/watch/managed_cleanup.rb +48 -0
  106. data/lib/woods/watch/managed_process.rb +144 -0
  107. data/lib/woods/watch/puma_adapter.rb +87 -0
  108. data/lib/woods/watch/puma_child.rb +66 -0
  109. data/lib/woods/watch/supervision_records.rb +95 -0
  110. data/lib/woods/watch/supervision_status.rb +104 -0
  111. data/lib/woods/watch/supervisor.rb +161 -0
  112. data/lib/woods/watch/supervisor_reporting.rb +46 -0
  113. data/plugin/.claude-plugin/plugin.json +1 -1
  114. data/plugin/hooks/woods-input-rules.sh +4 -4
  115. data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
  116. data/plugin/skills/woods-diagnose/SKILL.md +134 -34
  117. data/plugin/skills/woods-investigate/SKILL.md +54 -15
  118. data/plugin/skills/woods-mcp-config/SKILL.md +38 -11
  119. data/plugin/skills/woods-setup/SKILL.md +72 -15
  120. metadata +38 -5
@@ -211,7 +211,6 @@ module Woods
211
211
  configuration: :extract_configuration_file,
212
212
  view_template: :extract_view_template_file,
213
213
  migration: :extract_migration_file,
214
- rake_task: :extract_rake_file,
215
214
  decorator: :extract_decorator_file,
216
215
  database_view: :extract_view_file,
217
216
  caching: :extract_caching_file,
@@ -293,7 +292,7 @@ module Woods
293
292
  method: :extract_from_runtime_type, reconcile_removals: false }
294
293
  }.freeze
295
294
 
296
- # Extractors with no per-file entry point: they scan the whole app (or
295
+ # Extractors requiring a complete source set: they scan the whole app (or
297
296
  # introspect the whole runtime) in one pass, so an incremental run
298
297
  # replaces their output wholesale rather than per unit. Before #164
299
298
  # these types were simply skipped by incremental runs while
@@ -302,6 +301,7 @@ module Woods
302
301
  #
303
302
  # @return [Hash{Symbol => Symbol}] extractor key => unit type
304
303
  WHOLE_APP_EXTRACTORS = {
304
+ rake_tasks: :rake_task,
305
305
  routes: :route,
306
306
  middleware: :middleware,
307
307
  engines: :engine,
@@ -600,6 +600,7 @@ module Woods
600
600
  ))
601
601
  end
602
602
 
603
+ raise_on_handled_extraction_failure!
603
604
  finalize_incremental_unit_json(affected_types)
604
605
 
605
606
  # Regenerate type indexes for affected types
@@ -662,6 +663,7 @@ module Woods
662
663
  acc.merge(replace_type_wholesale(key, affected_types))
663
664
  end
664
665
 
666
+ raise_on_handled_extraction_failure!
665
667
  finalize_incremental_unit_json(affected_types)
666
668
  profile_phase('type index') { affected_types.each { |type_key| regenerate_type_index(type_key) } }
667
669
  finalize_incremental_run(touched, reason: "refresh:#{known.sort.join(',')}")
@@ -889,6 +891,7 @@ module Woods
889
891
  # Capture before eager loading or extraction; only an explicit fresh-launch
890
892
  # handoff can additionally establish the pre-Bundler/Rails boot boundary.
891
893
  def begin_source_inputs(operation)
894
+ @failed_consumers = Set.new
892
895
  @source_inputs = SourceInputs::Session.new(root: Rails.root, output_dir: @output_dir,
893
896
  baseline_path: source_input_baseline_path,
894
897
  operation: operation)
@@ -911,6 +914,28 @@ module Woods
911
914
  failed
912
915
  end
913
916
 
917
+ # A rescued consumer error is not a successful empty result. Reset the
918
+ # per-call flag because one extractor instance serves several paths, but
919
+ # retain the failed scope for the run so a later success cannot authorize
920
+ # publication. Watch then carries the complete batch forward for retry.
921
+ def checked_extraction(key, consumer)
922
+ (@failed_consumers ||= Set.new).add(key) if SourceInputs::ConsumerErrors.failed?(consumer)
923
+ SourceInputs::ConsumerErrors.reset(consumer)
924
+ result = yield
925
+ return result unless source_consumer_failed?(key, consumer)
926
+
927
+ (@failed_consumers ||= Set.new).add(key)
928
+ nil
929
+ end
930
+
931
+ def raise_on_handled_extraction_failure!
932
+ return if @failed_consumers.nil? || @failed_consumers.empty?
933
+
934
+ raise Woods::ExtractionError,
935
+ "Extraction failed for #{@failed_consumers.to_a.sort.join(', ')}; " \
936
+ 'the previous generation remains active. Fix the logged source errors and retry the complete batch.'
937
+ end
938
+
914
939
  def write_source_inputs
915
940
  return unless @source_inputs
916
941
 
@@ -2078,10 +2103,28 @@ module Woods
2078
2103
  end
2079
2104
 
2080
2105
  @git_available = complete_git_history?
2106
+ rescue Errno::ENOENT
2107
+ warn_missing_git_executable
2108
+ @git_available = false
2081
2109
  rescue StandardError
2082
2110
  @git_available = false
2083
2111
  end
2084
2112
 
2113
+ # An explicit Git directory still requests history when .git is not mounted
2114
+ # at Rails.root. Ordinary source archives remain a supported, quiet path.
2115
+ #
2116
+ # @return [void]
2117
+ def warn_missing_git_executable
2118
+ configured = [GitCommand::OVERRIDE_KEY, 'GIT_DIR'].any? { |key| !ENV[key].to_s.empty? }
2119
+ return unless configured || File.exist?(File.join(Rails.root.to_s, '.git'))
2120
+
2121
+ Rails.logger.warn(
2122
+ '[Woods] Git history unavailable: git executable was not found in PATH. ' \
2123
+ 'Extraction continues without per-unit Git metadata. Install Git in the extraction environment ' \
2124
+ 'and run a full extraction to refresh Git metadata.'
2125
+ )
2126
+ end
2127
+
2085
2128
  # A shallow HEAD resolves but represents an incomplete ancestry. Do not
2086
2129
  # turn that boundary into apparent one-commit/new-file churn facts.
2087
2130
  def complete_git_history?
@@ -2109,19 +2152,19 @@ module Woods
2109
2152
  cause = error.to_s.lines.first.to_s.strip
2110
2153
  Rails.logger.warn(
2111
2154
  '[Woods] git cannot resolve HEAD for this working tree, so no unit will carry git ' \
2112
- "metadata: #{cause}. Over a linked worktree in a container, mount the canonical git " \
2113
- 'directory and point WOODS_GIT_DIR at it; GIT_DIR alone is not enough, because the ' \
2114
- "worktree's private git directory reaches the shared one through a relative pointer."
2155
+ "metadata: #{cause}. For a linked worktree, mount the complete shared .git directory " \
2156
+ 'at its original path, or set WOODS_GIT_DIR to /mounted-common/worktrees/<id> within ' \
2157
+ 'the relocated complete layout. Derive <id> from the worktree Git metadata, not its branch name; ' \
2158
+ "selecting /mounted-common itself uses the primary checkout's HEAD."
2115
2159
  )
2116
2160
  end
2117
2161
 
2118
2162
  # The git command line every enrichment call runs.
2119
2163
  #
2120
2164
  # `-C <root>` keeps the result independent of the process working
2121
- # directory. `WOODS_GIT_DIR` wins when set: it names the canonical git
2122
- # directory directly, which is the escape hatch for a container that can
2123
- # mount that directory but not the host path a worktree pointer names
2124
- # (B-181).
2165
+ # directory. `WOODS_GIT_DIR` wins when set and selects that git directory's
2166
+ # HEAD. A relocated linked worktree needs its worktree-specific directory
2167
+ # inside the complete shared Git layout (B-181).
2125
2168
  #
2126
2169
  # @param args [Array<String>] git arguments
2127
2170
  # @return [Array<String>] full argv
@@ -2760,10 +2803,11 @@ module Woods
2760
2803
  #
2761
2804
  # This is the fix for #164 gap 1 (a path the index has never seen routed
2762
2805
  # nowhere, so new files were silently ignored) and the per-path half of
2763
- # gap 3 (a file that defines several units — a `.rake` file with multiple
2764
- # tasks, an i18n YAML — could only ever resolve to one identifier).
2765
- # Reconciling the whole path at once means a task deleted from a
2766
- # multi-task file is removed rather than left behind.
2806
+ # gap 3 (a file defining several units could only ever resolve to one
2807
+ # identifier). Rake tasks need the wholesale path because definitions
2808
+ # of one task can span several files.
2809
+ # Reconciling the whole path removes definitions deleted from a surviving
2810
+ # file rather than leaving them behind.
2767
2811
  #
2768
2812
  # Removal is scoped to the unit types the matching rules could have
2769
2813
  # produced, so a class-based unit sharing the path (the `User` model unit
@@ -2837,7 +2881,7 @@ module Woods
2837
2881
  end
2838
2882
  return [] unless extractor.respond_to?(rule.method_name)
2839
2883
 
2840
- result =
2884
+ result = checked_extraction(rule.extractor_key, extractor) do
2841
2885
  if rule.extractor_key == :poros
2842
2886
  # PoroExtractor needs the AR name set to reject persisted models;
2843
2887
  # its default is an empty set, which would misfile every model
@@ -2846,6 +2890,8 @@ module Woods
2846
2890
  else
2847
2891
  extractor.public_send(rule.method_name, absolute_path)
2848
2892
  end
2893
+ end
2894
+ return nil if result.nil? && SourceInputs::ConsumerErrors.failed?(extractor)
2849
2895
 
2850
2896
  Array(result).compact
2851
2897
  rescue StandardError => e
@@ -3172,7 +3218,10 @@ module Woods
3172
3218
  return Set.new unless extractor.respond_to?(:extract_all)
3173
3219
 
3174
3220
  @wholesale_mutations = 0
3175
- units = Array(extractor.extract_all).compact.uniq(&:identifier)
3221
+ result = checked_extraction(key, extractor) { extractor.extract_all }
3222
+ return Set.new if SourceInputs::ConsumerErrors.failed?(extractor)
3223
+
3224
+ units = Array(result).compact.uniq(&:identifier)
3176
3225
  Rails.logger.info "[Woods] Re-ran #{key} wholesale: #{units.size} units"
3177
3226
 
3178
3227
  touched = register_and_write(key, units, affected_types)
@@ -3723,10 +3772,12 @@ module Woods
3723
3772
  return nil
3724
3773
  end
3725
3774
 
3726
- # File-based extractors can return several units from one file (a .rake
3727
- # file defining multiple tasks, etc.); class-based extractors return one.
3728
- units = Array(re_extracted_units(extractor, type, unit_id, file_path, extractor_key)).compact
3729
- source_consumer_failed?(extractor_key, extractor)
3775
+ # File-based extractors can return several units from one file;
3776
+ # class-based extractors return one.
3777
+ result = checked_extraction(extractor_key, extractor) do
3778
+ re_extracted_units(extractor, type, unit_id, file_path, extractor_key)
3779
+ end
3780
+ units = Array(result).compact
3730
3781
  return nil if units.empty?
3731
3782
 
3732
3783
  register_and_write(extractor_key, units, affected_types)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Woods
6
+ module Extractors
7
+ # Reads only the selected declaration's explicit constant-path parent.
8
+ # Identity selection remains with the extractor; literals and unrelated
9
+ # declarations must never supply metadata for that identity.
10
+ module DeclaredParent
11
+ module_function
12
+
13
+ def call(source, identifier)
14
+ parsed = Prism.parse(source)
15
+ return nil unless parsed.success?
16
+
17
+ find(parsed.value, identifier, '')&.first
18
+ end
19
+
20
+ def find(node, identifier, namespace)
21
+ return unless node
22
+
23
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
24
+ name = constant_name(node.constant_path)
25
+ return unless name
26
+
27
+ qualified = name.start_with?('::') ? name.delete_prefix('::') : [namespace, name].reject(&:empty?).join('::')
28
+ if qualified == identifier
29
+ return [node.is_a?(Prism::ClassNode) ? constant_name(node.superclass) : nil]
30
+ end
31
+
32
+ return find(node.body, identifier, qualified)
33
+ end
34
+
35
+ node.compact_child_nodes.each do |child|
36
+ match = find(child, identifier, namespace)
37
+ return match if match
38
+ end
39
+ nil
40
+ end
41
+ private_class_method :find
42
+
43
+ def constant_name(node)
44
+ case node
45
+ when Prism::ConstantReadNode
46
+ node.name.to_s
47
+ when Prism::ConstantPathNode
48
+ parent = node.parent ? constant_name(node.parent) : ''
49
+ "#{parent}::#{node.name}" if parent
50
+ end
51
+ end
52
+ private_class_method :constant_name
53
+ end
54
+ end
55
+ end
@@ -441,11 +441,11 @@ module Woods
441
441
  # @param unit_type [Symbol]
442
442
  # @param runtime_class [Class, nil]
443
443
  # @return [Hash]
444
- def build_metadata(source, _class_name, _unit_type, runtime_class)
444
+ def build_metadata(source, class_name, _unit_type, runtime_class)
445
445
  {
446
446
  # GraphQL classification
447
447
  graphql_kind: detect_graphql_kind(source, runtime_class),
448
- parent_class: extract_parent_class(source),
448
+ parent_class: extract_parent_class(source, class_name),
449
449
 
450
450
  # Fields and arguments
451
451
  fields: extract_fields(source, runtime_class),
@@ -508,15 +508,6 @@ module Woods
508
508
  :object
509
509
  end
510
510
 
511
- # Extract the parent class name from source
512
- #
513
- # @param source [String]
514
- # @return [String, nil]
515
- def extract_parent_class(source)
516
- match = source.match(/class\s+\w+\s*<\s*([\w:]+)/)
517
- match ? match[1] : nil
518
- end
519
-
520
511
  # Extract field definitions from source and/or runtime
521
512
  #
522
513
  # @param source [String]
@@ -80,9 +80,11 @@ module Woods
80
80
  file_path: file_path
81
81
  )
82
82
 
83
+ parent_class = extract_parent_class(source, class_name)
84
+
83
85
  unit.namespace = extract_namespace(class_name)
84
- unit.source_code = annotate_source(source, class_name)
85
- unit.metadata = extract_metadata(source, class_name)
86
+ unit.source_code = annotate_source(source, class_name, parent_class)
87
+ unit.metadata = extract_metadata(source, parent_class)
86
88
  unit.dependencies = extract_dependencies(source)
87
89
 
88
90
  unit
@@ -170,11 +172,11 @@ module Woods
170
172
  #
171
173
  # @param source [String] Ruby source code
172
174
  # @param class_name [String] The inferred constant name
175
+ # @param parent_class [String, nil] Selected explicit parent
173
176
  # @return [String] Annotated source
174
- def annotate_source(source, class_name)
175
- parent = extract_parent_class(source)
177
+ def annotate_source(source, class_name, parent_class)
176
178
  entry_points = detect_entry_points(source)
177
- parent_label = parent || 'none'
179
+ parent_label = parent_class || 'none'
178
180
 
179
181
  annotation = <<~ANNOTATION
180
182
  # ╔═══════════════════════════════════════════════════════════════════════╗
@@ -195,14 +197,14 @@ module Woods
195
197
  # Build the metadata hash for a lib unit.
196
198
  #
197
199
  # @param source [String] Ruby source code
198
- # @param class_name [String] The inferred constant name
200
+ # @param parent_class [String, nil] Selected explicit parent
199
201
  # @return [Hash] Lib unit metadata
200
- def extract_metadata(source, _class_name)
202
+ def extract_metadata(source, parent_class)
201
203
  {
202
204
  public_methods: extract_public_methods(source),
203
205
  class_methods: extract_class_methods(source),
204
206
  initialize_params: extract_initialize_params(source),
205
- parent_class: extract_parent_class(source),
207
+ parent_class: parent_class,
206
208
  loc: count_loc(source),
207
209
  method_count: source.scan(/def\s+(?:self\.)?\w+/).size,
208
210
  entry_points: detect_entry_points(source)
@@ -116,12 +116,8 @@ module Woods
116
116
  # ──────────────────────────────────────────────────────────────────────
117
117
 
118
118
  def annotate_source(source, mailer)
119
- actions = mailer.action_methods.to_a
120
- default_from = begin
121
- mailer.default[:from]
122
- rescue StandardError
123
- nil
124
- end
119
+ actions = mailer.action_methods.sort
120
+ default_from = extract_defaults(mailer)[:from]
125
121
 
126
122
  <<~ANNOTATION
127
123
  # ╔═══════════════════════════════════════════════════════════════════════╗
@@ -139,7 +135,7 @@ module Woods
139
135
  # ──────────────────────────────────────────────────────────────────────
140
136
 
141
137
  def extract_metadata(mailer, source)
142
- actions = mailer.action_methods.to_a
138
+ actions = mailer.action_methods.sort
143
139
 
144
140
  {
145
141
  # Actions (mail methods)
@@ -171,7 +167,7 @@ module Woods
171
167
 
172
168
  def extract_defaults(mailer)
173
169
  mailer_defaults = mailer.default
174
- mailer_defaults.slice(:from, :reply_to, :cc, :bcc).compact
170
+ mailer_defaults.slice(:from, :reply_to, :cc, :bcc).compact.transform_values { |value| stable_filter(value) }
175
171
  rescue StandardError
176
172
  {}
177
173
  end
@@ -182,7 +178,7 @@ module Woods
182
178
 
183
179
  result = {
184
180
  type: :"#{cb.kind}_action",
185
- filter: cb.filter.to_s
181
+ filter: callback_filter_label(cb)
186
182
  }
187
183
  result[:only] = only if only.any?
188
184
  result[:except] = except if except.any?
@@ -274,7 +270,7 @@ module Woods
274
270
  # ──────────────────────────────────────────────────────────────────────
275
271
 
276
272
  def build_action_chunks(mailer, _source)
277
- mailer.action_methods.filter_map do |action|
273
+ mailer.action_methods.sort.filter_map do |action|
278
274
  action_source = extract_action_source(mailer, action)
279
275
  next if action_source.nil? || action_source.strip.empty?
280
276
 
@@ -619,7 +619,7 @@ module Woods
619
619
  entry = {
620
620
  attribute: attribute,
621
621
  type: v.class.name.demodulize.underscore.sub(/_validator$/, ''),
622
- options: v.options.except(:if, :unless, :on),
622
+ options: v.options.except(:if, :unless, :on).transform_values { |value| stable_filter(value) },
623
623
  conditions: format_validation_conditions(v)
624
624
  }
625
625
  entry[:implicit_belongs_to] = true if implicit_belongs_to_validator?(model, v)
@@ -650,20 +650,6 @@ module Woods
650
650
  end.compact
651
651
  end
652
652
 
653
- # Preserve application labels; strip addresses only from Ruby's actual
654
- # default representation. Nested identities occur for anonymous classes.
655
- # These are descriptive labels, not serialization of callback object state.
656
- def callback_filter_label(callback)
657
- filter = callback_filter(callback)
658
- label = filter.to_s
659
- return label unless label.start_with?('#<')
660
-
661
- owner = filter.is_a?(Module) ? Module : Kernel
662
- return label unless label == owner.instance_method(:to_s).bind(filter).call
663
-
664
- label.gsub(/:0x[0-9a-f]+(?=>)/i, '')
665
- end
666
-
667
653
  # Extract scopes with their source if available.
668
654
  # Parses the full source with the AST layer to get accurate scope
669
655
  # boundaries, falling back to regex line-scanning on parse failure.
@@ -82,9 +82,11 @@ module Woods
82
82
  file_path: file_path
83
83
  )
84
84
 
85
+ parent_class = extract_parent_class(source, class_name)
86
+
85
87
  unit.namespace = extract_namespace(class_name)
86
- unit.source_code = annotate_source(source, class_name)
87
- unit.metadata = extract_metadata(source, class_name)
88
+ unit.source_code = annotate_source(source, class_name, parent_class)
89
+ unit.metadata = extract_metadata(source, parent_class)
88
90
  unit.dependencies = extract_dependencies(source)
89
91
 
90
92
  unit
@@ -177,10 +179,10 @@ module Woods
177
179
  #
178
180
  # @param source [String] Ruby source code
179
181
  # @param class_name [String] The class name
182
+ # @param parent_class [String, nil] Selected explicit parent
180
183
  # @return [String] Annotated source
181
- def annotate_source(source, class_name)
182
- parent = extract_parent_class(source)
183
- parent_label = parent || 'none'
184
+ def annotate_source(source, class_name, parent_class)
185
+ parent_label = parent_class || 'none'
184
186
 
185
187
  annotation = <<~ANNOTATION
186
188
  # ╔═══════════════════════════════════════════════════════════════════════╗
@@ -200,14 +202,14 @@ module Woods
200
202
  # Build the metadata hash for a PORO unit.
201
203
  #
202
204
  # @param source [String] Ruby source code
203
- # @param class_name [String] The class name
205
+ # @param parent_class [String, nil] Selected explicit parent
204
206
  # @return [Hash] PORO metadata
205
- def extract_metadata(source, _class_name)
207
+ def extract_metadata(source, parent_class)
206
208
  {
207
209
  public_methods: extract_public_methods(source),
208
210
  class_methods: extract_class_methods(source),
209
211
  initialize_params: extract_initialize_params(source),
210
- parent_class: extract_parent_class(source),
212
+ parent_class: parent_class,
211
213
  loc: count_loc(source),
212
214
  method_count: source.scan(/def\s+(?:self\.)?\w+/).size
213
215
  }
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'declared_parent'
4
+
3
5
  require_relative 'source_nesting'
4
6
  require_relative 'method_parameters'
5
7
 
@@ -125,10 +127,10 @@ module Woods
125
127
  # Extract the parent class name from a class definition.
126
128
  #
127
129
  # @param source [String] Ruby source code
128
- # @return [String, nil] Parent class name or nil
129
- def extract_parent_class(source)
130
- match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
131
- match ? match[1] : nil
130
+ # @param class_name [String] Already selected extraction identity
131
+ # @return [String, nil] Explicit parent of that declaration, or nil
132
+ def extract_parent_class(source, class_name)
133
+ DeclaredParent.call(source, class_name)
132
134
  end
133
135
 
134
136
  # Count non-blank, non-comment lines of code.
@@ -243,7 +245,22 @@ module Woods
243
245
  end
244
246
  "#<#{filter.lambda? ? 'lambda' : 'Proc'} #{site}>"
245
247
  end
246
- private :callback_filter, :stable_filter
248
+
249
+ # Preserve application labels; strip addresses only from Ruby's actual
250
+ # default representation. Nested identities occur for anonymous classes.
251
+ # These are descriptive labels, not serialization of callback object state.
252
+ def callback_filter_label(callback)
253
+ filter = callback_filter(callback)
254
+ label = filter.to_s
255
+ return label unless label.start_with?('#<')
256
+
257
+ owner = filter.is_a?(Module) ? Module : Kernel
258
+ return label unless label == owner.instance_method(:to_s).bind(filter).call
259
+
260
+ label.gsub(/:0x[0-9a-f]+(?=>)/i, '')
261
+ end
262
+
263
+ private :callback_filter, :stable_filter, :callback_filter_label
247
264
 
248
265
  # Human-readable label for a non-ActionFilter condition.
249
266
  #
@@ -7,18 +7,17 @@ module Woods
7
7
  # than at the process working directory, so extraction launched from another
8
8
  # checkout never reports that checkout's history.
9
9
  #
10
- # `WOODS_GIT_DIR` wins when set. It is the escape hatch for a container over
11
- # a linked worktree: `.git` there is a file naming an absolute host path that
12
- # may not be mounted, and git's own `GIT_DIR` is not enough, because a
13
- # worktree's private git directory reaches the shared object store through a
14
- # relative `commondir` pointer that resolves outside the mount, which
15
- # `GIT_COMMON_DIR` does not override (B-181, B-186).
10
+ # `WOODS_GIT_DIR` wins when set and selects HEAD exactly as `--git-dir`
11
+ # does. For a container over a linked worktree, select its private directory
12
+ # within the complete mounted Git layout (shared objects, refs and worktrees).
13
+ # Selecting the shared root instead selects the primary checkout's HEAD.
14
+ # A same-path mount that resolves the worktree's .git pointer needs no override.
16
15
  #
17
16
  # Three call sites use this, and the documentation promises all three:
18
17
  # per-unit enrichment (`Extractor`), manifest provenance (`GitProvenance`),
19
18
  # and the `woods:incremental` diff range (`lib/tasks/woods.rake`).
20
19
  module GitCommand
21
- # Environment variable naming the canonical git directory.
20
+ # Environment variable naming the explicitly selected git directory.
22
21
  OVERRIDE_KEY = 'WOODS_GIT_DIR'
23
22
 
24
23
  module_function
@@ -85,12 +85,10 @@ module Woods
85
85
  ''
86
86
  end
87
87
 
88
- # +WOODS_GIT_DIR+ names the canonical git directory outright and wins when
89
- # set. It is the escape hatch for a container that can mount that
90
- # directory but not the host path a linked worktree's +.git+ file points
91
- # at; git's own +GIT_DIR+ is not enough there, because a worktree's
92
- # private git directory reaches the shared one through a relative
93
- # +commondir+ pointer that resolves outside the mount.
88
+ # +WOODS_GIT_DIR+ selects a git directory and its HEAD outright and wins when
89
+ # set. A relocated linked worktree needs its private directory inside the
90
+ # complete mounted Git layout, preserving access to shared objects and refs.
91
+ # Selecting the shared root instead uses the primary checkout's HEAD.
94
92
  #
95
93
  # @param args [Array<String>] git arguments
96
94
  # @return [Array<String>] full argv
@@ -54,7 +54,8 @@ module Woods
54
54
 
55
55
  expected = resolve_token
56
56
  header = env['HTTP_AUTHORIZATION'].to_s
57
- presented = header.start_with?('Bearer ') ? header.sub(/\ABearer /, '') : nil
57
+ # Only the ASCII scheme changes case; do not normalize or trim the token.
58
+ presented = header.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header[7..] : nil
58
59
 
59
60
  if expected && presented && Rack::Utils.secure_compare(expected, presented)
60
61
  @app.call(env)
@@ -28,16 +28,19 @@ module Woods
28
28
  # @param argv [Array<String>] Command-line arguments
29
29
  # @return [String] Validated index directory path
30
30
  def self.resolve_index_dir(argv)
31
- dir = argv[0] || ENV['WOODS_DIR'] || Dir.pwd
31
+ dir = argv[0] || ENV['WOODS_DIR'] || ENV['WOODS_OUTPUT'] || Dir.pwd
32
+ examined = dir.empty? ? '(empty path)' : File.expand_path(dir)
32
33
 
33
34
  unless Dir.exist?(dir)
34
- warn "Error: Index directory does not exist: #{dir}"
35
+ warn "Error: Index directory does not exist: #{examined}"
36
+ warn index_path_remedy
35
37
  exit 1
36
38
  end
37
39
 
38
40
  unless manifest_present?(dir)
39
- warn "Error: No manifest.json found in: #{dir}"
40
- warn 'Run `bundle exec rake woods:extract` in your Rails app first.'
41
+ warn "Error: Could not resolve a published Woods index in: #{examined}"
42
+ warn 'Expected generation.json pointing to a payload manifest.json, or a legacy flat manifest.json.'
43
+ warn index_path_remedy
41
44
  exit 1
42
45
  end
43
46
 
@@ -61,9 +64,19 @@ module Woods
61
64
 
62
65
  generation = Woods::Generation.new(output_dir: dir)
63
66
  generation.payload_dir(generation.current).join('manifest.json').file?
67
+ rescue TypeError, NoMethodError
68
+ # A malformed marker must remain a startup failure, with the same
69
+ # selected-path guidance as a missing or unresolved payload.
70
+ false
64
71
  end
65
72
  private_class_method :manifest_present?
66
73
 
74
+ def self.index_path_remedy
75
+ 'Point at the existing index with an explicit path, WOODS_DIR, or WOODS_OUTPUT. ' \
76
+ 'If no index exists, run `bundle exec rake woods:extract` in your Rails app.'
77
+ end
78
+ private_class_method :index_path_remedy
79
+
67
80
  # Build a snapshot store for temporal tracking.
68
81
  #
69
82
  # Auto-enables when a SQLite database already exists in the index directory,
@@ -163,7 +176,9 @@ module Woods
163
176
  retriever = build_retriever_from_config(config, resolved, artifact, state)
164
177
  probe_and_mark_state(config, state)
165
178
  derive_state_from_store_health(state)
166
- warn "[woods-mcp] semantic search: #{state.status} (#{config.embedding_provider})"
179
+ corpus = retriever.corpus_status(include_types: false) if retriever.respond_to?(:corpus_status)
180
+ corpus_note = corpus ? "; corpus: #{corpus[:state]}" : ''
181
+ warn "[woods-mcp] semantic search: #{state.status} (#{config.embedding_provider})#{corpus_note}"
167
182
 
168
183
  [retriever, state]
169
184
  end
@@ -295,7 +295,8 @@ module Woods
295
295
  unless resolved.embedding_provider
296
296
  warn '[woods-mcp] no woods.json and no embedding provider — serving ' \
297
297
  'pattern/structural tools only. Run `rake woods:embed`, or set ' \
298
- 'OPENAI_API_KEY / run Ollama, to enable semantic search.'
298
+ 'OPENAI_API_KEY / run Ollama, to enable semantic search. For ranked discovery with no embeddings, ' \
299
+ 'set WOODS_RETRIEVAL_MODE=lexical in the MCP process environment and restart the server.'
299
300
  end
300
301
  [resolved, :autodetect]
301
302
  end
@@ -60,11 +60,16 @@ module Woods
60
60
  # @param auto_refresh [Boolean] re-read the index when its published
61
61
  # generation moves. On by default; specs that assert caching behaviour
62
62
  # turn it off.
63
- # @raise [ArgumentError] if directory doesn't exist or has no manifest.json
63
+ # @raise [ArgumentError] if directory doesn't exist or no published index resolves
64
64
  def initialize(index_dir, auto_refresh: true)
65
65
  @index_dir = Pathname.new(index_dir)
66
66
  raise ArgumentError, "Index directory does not exist: #{index_dir}" unless @index_dir.directory?
67
- raise ArgumentError, "No manifest.json found in: #{index_dir}" unless manifest_present?
67
+ unless manifest_present?
68
+ raise ArgumentError, "Could not resolve a published Woods index in: #{@index_dir.expand_path}\n" \
69
+ 'Expected generation.json pointing to a payload manifest.json, or a legacy flat manifest.json. ' \
70
+ 'Point IndexReader at an existing index root. ' \
71
+ 'If no index exists, run `bundle exec rake woods:extract` in your Rails app.'
72
+ end
68
73
 
69
74
  @unit_cache = {}
70
75
  @unit_cache_signatures = {}
@@ -963,6 +968,10 @@ module Woods
963
968
 
964
969
  marker = Woods::Generation.new(output_dir: @index_dir).current
965
970
  resolve_payload_dir(marker).join('manifest.json').file?
971
+ rescue TypeError, NoMethodError
972
+ # Match Bootstrapper's startup preflight for malformed marker shapes.
973
+ # Keep this local: errors during later generation refresh still surface.
974
+ false
966
975
  end
967
976
 
968
977
  # The loaded generation's payload directory, without a freshness check.