woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module MCP
|
|
7
|
+
# Pins reader-backed MCP handlers to one cached generation per request.
|
|
8
|
+
# Installed on each built server's singleton class so the captured reader
|
|
9
|
+
# never leaks to another Woods server or a foreign MCP::Server instance.
|
|
10
|
+
#
|
|
11
|
+
# Coverage is derived from every tool the server actually has registered
|
|
12
|
+
# at install time, not a hand-maintained allowlist. A static list of
|
|
13
|
+
# "reader tools" silently stopped covering new tools the moment one was
|
|
14
|
+
# added and nobody remembered to update the list — the pin wrapper once
|
|
15
|
+
# covered 14 of 28 non-reload tools this way, leaving retrieval,
|
|
16
|
+
# pipeline, snapshot, and feedback calls free to run concurrently with an
|
|
17
|
+
# exclusive reload. Pinning a handler that turns out not to touch the
|
|
18
|
+
# reader costs an uncontended mutex + refcount bump; leaving one
|
|
19
|
+
# unpinned that does touch it is the correctness bug. Default to pinned.
|
|
20
|
+
module IndexReaderPinning
|
|
21
|
+
# Tools deliberately left outside the pin gate, each with its own
|
|
22
|
+
# justification — anything not listed here is pinned by construction.
|
|
23
|
+
#
|
|
24
|
+
# `reload` — the exclusive writer `with_pinned_generation` exists to
|
|
25
|
+
# hold readers back from. Pinning its own handler would acquire a pin
|
|
26
|
+
# first and then, inside the same call, ask `with_exclusive_reload` to
|
|
27
|
+
# wait for every outstanding pin to drain — including the one the
|
|
28
|
+
# handler itself is still holding. Self-deadlock, not missed coverage.
|
|
29
|
+
#
|
|
30
|
+
# No other registered tool qualifies as of this audit round. The
|
|
31
|
+
# operator/feedback/snapshot handlers read their own collaborators
|
|
32
|
+
# (StatusReporter, the feedback store, the snapshot store) rather than
|
|
33
|
+
# the shared `reader`, so pinning them is a no-op today — but it is a
|
|
34
|
+
# free no-op, and the alternative is a hand-maintained allowlist that
|
|
35
|
+
# silently stops covering a handler the moment it starts reading
|
|
36
|
+
# `reader` too. Default to pinned; name the exception, don't guess it.
|
|
37
|
+
NON_READER_TOOL_NAMES = Set.new(
|
|
38
|
+
%w[
|
|
39
|
+
reload
|
|
40
|
+
]
|
|
41
|
+
).freeze
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# @param server [MCP::Server]
|
|
45
|
+
# @param reader [Woods::MCP::IndexReader]
|
|
46
|
+
# @return [MCP::Server]
|
|
47
|
+
def install(server, reader:)
|
|
48
|
+
server.singleton_class.prepend(Dispatch)
|
|
49
|
+
server.instance_variable_set(:@woods_index_reader, reader)
|
|
50
|
+
server.instance_variable_set(
|
|
51
|
+
:@woods_index_reader_tool_names,
|
|
52
|
+
(server.tools.keys.to_set - NON_READER_TOOL_NAMES).freeze
|
|
53
|
+
)
|
|
54
|
+
server
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
module Dispatch
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def call_tool(request, **kwargs)
|
|
62
|
+
reader = @woods_index_reader
|
|
63
|
+
tool_names = @woods_index_reader_tool_names
|
|
64
|
+
return super unless reader && tool_names&.include?(request[:name])
|
|
65
|
+
|
|
66
|
+
reader.with_pinned_generation { super }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def read_resource_contents(request, **kwargs)
|
|
70
|
+
reader = @woods_index_reader
|
|
71
|
+
return super unless reader
|
|
72
|
+
|
|
73
|
+
reader.with_pinned_generation { super }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -39,7 +39,7 @@ module Woods
|
|
|
39
39
|
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
|
|
40
40
|
|
|
41
41
|
ALLOWED_METHODS = 'GET, POST, DELETE, OPTIONS'
|
|
42
|
-
ALLOWED_HEADERS = 'Authorization, Content-Type, Mcp-Session-Id'
|
|
42
|
+
ALLOWED_HEADERS = 'Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id'
|
|
43
43
|
|
|
44
44
|
# Response bodies are emitted as constants so the rejected Origin /
|
|
45
45
|
# Host value is NEVER echoed back to the caller — preventing a
|
|
@@ -48,14 +48,39 @@ module Woods
|
|
|
48
48
|
FORBIDDEN_BODY = { jsonrpc: '2.0', error: { code: -32_002, message: 'Origin not allowed' }, id: nil }.to_json.freeze
|
|
49
49
|
FORBIDDEN_HOST_BODY = { jsonrpc: '2.0', error: { code: -32_002, message: 'Host not allowed' }, id: nil }.to_json.freeze
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
# @param app [#call] The next Rack app in the middleware stack
|
|
52
|
+
# @param allowed_origins [Array<String>, #call, nil] Origin allow-list,
|
|
53
|
+
# or a callable returning one. A callable is resolved (and memoized)
|
|
54
|
+
# on the first guarded request, so an allow-list configured after the
|
|
55
|
+
# middleware was inserted — e.g. in `config/initializers/woods.rb`,
|
|
56
|
+
# which runs after Rails railtie initializers captured the middleware
|
|
57
|
+
# arguments — still takes effect (#183). Empty/nil falls back to
|
|
58
|
+
# {DEFAULT_ALLOWED}.
|
|
59
|
+
# @param path [String, nil] When set, only requests whose PATH_INFO
|
|
60
|
+
# starts with this prefix are guarded — everything else passes
|
|
61
|
+
# straight through to the app. Nil (the default) guards every request.
|
|
62
|
+
# @param enabled [#call, nil] Optional request-time predicate. When it
|
|
63
|
+
# returns falsy the request passes through unguarded. Nil (the
|
|
64
|
+
# default) means always guard.
|
|
65
|
+
def initialize(app, allowed_origins: nil, path: nil, enabled: nil)
|
|
52
66
|
@app = app
|
|
53
|
-
@
|
|
54
|
-
@
|
|
55
|
-
|
|
67
|
+
@path = path
|
|
68
|
+
@enabled = enabled
|
|
69
|
+
if allowed_origins.respond_to?(:call)
|
|
70
|
+
@allowed_source = allowed_origins
|
|
71
|
+
else
|
|
72
|
+
build_allow_list(allowed_origins)
|
|
73
|
+
end
|
|
56
74
|
end
|
|
57
75
|
|
|
76
|
+
# Rack entry point. Out-of-scope requests (non-matching `path:` prefix
|
|
77
|
+
# or a falsy `enabled:` predicate) pass through untouched.
|
|
78
|
+
#
|
|
79
|
+
# @param env [Hash] Rack environment
|
|
80
|
+
# @return [Array] Rack response triple
|
|
58
81
|
def call(env)
|
|
82
|
+
return @app.call(env) unless guard?(env)
|
|
83
|
+
|
|
59
84
|
origin = env['HTTP_ORIGIN']
|
|
60
85
|
method = env['REQUEST_METHOD']
|
|
61
86
|
host = env['HTTP_HOST']
|
|
@@ -72,6 +97,40 @@ module Woods
|
|
|
72
97
|
|
|
73
98
|
private
|
|
74
99
|
|
|
100
|
+
# @param env [Hash] Rack environment
|
|
101
|
+
# @return [Boolean] whether this request falls under the guard
|
|
102
|
+
def guard?(env)
|
|
103
|
+
return false if @path && !env['PATH_INFO'].to_s.start_with?(@path)
|
|
104
|
+
return false if @enabled && !@enabled.call
|
|
105
|
+
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Normalize a raw allow-list into `@allowed` + `@allowed_hosts`.
|
|
110
|
+
#
|
|
111
|
+
# @param origins [Array<String>, nil]
|
|
112
|
+
# @return [Array<String>] the normalized allow-list
|
|
113
|
+
def build_allow_list(origins)
|
|
114
|
+
allowed = Array(origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) }
|
|
115
|
+
allowed = DEFAULT_ALLOWED.dup if allowed.empty?
|
|
116
|
+
@allowed_hosts = allowed.map { |o| extract_host(o) }.compact.uniq
|
|
117
|
+
@allowed = allowed
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The allow-list, resolving a deferred source on first use. Memoized —
|
|
121
|
+
# configuration is settled by the time the first request arrives.
|
|
122
|
+
#
|
|
123
|
+
# @return [Array<String>]
|
|
124
|
+
def allowed
|
|
125
|
+
@allowed || build_allow_list(@allowed_source.call)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Array<String>] hosts extracted from the allow-list
|
|
129
|
+
def allowed_hosts
|
|
130
|
+
allowed
|
|
131
|
+
@allowed_hosts
|
|
132
|
+
end
|
|
133
|
+
|
|
75
134
|
def normalize(origin)
|
|
76
135
|
origin.to_s.sub(%r{/\z}, '').downcase
|
|
77
136
|
end
|
|
@@ -96,13 +155,13 @@ module Woods
|
|
|
96
155
|
|
|
97
156
|
return true if LOOPBACK_HOSTS.include?(bare)
|
|
98
157
|
|
|
99
|
-
|
|
158
|
+
allowed_hosts.include?(normalized) || allowed_hosts.include?(bare)
|
|
100
159
|
end
|
|
101
160
|
|
|
102
161
|
def origin_allowed?(origin)
|
|
103
162
|
return false if origin.match?(/[[:cntrl:]]/)
|
|
104
163
|
|
|
105
|
-
|
|
164
|
+
allowed.include?(normalize(origin)) || allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
|
|
106
165
|
end
|
|
107
166
|
|
|
108
167
|
def preflight(origin)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module MCP
|
|
5
|
+
# Protocol-level policy shared by both MCP servers (index and console).
|
|
6
|
+
#
|
|
7
|
+
# Everything here is a decision the MCP 2026-07-28 revision asks a server to
|
|
8
|
+
# make but the SDK cannot make for us: how long a list/read result stays
|
|
9
|
+
# fresh, who is allowed to cache it, and what order tools are advertised in.
|
|
10
|
+
#
|
|
11
|
+
# @see docs/design/MCP_2026_STRATEGY.md
|
|
12
|
+
module ProtocolPolicy
|
|
13
|
+
# SEP-2549 `cacheScope`. **Always `"private"`, deliberately not configurable.**
|
|
14
|
+
#
|
|
15
|
+
# Every result Woods can return describes the user's own codebase — tool
|
|
16
|
+
# descriptions naming their models, resource reads carrying their source.
|
|
17
|
+
# `"public"` authorises shared intermediaries (a corporate proxy, a gateway,
|
|
18
|
+
# a CDN in front of `woods-mcp-http`) to cache and re-serve that to other
|
|
19
|
+
# callers. The SDK's fallback when only `ttl_ms` is set is `"public"`, so
|
|
20
|
+
# setting the ttl and leaving the scope alone is the one combination that
|
|
21
|
+
# silently opts into the bad outcome — which is exactly why this is pinned
|
|
22
|
+
# rather than defaulted.
|
|
23
|
+
CACHE_SCOPE = 'private'
|
|
24
|
+
|
|
25
|
+
# SEP-2549 `ttlMs`, a freshness hint with max-age semantics (0 = do not cache).
|
|
26
|
+
#
|
|
27
|
+
# The SDK applies one server-level value to `tools/list`, `prompts/list`,
|
|
28
|
+
# `resources/list`, `resources/read` and `resources/templates/list` alike,
|
|
29
|
+
# so this has to be safe for the most volatile of them — a `resources/read`
|
|
30
|
+
# against an index a watch daemon may rewrite seconds from now.
|
|
31
|
+
#
|
|
32
|
+
# Ten seconds collapses the burst of list calls an agent makes when it
|
|
33
|
+
# opens a session without letting a daemon-driven index change go unseen
|
|
34
|
+
# for meaningfully longer than the poll interval that produced it. Callers
|
|
35
|
+
# that never run a daemon can raise it via WOODS_MCP_CACHE_TTL_MS.
|
|
36
|
+
DEFAULT_TTL_MS = 10_000
|
|
37
|
+
|
|
38
|
+
# Environment override for {DEFAULT_TTL_MS}. `0` disables caching.
|
|
39
|
+
TTL_ENV_KEY = 'WOODS_MCP_CACHE_TTL_MS'
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Resolved `ttlMs` for this process.
|
|
43
|
+
#
|
|
44
|
+
# A malformed or negative value falls back to the default rather than
|
|
45
|
+
# raising: this is a cache hint on a server an agent is mid-conversation
|
|
46
|
+
# with, and refusing to boot over a typo'd env var trades a mild
|
|
47
|
+
# performance regression for a total outage.
|
|
48
|
+
#
|
|
49
|
+
# @return [Integer] non-negative milliseconds
|
|
50
|
+
def ttl_ms
|
|
51
|
+
raw = ENV.fetch(TTL_ENV_KEY, nil)
|
|
52
|
+
return DEFAULT_TTL_MS if raw.nil? || raw.strip.empty?
|
|
53
|
+
|
|
54
|
+
value = Integer(raw, exception: false)
|
|
55
|
+
return DEFAULT_TTL_MS if value.nil? || value.negative?
|
|
56
|
+
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Keyword arguments for `MCP::Server.new` carrying the cache hints.
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash{Symbol => Object}]
|
|
63
|
+
def cache_hints
|
|
64
|
+
{ ttl_ms: ttl_ms, cache_scope: CACHE_SCOPE }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Sort a built server's tools by name, in place.
|
|
68
|
+
#
|
|
69
|
+
# MCP 2026-07-28 asks servers to return `tools/list` in a deterministic
|
|
70
|
+
# order, explicitly so clients can cache the list and so the tool block
|
|
71
|
+
# lands identically in an LLM's prompt cache across turns. The SDK lists
|
|
72
|
+
# `@tools.values` — Hash insertion order — and Woods registers 14
|
|
73
|
+
# always-on tools followed by up to 15 more gated on which collaborators
|
|
74
|
+
# happen to be wired. Two hosts with different integrations therefore
|
|
75
|
+
# advertise the same tools in different orders, and enabling an
|
|
76
|
+
# integration reorders the block for everyone on that host.
|
|
77
|
+
#
|
|
78
|
+
# Sorting also fixes cursor pagination, which the SDK implements by
|
|
79
|
+
# offset over that same unordered collection.
|
|
80
|
+
#
|
|
81
|
+
# This reaches into an SDK ivar because `define_tool` is the only
|
|
82
|
+
# registration API and it appends. The reach is contained here and
|
|
83
|
+
# no-ops if the internal shape ever changes, so a future SDK refactor
|
|
84
|
+
# degrades to "unsorted, as before" rather than breaking the server.
|
|
85
|
+
#
|
|
86
|
+
# @param server [MCP::Server]
|
|
87
|
+
# @return [MCP::Server] the same server, for chaining
|
|
88
|
+
def sort_tools!(server)
|
|
89
|
+
tools = server.instance_variable_get(:@tools)
|
|
90
|
+
return server unless tools.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
server.instance_variable_set(:@tools, tools.sort_by { |name, _| name.to_s }.to_h)
|
|
93
|
+
server
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
require 'net/http'
|
|
4
4
|
require 'uri'
|
|
5
5
|
|
|
6
|
+
require_relative 'errors'
|
|
7
|
+
require_relative '../embedding/provider'
|
|
8
|
+
require_relative '../embedding/openai'
|
|
9
|
+
require_relative '../embedding/fake'
|
|
10
|
+
|
|
6
11
|
module Woods
|
|
7
12
|
module MCP
|
|
8
13
|
# Probes an embedding provider's HTTP endpoint to confirm it is reachable
|
|
@@ -39,28 +44,62 @@ module Woods
|
|
|
39
44
|
# with +reason: "unauthorized"+ because an invalid key means the
|
|
40
45
|
# provider cannot be used; network failures raise with the appropriate
|
|
41
46
|
# reason string.
|
|
42
|
-
# -
|
|
47
|
+
# - {Woods::Embedding::Provider::Fake} → trivially reachable (#178).
|
|
48
|
+
# The provider is deterministic and in-process; there is no endpoint
|
|
49
|
+
# to probe, so the probe succeeds without any network I/O.
|
|
50
|
+
# - Any other object responding to +#embed+ and +#embed_batch+ — an
|
|
51
|
+
# injected provider object, the third shape
|
|
52
|
+
# {Woods::Builder#build_embedding_provider} accepts (#178) — is
|
|
53
|
+
# presumed reachable. An arbitrary host-supplied implementation has
|
|
54
|
+
# no endpoint contract this probe could exercise, so the probe
|
|
55
|
+
# succeeds without any network I/O and the provider's first real
|
|
56
|
+
# call surfaces connectivity or credential errors.
|
|
57
|
+
# - Anything else → raises +ArgumentError+.
|
|
43
58
|
#
|
|
44
59
|
# @param provider [Woods::Embedding::Provider::Ollama,
|
|
45
|
-
# Woods::Embedding::Provider::OpenAI
|
|
60
|
+
# Woods::Embedding::Provider::OpenAI,
|
|
61
|
+
# Woods::Embedding::Provider::Fake, Object] a concrete embedding
|
|
62
|
+
# provider, or an injected object implementing +#embed+/+#embed_batch+
|
|
46
63
|
# @return [Object] the same +provider+ if reachable
|
|
47
64
|
# @raise [Woods::MCP::ProviderUnreachable] if the endpoint is unreachable,
|
|
48
65
|
# times out, returns 5xx, or (for OpenAI) returns 401
|
|
49
|
-
# @raise [ArgumentError] if +provider+ is
|
|
66
|
+
# @raise [ArgumentError] if +provider+ is neither a recognised provider
|
|
67
|
+
# class nor an object implementing +#embed+ and +#embed_batch+
|
|
50
68
|
def self.reachable!(provider)
|
|
51
69
|
case provider
|
|
52
70
|
when Woods::Embedding::Provider::Ollama
|
|
53
71
|
probe_ollama!(provider)
|
|
54
72
|
when Woods::Embedding::Provider::OpenAI
|
|
55
73
|
probe_openai!(provider)
|
|
74
|
+
when Woods::Embedding::Provider::Fake
|
|
75
|
+
# In-process, no endpoint: nothing to probe (#178). The case arm
|
|
76
|
+
# is deliberately empty — the method returns +provider+ below.
|
|
77
|
+
nil
|
|
56
78
|
else
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
unless provider_object?(provider)
|
|
80
|
+
raise ArgumentError,
|
|
81
|
+
"#{self}.reachable! does not know how to probe #{provider.class} — " \
|
|
82
|
+
'the object must implement #embed and #embed_batch, or be a known ' \
|
|
83
|
+
'provider class (OpenAI, Ollama, Fake)'
|
|
84
|
+
end
|
|
85
|
+
# Injected provider object: presumed reachable (see above). The
|
|
86
|
+
# arm deliberately performs no network I/O — the method returns
|
|
87
|
+
# +provider+ below, like the Fake arm.
|
|
88
|
+
nil
|
|
60
89
|
end
|
|
61
90
|
provider
|
|
62
91
|
end
|
|
63
92
|
|
|
93
|
+
# Duck-type check for an injected provider object — the same two-method
|
|
94
|
+
# contract {Woods::Builder#provider_object?} accepts (#178).
|
|
95
|
+
#
|
|
96
|
+
# @param provider [Object]
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
def self.provider_object?(provider)
|
|
99
|
+
provider.respond_to?(:embed) && provider.respond_to?(:embed_batch)
|
|
100
|
+
end
|
|
101
|
+
private_class_method :provider_object?
|
|
102
|
+
|
|
64
103
|
# Probe the Ollama instance backing +provider+.
|
|
65
104
|
#
|
|
66
105
|
# @param provider [Woods::Embedding::Provider::Ollama]
|
|
@@ -72,6 +72,14 @@ module Woods
|
|
|
72
72
|
lines << line
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
note = fetch_key(data, :note)
|
|
76
|
+
partial = fetch_key(data, :partial, false)
|
|
77
|
+
if partial || note
|
|
78
|
+
lines << ''
|
|
79
|
+
lines << '**partial:** true' if partial
|
|
80
|
+
lines << "**note:** #{note}" if note
|
|
81
|
+
end
|
|
82
|
+
|
|
75
83
|
lines.join("\n").rstrip
|
|
76
84
|
end
|
|
77
85
|
|
|
@@ -165,14 +173,16 @@ module Woods
|
|
|
165
173
|
lines << ''
|
|
166
174
|
end
|
|
167
175
|
|
|
168
|
-
|
|
176
|
+
GRAPH_ANALYSIS_SECTIONS.each do |section|
|
|
169
177
|
items = fetch_key(data, section)
|
|
170
178
|
next unless items.is_a?(Array) && items.any?
|
|
171
179
|
|
|
172
180
|
lines << "### #{section.tr('_', ' ').capitalize}"
|
|
173
181
|
lines << ''
|
|
174
182
|
items.each do |item|
|
|
175
|
-
lines << if item.is_a?(Hash) && item.key?('
|
|
183
|
+
lines << if item.is_a?(Hash) && item.key?('from')
|
|
184
|
+
graph_edge_line(item, bold: true)
|
|
185
|
+
elsif item.is_a?(Hash) && item.key?('score')
|
|
176
186
|
"- **#{item['identifier']}** (#{item['type']}) — score: #{item['score']}"
|
|
177
187
|
elsif item.is_a?(Hash)
|
|
178
188
|
"- **#{item['identifier']}** (#{item['type']}) — #{item['dependent_count']} dependents"
|
|
@@ -184,7 +194,9 @@ module Woods
|
|
|
184
194
|
total_key = "#{section}_total"
|
|
185
195
|
if data[total_key]
|
|
186
196
|
lines << ''
|
|
187
|
-
|
|
197
|
+
offset = fetch_key(data, "#{section}_offset", 0)
|
|
198
|
+
position = offset.positive? ? " from offset #{offset}" : ''
|
|
199
|
+
lines << "_Showing #{items.size} of #{data[total_key]}#{position} (truncated)_"
|
|
188
200
|
end
|
|
189
201
|
lines << ''
|
|
190
202
|
end
|
|
@@ -354,6 +366,40 @@ module Woods
|
|
|
354
366
|
|
|
355
367
|
private
|
|
356
368
|
|
|
369
|
+
# One line for an edge-shaped report item (from, to, via, then the
|
|
370
|
+
# remaining keys in the order the report emits them).
|
|
371
|
+
#
|
|
372
|
+
# @param item [Hash] string-keyed
|
|
373
|
+
# @param bold [Boolean] wrap the endpoints in ** for markdown
|
|
374
|
+
# @return [String]
|
|
375
|
+
def graph_edge_line(item, bold:)
|
|
376
|
+
detail = item.except('from', 'to', 'via')
|
|
377
|
+
.map { |key, value| "#{key}: #{edge_value(value)}" }.join(', ')
|
|
378
|
+
line = "#{edge_endpoint(item['from'], bold: bold)} -> #{edge_endpoint(item['to'], bold: bold)} " \
|
|
379
|
+
"(#{item['via']})"
|
|
380
|
+
detail.empty? ? "- #{line}" : "- #{line}: #{detail}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# A resolved endpoint is bolded (when markdown calls for it); a nil
|
|
384
|
+
# endpoint (an ambiguous foreign-key owner, for example) is a plain
|
|
385
|
+
# placeholder, never an empty bold pair (`****`).
|
|
386
|
+
#
|
|
387
|
+
# @param value [String, nil]
|
|
388
|
+
# @param bold [Boolean]
|
|
389
|
+
# @return [String]
|
|
390
|
+
def edge_endpoint(value, bold:)
|
|
391
|
+
return '(unresolved)' if value.nil?
|
|
392
|
+
|
|
393
|
+
bold ? "**#{value}**" : value
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# @param value [Object] a detail hash value; arrays (e.g. `ambiguous_owners`) join
|
|
397
|
+
# as plain text instead of printing Ruby's `Array#inspect` syntax.
|
|
398
|
+
# @return [Object]
|
|
399
|
+
def edge_value(value)
|
|
400
|
+
value.is_a?(Array) ? value.join(', ') : value
|
|
401
|
+
end
|
|
402
|
+
|
|
357
403
|
def render_traversal(label, data)
|
|
358
404
|
root = fetch_key(data, :root)
|
|
359
405
|
found = data[:found] || data['found']
|
|
@@ -372,14 +418,36 @@ module Woods
|
|
|
372
418
|
nodes.each do |id, info|
|
|
373
419
|
depth = fetch_key(info, :depth) || 0
|
|
374
420
|
deps = fetch_key(info, :deps, [])
|
|
421
|
+
# Present only where one identifier names units of several types,
|
|
422
|
+
# so the reader is told rather than shown one of them silently.
|
|
423
|
+
types = fetch_key(info, :types)
|
|
424
|
+
# Likewise for the database: the reader sets it only in a graph
|
|
425
|
+
# spanning more than one.
|
|
426
|
+
database = fetch_key(info, :database)
|
|
375
427
|
indent = ' ' * depth
|
|
376
|
-
|
|
428
|
+
suffix = types ? " (#{Array(types).join(', ')})" : ''
|
|
429
|
+
suffix += " [#{database}]" if database
|
|
430
|
+
lines << "#{indent}- **#{id}**#{suffix}"
|
|
377
431
|
deps.each { |d| lines << "#{indent} - #{d}" }
|
|
378
432
|
end
|
|
379
433
|
|
|
434
|
+
lines << '' << truncation_note(data, nodes.size) if fetch_key(data, :nodes_total)
|
|
435
|
+
|
|
380
436
|
lines.join("\n").rstrip
|
|
381
437
|
end
|
|
382
438
|
|
|
439
|
+
# The line `graph_analysis` prints over a paged section, reused
|
|
440
|
+
# verbatim for a paged traversal (B-183).
|
|
441
|
+
#
|
|
442
|
+
# @param data [Hash] the paged payload
|
|
443
|
+
# @param shown [Integer] entries in the page
|
|
444
|
+
# @return [String]
|
|
445
|
+
def truncation_note(data, shown)
|
|
446
|
+
offset = fetch_key(data, :nodes_offset, 0)
|
|
447
|
+
position = offset.positive? ? " from offset #{offset}" : ''
|
|
448
|
+
"_Showing #{shown} of #{fetch_key(data, :nodes_total)}#{position} (truncated)_"
|
|
449
|
+
end
|
|
450
|
+
|
|
383
451
|
def render_metadata_section(metadata)
|
|
384
452
|
lines = []
|
|
385
453
|
lines << '### Metadata'
|
|
@@ -69,9 +69,17 @@ module Woods
|
|
|
69
69
|
results.each do |r|
|
|
70
70
|
ident = fetch_key(r, :identifier)
|
|
71
71
|
type = fetch_key(r, :type)
|
|
72
|
-
|
|
72
|
+
match = fetch_key(r, :match_field)
|
|
73
|
+
line = " #{ident} (#{type})"
|
|
74
|
+
line += " - matched in #{match}" if match
|
|
75
|
+
lines << line
|
|
73
76
|
end
|
|
74
77
|
|
|
78
|
+
note = fetch_key(data, :note)
|
|
79
|
+
partial = fetch_key(data, :partial, false)
|
|
80
|
+
lines << 'partial: true' if partial
|
|
81
|
+
lines << "note: #{note}" if note
|
|
82
|
+
|
|
75
83
|
lines.join("\n").rstrip
|
|
76
84
|
end
|
|
77
85
|
|
|
@@ -135,13 +143,15 @@ module Woods
|
|
|
135
143
|
lines << ''
|
|
136
144
|
end
|
|
137
145
|
|
|
138
|
-
|
|
146
|
+
GRAPH_ANALYSIS_SECTIONS.each do |section|
|
|
139
147
|
items = fetch_key(data, section)
|
|
140
148
|
next unless items.is_a?(Array) && items.any?
|
|
141
149
|
|
|
142
150
|
lines << "#{section.tr('_', ' ').upcase}:"
|
|
143
151
|
items.each do |item|
|
|
144
|
-
lines << if item.is_a?(Hash)
|
|
152
|
+
lines << if item.is_a?(Hash) && item.key?('from')
|
|
153
|
+
" #{plain_edge_line(item)}"
|
|
154
|
+
elsif item.is_a?(Hash)
|
|
145
155
|
" #{item['identifier']} (#{item['type']}) - #{item['dependent_count']} dependents"
|
|
146
156
|
else
|
|
147
157
|
" #{item}"
|
|
@@ -149,7 +159,11 @@ module Woods
|
|
|
149
159
|
end
|
|
150
160
|
|
|
151
161
|
total_key = "#{section}_total"
|
|
152
|
-
|
|
162
|
+
offset = fetch_key(data, "#{section}_offset", 0)
|
|
163
|
+
if data[total_key]
|
|
164
|
+
position = offset.positive? ? " from offset #{offset}" : ''
|
|
165
|
+
lines << " (showing #{items.size} of #{data[total_key]}#{position}; truncated)"
|
|
166
|
+
end
|
|
153
167
|
lines << ''
|
|
154
168
|
end
|
|
155
169
|
|
|
@@ -202,7 +216,8 @@ module Woods
|
|
|
202
216
|
ident = fetch_key(r, :identifier)
|
|
203
217
|
type = fetch_key(r, :type)
|
|
204
218
|
modified = fetch_key(r, :last_modified) || '-'
|
|
205
|
-
|
|
219
|
+
author = fetch_key(r, :author) || '-'
|
|
220
|
+
lines << " #{ident} (#{type}) - #{modified} - #{author}"
|
|
206
221
|
end
|
|
207
222
|
|
|
208
223
|
lines.join("\n").rstrip
|
|
@@ -223,6 +238,31 @@ module Woods
|
|
|
223
238
|
|
|
224
239
|
private
|
|
225
240
|
|
|
241
|
+
# @param item [Hash] string-keyed edge-shaped report item
|
|
242
|
+
# @return [String]
|
|
243
|
+
def plain_edge_line(item)
|
|
244
|
+
detail = item.except('from', 'to', 'via')
|
|
245
|
+
.map { |key, value| "#{key}: #{edge_value(value)}" }.join(', ')
|
|
246
|
+
line = "#{edge_endpoint(item['from'])} -> #{edge_endpoint(item['to'])} (#{item['via']})"
|
|
247
|
+
detail.empty? ? line : "#{line}: #{detail}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# A nil endpoint (an ambiguous foreign-key owner, for example) prints
|
|
251
|
+
# as a placeholder rather than an empty string.
|
|
252
|
+
#
|
|
253
|
+
# @param value [String, nil]
|
|
254
|
+
# @return [String]
|
|
255
|
+
def edge_endpoint(value)
|
|
256
|
+
value.nil? ? '(unresolved)' : value
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# @param value [Object] a detail hash value; arrays (e.g. `ambiguous_owners`) join
|
|
260
|
+
# as plain text instead of printing Ruby's `Array#inspect` syntax.
|
|
261
|
+
# @return [Object]
|
|
262
|
+
def edge_value(value)
|
|
263
|
+
value.is_a?(Array) ? value.join(', ') : value
|
|
264
|
+
end
|
|
265
|
+
|
|
226
266
|
def render_plain_traversal(label, data)
|
|
227
267
|
root = fetch_key(data, :root)
|
|
228
268
|
found = data[:found] || data['found']
|
|
@@ -241,11 +281,19 @@ module Woods
|
|
|
241
281
|
nodes.each do |id, info|
|
|
242
282
|
depth = fetch_key(info, :depth) || 0
|
|
243
283
|
deps = fetch_key(info, :deps, [])
|
|
284
|
+
# Set by the reader only in a graph spanning more than one database.
|
|
285
|
+
database = fetch_key(info, :database)
|
|
244
286
|
indent = ' ' * (depth + 1)
|
|
245
|
-
lines << "#{indent}#{id}"
|
|
287
|
+
lines << "#{indent}#{id}#{" [#{database}]" if database}"
|
|
246
288
|
deps.each { |d| lines << "#{indent} -> #{d}" }
|
|
247
289
|
end
|
|
248
290
|
|
|
291
|
+
if fetch_key(data, :nodes_total)
|
|
292
|
+
offset = fetch_key(data, :nodes_offset, 0)
|
|
293
|
+
position = offset.positive? ? " from offset #{offset}" : ''
|
|
294
|
+
lines << " (showing #{nodes.size} of #{fetch_key(data, :nodes_total)}#{position}; truncated)"
|
|
295
|
+
end
|
|
296
|
+
|
|
249
297
|
lines.join("\n").rstrip
|
|
250
298
|
end
|
|
251
299
|
end
|