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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +500 -420
- data/CONTRIBUTING.md +29 -17
- data/README.md +78 -178
- data/docs/AGENT_GUIDE.md +52 -11
- data/docs/AGENT_SETUP.md +34 -17
- data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
- data/docs/BACKEND_MATRIX.md +18 -7
- data/docs/CLIENT_HOOKS.md +1 -1
- data/docs/CONFIGURATION_REFERENCE.md +105 -29
- data/docs/CONSOLE_MCP_SETUP.md +54 -9
- data/docs/DOCKER_SETUP.md +16 -1
- data/docs/EVALUATION.md +10 -4
- data/docs/EXTRACTOR_REFERENCE.md +23 -3
- data/docs/FAQ.md +14 -3
- data/docs/GETTING_STARTED.md +18 -17
- data/docs/INCREMENTAL_EXTRACTION.md +37 -8
- data/docs/INDEX_LAYOUT.md +2 -2
- data/docs/MCP_SERVERS.md +79 -7
- data/docs/MCP_TOOL_COOKBOOK.md +5 -5
- data/docs/MCP_WORKTREE_SETUP.md +55 -83
- data/docs/PUBLISHED_INDEX.md +17 -0
- data/docs/README.md +2 -1
- data/docs/RETRIEVAL_GUIDE.md +81 -13
- data/docs/SOURCE_FRESHNESS.md +1 -1
- data/docs/TOKEN_BENCHMARK.md +16 -10
- data/docs/TROUBLESHOOTING.md +142 -47
- data/docs/UPGRADING_TO_2.md +12 -6
- data/docs/WATCH_DAEMON.md +189 -24
- data/docs/WHY_WOODS.md +9 -5
- data/exe/woods-console +13 -11
- data/exe/woods-mcp-start +14 -9
- data/exe/woods-watch +5 -0
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/generators/woods/watch_generator.rb +53 -0
- data/lib/puma/plugin/woods.rb +10 -0
- data/lib/tasks/woods.rake +14 -0
- data/lib/woods/agent_configuration/applier.rb +5 -3
- data/lib/woods/agent_configuration/cli.rb +2 -2
- data/lib/woods/agent_configuration/layout.rb +13 -0
- data/lib/woods/cache/cache_middleware.rb +6 -0
- data/lib/woods/console/credential_scanner.rb +4 -3
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +31 -9
- data/lib/woods/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/stdio_transport.rb +27 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/embedding/indexer.rb +24 -14
- data/lib/woods/extractor.rb +70 -19
- data/lib/woods/extractors/declared_parent.rb +55 -0
- data/lib/woods/extractors/graphql_extractor.rb +2 -11
- data/lib/woods/extractors/lib_extractor.rb +10 -8
- data/lib/woods/extractors/mailer_extractor.rb +6 -10
- data/lib/woods/extractors/model_extractor.rb +1 -15
- data/lib/woods/extractors/poro_extractor.rb +10 -8
- data/lib/woods/extractors/shared_utility_methods.rb +22 -5
- data/lib/woods/git_command.rb +6 -7
- data/lib/woods/git_provenance.rb +4 -6
- data/lib/woods/mcp/bearer_auth.rb +2 -1
- data/lib/woods/mcp/bootstrapper.rb +20 -5
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +11 -2
- data/lib/woods/mcp/initialization_guidance.rb +1 -1
- data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
- data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
- data/lib/woods/mcp/server.rb +63 -37
- data/lib/woods/mcp/tool_contract.rb +1 -1
- data/lib/woods/mcp/tool_response_renderer.rb +16 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
- data/lib/woods/mcp/traversal_response.rb +22 -0
- data/lib/woods/path_dispatcher.rb +6 -5
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/rake_helpers.rb +2 -12
- data/lib/woods/retrieval/corpus_status.rb +46 -0
- data/lib/woods/retrieval/lexical_assembler.rb +14 -3
- data/lib/woods/retrieval/lexical_index.rb +2 -1
- data/lib/woods/retriever.rb +19 -7
- data/lib/woods/session_tracer/file_store.rb +6 -1
- data/lib/woods/source_inputs/consumer_errors.rb +4 -0
- data/lib/woods/storage/local_corpus_stats.rb +32 -0
- data/lib/woods/storage/metadata_store.rb +20 -0
- data/lib/woods/storage/pgvector.rb +6 -2
- data/lib/woods/storage/vector_store.rb +10 -0
- data/lib/woods/temporal/json_snapshot_store.rb +35 -7
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/child_environment.rb +30 -0
- data/lib/woods/watch/cli.rb +91 -0
- data/lib/woods/watch/daemon.rb +73 -11
- data/lib/woods/watch/event_stream.rb +70 -0
- data/lib/woods/watch/guardian.rb +142 -0
- data/lib/woods/watch/installation/layout.rb +70 -0
- data/lib/woods/watch/installation/options.rb +128 -0
- data/lib/woods/watch/installation/planner.rb +128 -0
- data/lib/woods/watch/installation/probe.rb +101 -0
- data/lib/woods/watch/installation/receipt.rb +77 -0
- data/lib/woods/watch/installation/recovery.rb +64 -0
- data/lib/woods/watch/installation/templates.rb +58 -0
- data/lib/woods/watch/installation.rb +56 -0
- data/lib/woods/watch/lifecycle.rb +182 -0
- data/lib/woods/watch/managed_child.rb +113 -0
- data/lib/woods/watch/managed_cleanup.rb +48 -0
- data/lib/woods/watch/managed_process.rb +144 -0
- data/lib/woods/watch/puma_adapter.rb +87 -0
- data/lib/woods/watch/puma_child.rb +66 -0
- data/lib/woods/watch/supervision_records.rb +95 -0
- data/lib/woods/watch/supervision_status.rb +104 -0
- data/lib/woods/watch/supervisor.rb +161 -0
- data/lib/woods/watch/supervisor_reporting.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/woods-input-rules.sh +4 -4
- data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
- data/plugin/skills/woods-diagnose/SKILL.md +134 -34
- data/plugin/skills/woods-investigate/SKILL.md +54 -15
- data/plugin/skills/woods-mcp-config/SKILL.md +38 -11
- data/plugin/skills/woods-setup/SKILL.md +72 -15
- metadata +38 -5
data/lib/woods/extractor.rb
CHANGED
|
@@ -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
|
|
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}.
|
|
2113
|
-
'
|
|
2114
|
-
|
|
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
|
|
2122
|
-
#
|
|
2123
|
-
#
|
|
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
|
|
2764
|
-
#
|
|
2765
|
-
#
|
|
2766
|
-
#
|
|
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
|
-
|
|
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
|
|
3727
|
-
#
|
|
3728
|
-
|
|
3729
|
-
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
|
200
|
+
# @param parent_class [String, nil] Selected explicit parent
|
|
199
201
|
# @return [Hash] Lib unit metadata
|
|
200
|
-
def extract_metadata(source,
|
|
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:
|
|
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.
|
|
120
|
-
default_from =
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
205
|
+
# @param parent_class [String, nil] Selected explicit parent
|
|
204
206
|
# @return [Hash] PORO metadata
|
|
205
|
-
def extract_metadata(source,
|
|
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:
|
|
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
|
-
# @
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
#
|
data/lib/woods/git_command.rb
CHANGED
|
@@ -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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
|
20
|
+
# Environment variable naming the explicitly selected git directory.
|
|
22
21
|
OVERRIDE_KEY = 'WOODS_GIT_DIR'
|
|
23
22
|
|
|
24
23
|
module_function
|
data/lib/woods/git_provenance.rb
CHANGED
|
@@ -85,12 +85,10 @@ module Woods
|
|
|
85
85
|
''
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
# +WOODS_GIT_DIR+
|
|
89
|
-
# set.
|
|
90
|
-
#
|
|
91
|
-
#
|
|
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
|
-
|
|
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: #{
|
|
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:
|
|
40
|
-
warn '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|