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
|
@@ -7,6 +7,8 @@ require 'json'
|
|
|
7
7
|
require 'pathname'
|
|
8
8
|
require 'set'
|
|
9
9
|
|
|
10
|
+
require_relative '../generation'
|
|
11
|
+
|
|
10
12
|
module Woods
|
|
11
13
|
module MCP
|
|
12
14
|
# Reads extraction output from disk for the MCP server.
|
|
@@ -29,7 +31,7 @@ module Woods
|
|
|
29
31
|
engines view_templates migrations action_cable_channels
|
|
30
32
|
scheduled_jobs rake_tasks state_machines events decorators
|
|
31
33
|
database_views caching factories test_mappings rails_source
|
|
32
|
-
poros libs
|
|
34
|
+
poros libs packages ruby_classes ruby_modules ruby_methods ruby_files
|
|
33
35
|
].freeze
|
|
34
36
|
|
|
35
37
|
# Singular type name for each directory (used in search filtering).
|
|
@@ -42,15 +44,200 @@ module Woods
|
|
|
42
44
|
MAX_UNIT_CACHE = 50
|
|
43
45
|
|
|
44
46
|
# @param index_dir [String] Path to extraction output directory
|
|
47
|
+
# @param auto_refresh [Boolean] re-read the index when its published
|
|
48
|
+
# generation moves. On by default; specs that assert caching behaviour
|
|
49
|
+
# turn it off.
|
|
45
50
|
# @raise [ArgumentError] if directory doesn't exist or has no manifest.json
|
|
46
|
-
def initialize(index_dir)
|
|
51
|
+
def initialize(index_dir, auto_refresh: true)
|
|
47
52
|
@index_dir = Pathname.new(index_dir)
|
|
48
53
|
raise ArgumentError, "Index directory does not exist: #{index_dir}" unless @index_dir.directory?
|
|
49
|
-
raise ArgumentError, "No manifest.json found in: #{index_dir}" unless
|
|
54
|
+
raise ArgumentError, "No manifest.json found in: #{index_dir}" unless manifest_present?
|
|
50
55
|
|
|
51
56
|
@unit_cache = {}
|
|
57
|
+
@unit_cache_signatures = {}
|
|
52
58
|
@unit_cache_order = []
|
|
53
59
|
@identifier_map = nil
|
|
60
|
+
@auto_refresh = auto_refresh
|
|
61
|
+
@pin_depth = 0
|
|
62
|
+
@pin_owners = Hash.new(0)
|
|
63
|
+
@exclusive_waiters = 0
|
|
64
|
+
@exclusive_owner = nil
|
|
65
|
+
# Pins waiting for the held pins to drain so a moved generation can be
|
|
66
|
+
# adopted (MCP-5). Gates NEW pin entries exactly as @exclusive_waiters
|
|
67
|
+
# does, so sustained overlapping traffic cannot pin the reader on a
|
|
68
|
+
# retired generation indefinitely.
|
|
69
|
+
@refresh_waiters = 0
|
|
70
|
+
@freshness_mutex = Mutex.new
|
|
71
|
+
@freshness_condition = ConditionVariable.new
|
|
72
|
+
# Separate from @freshness_mutex: the LRU bookkeeping must not
|
|
73
|
+
# serialize behind a generation check, and a reader that took the
|
|
74
|
+
# freshness lock must be able to touch the caches without deadlocking.
|
|
75
|
+
@cache_mutex = Mutex.new
|
|
76
|
+
@generation = Woods::Generation.new(output_dir: @index_dir)
|
|
77
|
+
@loaded_generation = nil
|
|
78
|
+
@loaded_token = nil
|
|
79
|
+
@generation_signature = nil
|
|
80
|
+
@payload_retention_pin = nil
|
|
81
|
+
# The directory the loaded generation's payload lives in. nil until a
|
|
82
|
+
# generation naming a payload is loaded; {#payload_dir} then falls back
|
|
83
|
+
# to the index root, which is every flat/pre-pointer index.
|
|
84
|
+
@payload_dir = nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The generation this reader's caches were populated from.
|
|
88
|
+
#
|
|
89
|
+
# @return [Integer, nil] nil until something has been read
|
|
90
|
+
attr_reader :loaded_generation
|
|
91
|
+
|
|
92
|
+
# Drop caches if the index has been rewritten since they were populated.
|
|
93
|
+
#
|
|
94
|
+
# This is what makes the MCP `reload` tool an optimization rather than a
|
|
95
|
+
# correctness requirement. A long-lived server used to hold whatever it
|
|
96
|
+
# read at boot until someone thought to call `reload` — so an agent
|
|
97
|
+
# working alongside a running extraction got answers describing the tree
|
|
98
|
+
# as of the last time the server happened to start.
|
|
99
|
+
#
|
|
100
|
+
# Called at the top of every public read. The cost is one `File.stat` of
|
|
101
|
+
# a ~100-byte file; the file is only parsed when its mtime or size moved,
|
|
102
|
+
# and the caches are only dropped when the generation number actually
|
|
103
|
+
# advanced. An index with no generation file (written before generations
|
|
104
|
+
# existed, or by a third party) never refreshes — same behaviour as
|
|
105
|
+
# before.
|
|
106
|
+
#
|
|
107
|
+
# The check-and-reload is a mutex-guarded critical section. The stdio
|
|
108
|
+
# transport serializes tool calls, but `woods-mcp-http` runs handlers on
|
|
109
|
+
# its Rack server's request threads — and unguarded, two concurrent reads
|
|
110
|
+
# race the signature bookkeeping: duplicate `reload!`s, or one request's
|
|
111
|
+
# freshly populated caches dropped mid-sequence by the other's refresh.
|
|
112
|
+
#
|
|
113
|
+
# @return [Integer, nil] the generation now loaded, or nil when the
|
|
114
|
+
# caches were already current
|
|
115
|
+
def ensure_fresh!
|
|
116
|
+
return nil unless @auto_refresh
|
|
117
|
+
|
|
118
|
+
thread = Thread.current
|
|
119
|
+
@freshness_mutex.synchronize do
|
|
120
|
+
wait_for_generation_access(thread)
|
|
121
|
+
refresh_if_stale
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Suppress cache invalidation for the duration of a block.
|
|
126
|
+
#
|
|
127
|
+
# {#ensure_fresh!} runs per read, which bounds staleness but does not
|
|
128
|
+
# make a *sequence* of reads consistent: a caller that reads the manifest
|
|
129
|
+
# and then a unit can straddle two generations if a write lands between
|
|
130
|
+
# them. Both halves describe a real state of the tree, but not the same
|
|
131
|
+
# one.
|
|
132
|
+
#
|
|
133
|
+
# Wrapping a multi-read operation checks freshness once, up front, and
|
|
134
|
+
# then holds it — so nothing already cached is dropped and re-read at a
|
|
135
|
+
# newer generation partway through.
|
|
136
|
+
#
|
|
137
|
+
# **What this does not do on a flat index:** an artifact that has never
|
|
138
|
+
# been read is loaded from whatever is on disk when the block reaches it.
|
|
139
|
+
# Guaranteeing more would mean materializing the whole index on entry,
|
|
140
|
+
# which is what {#warmup!} costs, per request. When the loaded generation
|
|
141
|
+
# names an immutable payload directory (its {Woods::Generation} carries a
|
|
142
|
+
# +payload+ pointer), that gap is closed for free: every read in the
|
|
143
|
+
# block resolves through {#payload_dir}, which is fixed at pin entry and
|
|
144
|
+
# names one immutable snapshot, so a never-read artifact still comes from
|
|
145
|
+
# the pinned generation even as a newer payload is published. A flat
|
|
146
|
+
# index carries no pointer and keeps the old behaviour — see
|
|
147
|
+
# docs/WATCH_DAEMON.md.
|
|
148
|
+
#
|
|
149
|
+
# Pins are *refcounted*, not a boolean. Under a threaded transport two
|
|
150
|
+
# requests can hold pins at once, and with a boolean the first to finish
|
|
151
|
+
# unpinned the reader while the second still relied on it — its remaining
|
|
152
|
+
# reads could then be invalidated mid-sequence, the exact tear this
|
|
153
|
+
# method exists to prevent. Freshness is checked when the depth goes
|
|
154
|
+
# 0 → 1; nested pins and the reads of a pin already held ride the
|
|
155
|
+
# generation it entered on, and invalidation resumes when the last pin
|
|
156
|
+
# releases.
|
|
157
|
+
#
|
|
158
|
+
# **Bounded staleness under sustained overlap (MCP-5).** Refresh used to
|
|
159
|
+
# be attempted ONLY at depth 0 → 1, so on a threaded transport with
|
|
160
|
+
# enough concurrent traffic — several agents against one shared
|
|
161
|
+
# `woods-mcp-http`, the deployment stateless mode exists for — the depth
|
|
162
|
+
# never reached 0 and a retired generation was served indefinitely, with
|
|
163
|
+
# nothing in the response saying so. A pin that arrives after the
|
|
164
|
+
# generation signature moved while pins are held now registers as a
|
|
165
|
+
# refresh waiter: like {#with_exclusive_reload}'s waiters it gates NEW
|
|
166
|
+
# pin entries, waits for the held pins to drain, refreshes, and admits
|
|
167
|
+
# the queue. Nested pins and existing holders are exempt, so the drain
|
|
168
|
+
# can always complete. The cost is one drain per published generation.
|
|
169
|
+
#
|
|
170
|
+
# The Index MCP server applies this around reader-backed handlers. Direct
|
|
171
|
+
# IndexReader callers remain responsible for pinning any multi-read
|
|
172
|
+
# operation that must not refresh between accessors.
|
|
173
|
+
#
|
|
174
|
+
# @example
|
|
175
|
+
# reader.with_pinned_generation { [reader.manifest, reader.find_unit("Post")] }
|
|
176
|
+
#
|
|
177
|
+
# @yield the block to run against a single generation
|
|
178
|
+
# @return [Object] the block's value
|
|
179
|
+
def with_pinned_generation
|
|
180
|
+
thread = Thread.current
|
|
181
|
+
exclusive_owner = false
|
|
182
|
+
@freshness_mutex.synchronize do
|
|
183
|
+
if @exclusive_owner.equal?(thread)
|
|
184
|
+
exclusive_owner = true
|
|
185
|
+
else
|
|
186
|
+
wait_for_generation_access(thread)
|
|
187
|
+
drain_for_pending_refresh(thread)
|
|
188
|
+
refresh_if_stale if @auto_refresh && @pin_depth.zero?
|
|
189
|
+
acquire_payload_retention_pin if @pin_depth.zero?
|
|
190
|
+
@pin_depth += 1
|
|
191
|
+
@pin_owners[thread] += 1
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
begin
|
|
196
|
+
yield
|
|
197
|
+
ensure
|
|
198
|
+
release_generation_pin(thread) unless exclusive_owner
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Reload all cached index state while excluding pinned readers.
|
|
203
|
+
#
|
|
204
|
+
# Existing pins may finish and nest normally. Once an exclusive reload is
|
|
205
|
+
# waiting, new pins wait until the reload block has assembled its result.
|
|
206
|
+
#
|
|
207
|
+
# @yieldparam manifest [Hash] the manifest loaded after caches are cleared
|
|
208
|
+
# @return [Object] the block's value
|
|
209
|
+
# @raise [ThreadError] when upgrading a pin or nesting an exclusive reload
|
|
210
|
+
def with_exclusive_reload
|
|
211
|
+
with_exclusive_generation do
|
|
212
|
+
reload!
|
|
213
|
+
yield manifest
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Hold the exclusive generation lock WITHOUT refreshing anything.
|
|
218
|
+
#
|
|
219
|
+
# This is the swap lock the reload transaction (M7) acquires after its
|
|
220
|
+
# candidate stores are built off-side. Inside the block the caller
|
|
221
|
+
# rechecks the generation and then swaps atomically; the reader's own
|
|
222
|
+
# caches are reloaded by the caller ONLY after the recheck passes, so a
|
|
223
|
+
# failed recheck leaves the previous fully aligned generation untouched.
|
|
224
|
+
# {#with_exclusive_reload} is this lock plus the unconditional reload.
|
|
225
|
+
#
|
|
226
|
+
# Waiters follow the same rules as {#with_exclusive_reload}: in-flight
|
|
227
|
+
# pins finish before the block runs, and new pins wait while it is held.
|
|
228
|
+
#
|
|
229
|
+
# @yield no arguments
|
|
230
|
+
# @return [Object] the block's value
|
|
231
|
+
# @raise [ThreadError] when upgrading a pin or nesting an exclusive lock
|
|
232
|
+
def with_exclusive_generation
|
|
233
|
+
thread = Thread.current
|
|
234
|
+
acquire_exclusive_generation(thread)
|
|
235
|
+
|
|
236
|
+
begin
|
|
237
|
+
yield
|
|
238
|
+
ensure
|
|
239
|
+
release_exclusive_generation(thread)
|
|
240
|
+
end
|
|
54
241
|
end
|
|
55
242
|
|
|
56
243
|
# Pre-populate cached state so the first MCP tool call doesn't pay
|
|
@@ -66,27 +253,18 @@ module Woods
|
|
|
66
253
|
#
|
|
67
254
|
# @return [Hash] Per-step outcome: `{step => true | Exception}`
|
|
68
255
|
def warmup!
|
|
69
|
-
|
|
70
|
-
manifest: -> { manifest },
|
|
71
|
-
summary: -> { summary },
|
|
72
|
-
dependency_graph: -> { dependency_graph },
|
|
73
|
-
graph_analysis: -> { graph_analysis },
|
|
74
|
-
identifier_map: -> { identifier_map }
|
|
75
|
-
}
|
|
76
|
-
steps.each_with_object({}) do |(step, runner), result|
|
|
77
|
-
runner.call
|
|
78
|
-
result[step] = true
|
|
79
|
-
rescue StandardError => e
|
|
80
|
-
result[step] = e
|
|
81
|
-
end
|
|
256
|
+
with_pinned_generation { warmup_steps }
|
|
82
257
|
end
|
|
83
258
|
|
|
84
259
|
# Clear all cached state so the next access re-reads from disk.
|
|
85
260
|
#
|
|
86
261
|
# @return [void]
|
|
87
262
|
def reload!
|
|
88
|
-
@
|
|
89
|
-
|
|
263
|
+
@cache_mutex.synchronize do
|
|
264
|
+
@unit_cache = {}
|
|
265
|
+
@unit_cache_signatures = {}
|
|
266
|
+
@unit_cache_order = []
|
|
267
|
+
end
|
|
90
268
|
@identifier_map = nil
|
|
91
269
|
@index_cache = {}
|
|
92
270
|
@manifest = nil
|
|
@@ -95,10 +273,31 @@ module Woods
|
|
|
95
273
|
@graph_analysis = nil
|
|
96
274
|
@raw_graph_data = nil
|
|
97
275
|
@normalized_graph_edges = nil
|
|
276
|
+
@graph_node_types = nil
|
|
277
|
+
remove_instance_variable(:@multi_database_graph) if defined?(@multi_database_graph)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The directory the current read resolves payload artifacts against —
|
|
281
|
+
# manifest, summary, graph, per-type indexes, unit files and precomputed
|
|
282
|
+
# flows. It is the immutable payload directory the loaded generation
|
|
283
|
+
# names, or the index root for a flat/pre-pointer index.
|
|
284
|
+
#
|
|
285
|
+
# Fixed for the life of a pin (only {#refresh_if_stale} moves it, and
|
|
286
|
+
# pins suppress refresh), so every artifact of a pinned read resolves
|
|
287
|
+
# through one generation. Public because callers that read payload files
|
|
288
|
+
# without going through this reader — the flow assembler, the
|
|
289
|
+
# precomputed-flow loader — have to resolve to the same generation this
|
|
290
|
+
# reader is serving, not to whatever is published when they look.
|
|
291
|
+
#
|
|
292
|
+
# @return [Pathname]
|
|
293
|
+
def payload_dir
|
|
294
|
+
ensure_fresh!
|
|
295
|
+
current_payload_dir
|
|
98
296
|
end
|
|
99
297
|
|
|
100
298
|
# @return [Hash] Parsed manifest.json
|
|
101
299
|
def manifest
|
|
300
|
+
ensure_fresh!
|
|
102
301
|
@manifest ||= parse_json('manifest.json')
|
|
103
302
|
end
|
|
104
303
|
|
|
@@ -115,22 +314,22 @@ module Woods
|
|
|
115
314
|
|
|
116
315
|
# @return [String, nil] SUMMARY.md content, or nil if not present
|
|
117
316
|
def summary
|
|
317
|
+
ensure_fresh!
|
|
118
318
|
@summary ||= begin
|
|
119
|
-
path =
|
|
120
|
-
path.file? ? path
|
|
319
|
+
path = current_payload_dir.join('SUMMARY.md')
|
|
320
|
+
path.file? ? read_utf8(path) : nil
|
|
121
321
|
end
|
|
122
322
|
end
|
|
123
323
|
|
|
124
324
|
# @return [Woods::DependencyGraph] Graph loaded from disk
|
|
125
325
|
def dependency_graph
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
Woods::DependencyGraph.from_h(data)
|
|
129
|
-
end
|
|
326
|
+
ensure_fresh!
|
|
327
|
+
@dependency_graph ||= Woods::DependencyGraph.from_h(raw_graph_data)
|
|
130
328
|
end
|
|
131
329
|
|
|
132
330
|
# @return [Hash] Parsed graph_analysis.json
|
|
133
331
|
def graph_analysis
|
|
332
|
+
ensure_fresh!
|
|
134
333
|
@graph_analysis ||= parse_json('graph_analysis.json')
|
|
135
334
|
end
|
|
136
335
|
|
|
@@ -139,6 +338,7 @@ module Woods
|
|
|
139
338
|
# @param identifier [String] Unit identifier (e.g. "Post", "Api::V1::HealthController")
|
|
140
339
|
# @return [Hash, nil] Full unit data or nil if not found
|
|
141
340
|
def find_unit(identifier)
|
|
341
|
+
ensure_fresh!
|
|
142
342
|
location = identifier_map[identifier]
|
|
143
343
|
return nil unless location
|
|
144
344
|
|
|
@@ -150,6 +350,7 @@ module Woods
|
|
|
150
350
|
# @param type [String, nil] Singular type name (e.g. "model", "controller")
|
|
151
351
|
# @return [Array<Hash>] Index entries for matching units
|
|
152
352
|
def list_units(type: nil)
|
|
353
|
+
ensure_fresh!
|
|
153
354
|
dirs = if type
|
|
154
355
|
dir = TYPE_TO_DIR[type]
|
|
155
356
|
dir ? [dir] : []
|
|
@@ -164,6 +365,14 @@ module Woods
|
|
|
164
365
|
# Override with WOODS_SEARCH_MAX_SCAN env var.
|
|
165
366
|
DEFAULT_SEARCH_MAX_SCAN = 500
|
|
166
367
|
|
|
368
|
+
# Wall-clock budget for a single match against the user-supplied search
|
|
369
|
+
# pattern (audit P5). The query is compiled as a raw Ruby regex; a
|
|
370
|
+
# pattern with catastrophic backtracking would otherwise stall the
|
|
371
|
+
# dispatch thread indefinitely. Ruby 3.2+ can enforce a per-match limit
|
|
372
|
+
# at the engine level (Regexp.new(timeout:)); on older Rubies the
|
|
373
|
+
# pattern compiles without one and this constant is unused.
|
|
374
|
+
SEARCH_PATTERN_TIMEOUT = 1.0
|
|
375
|
+
|
|
167
376
|
# Search units by case-insensitive pattern.
|
|
168
377
|
#
|
|
169
378
|
# Phase 1: match identifiers from index files (cheap).
|
|
@@ -194,6 +403,20 @@ module Woods
|
|
|
194
403
|
# @return [Hash] { results: Array<Hash>, note: String|nil, partial: Boolean }
|
|
195
404
|
# @raise [ArgumentError] when all of query, exact_prefix, and exact_suffix are blank
|
|
196
405
|
def search(query = nil, types: nil, fields: %w[identifier], limit: 20, exact_prefix: nil, exact_suffix: nil)
|
|
406
|
+
# Pinned, not merely checked-once. This walks the identifier map and
|
|
407
|
+
# then loads units for the hits; each nested `find_unit` re-checks
|
|
408
|
+
# freshness, so a publish landing mid-walk rebuilt the caches while the
|
|
409
|
+
# result list still held identifiers from the previous generation — one
|
|
410
|
+
# response describing two indexes.
|
|
411
|
+
with_pinned_generation do
|
|
412
|
+
search_within_pin(query, types: types, fields: fields, limit: limit,
|
|
413
|
+
exact_prefix: exact_prefix, exact_suffix: exact_suffix)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# @api private
|
|
418
|
+
def search_within_pin(query = nil, types: nil, fields: %w[identifier], limit: 20,
|
|
419
|
+
exact_prefix: nil, exact_suffix: nil)
|
|
197
420
|
prefix = exact_prefix.blank? ? nil : exact_prefix.downcase
|
|
198
421
|
suffix = exact_suffix.blank? ? nil : exact_suffix.downcase
|
|
199
422
|
if query.blank? && !prefix && !suffix
|
|
@@ -212,85 +435,92 @@ module Woods
|
|
|
212
435
|
phase2_scanned = 0
|
|
213
436
|
partial = false
|
|
214
437
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
438
|
+
begin
|
|
439
|
+
dirs = if types
|
|
440
|
+
types.filter_map { |t| TYPE_TO_DIR[t] }
|
|
441
|
+
else
|
|
442
|
+
TYPE_DIRS
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Phase 2 candidates are collected per-dir and then scanned in
|
|
446
|
+
# round-robin across dirs. Exhausting the per-run scan cap linearly
|
|
447
|
+
# down TYPE_DIRS order would starve later types (`concerns` at pos
|
|
448
|
+
# 13, `test_mappings` at pos 31) on any codebase where the earlier
|
|
449
|
+
# dirs together exceed max_scan entries. Interleaving guarantees
|
|
450
|
+
# every type contributes to the scanned set.
|
|
451
|
+
phase2_queues = {}
|
|
452
|
+
|
|
453
|
+
dirs.each do |dir|
|
|
454
|
+
type_name = DIR_TO_TYPE[dir]
|
|
455
|
+
entries = read_index(dir)
|
|
456
|
+
|
|
457
|
+
# Broad-match detection: warn when pattern matches >50% of dir entries
|
|
458
|
+
if entries.size > 1
|
|
459
|
+
matching_count = entries.count do |e|
|
|
460
|
+
identifier_passes_filters?(e['identifier'], pattern, prefix, suffix)
|
|
461
|
+
end
|
|
462
|
+
if matching_count > entries.size / 2.0
|
|
463
|
+
notes << "broad pattern matched #{matching_count}/#{entries.size} entries in #{dir}"
|
|
464
|
+
end
|
|
240
465
|
end
|
|
241
|
-
end
|
|
242
466
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
467
|
+
entries.each do |entry|
|
|
468
|
+
id = entry['identifier']
|
|
469
|
+
next unless identifier_passes_prefix_suffix?(id, prefix, suffix)
|
|
246
470
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
471
|
+
# Phase 1: identifier matching (still in-order per dir)
|
|
472
|
+
if fields.include?('identifier') && pattern.match?(id)
|
|
473
|
+
next if results.size >= limit
|
|
250
474
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
475
|
+
results << { identifier: id, type: type_name, match_field: 'identifier' }
|
|
476
|
+
next
|
|
477
|
+
end
|
|
254
478
|
|
|
255
|
-
|
|
256
|
-
|
|
479
|
+
# Phase 2 is only reached when the caller opted into deeper fields.
|
|
480
|
+
next unless fields.include?('metadata') || fields.include?('source_code')
|
|
257
481
|
|
|
258
|
-
|
|
482
|
+
(phase2_queues[dir] ||= []) << [type_name, id]
|
|
483
|
+
end
|
|
259
484
|
end
|
|
260
|
-
end
|
|
261
485
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
486
|
+
if results.size < limit && phase2_queues.any?
|
|
487
|
+
queues = phase2_queues.values.map(&:dup)
|
|
488
|
+
catch(:phase2_done) do
|
|
489
|
+
loop do
|
|
490
|
+
progressed = false
|
|
491
|
+
queues.each do |queue|
|
|
492
|
+
next if queue.empty?
|
|
269
493
|
|
|
270
|
-
|
|
494
|
+
throw :phase2_done if results.size >= limit
|
|
271
495
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
496
|
+
if phase2_scanned >= max_scan
|
|
497
|
+
partial = true
|
|
498
|
+
throw :phase2_done
|
|
499
|
+
end
|
|
276
500
|
|
|
277
|
-
|
|
278
|
-
|
|
501
|
+
type_name, id = queue.shift
|
|
502
|
+
progressed = true
|
|
279
503
|
|
|
280
|
-
|
|
281
|
-
|
|
504
|
+
unit = find_unit(id)
|
|
505
|
+
next unless unit
|
|
282
506
|
|
|
283
|
-
|
|
507
|
+
phase2_scanned += 1
|
|
284
508
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
509
|
+
if fields.include?('source_code') && unit['source_code'] && pattern.match?(unit['source_code'])
|
|
510
|
+
results << { identifier: id, type: type_name, match_field: 'source_code' }
|
|
511
|
+
elsif fields.include?('metadata') && unit['metadata'] && pattern.match?(unit['metadata'].to_json)
|
|
512
|
+
results << { identifier: id, type: type_name, match_field: 'metadata' }
|
|
513
|
+
end
|
|
289
514
|
end
|
|
515
|
+
break unless progressed
|
|
290
516
|
end
|
|
291
|
-
break unless progressed
|
|
292
517
|
end
|
|
293
518
|
end
|
|
519
|
+
rescue StandardError => e
|
|
520
|
+
raise unless regexp_timeout_error?(e)
|
|
521
|
+
|
|
522
|
+
notes << "search aborted: the pattern exceeded the #{SEARCH_PATTERN_TIMEOUT}s per-match limit"
|
|
523
|
+
partial = true
|
|
294
524
|
end
|
|
295
525
|
|
|
296
526
|
response = { results: results.first(limit) }
|
|
@@ -330,6 +560,11 @@ module Woods
|
|
|
330
560
|
# @param limit [Integer] Maximum results to return
|
|
331
561
|
# @return [Array<Hash>] Matching rails_source unit summaries
|
|
332
562
|
def framework_sources(keyword, limit: 20)
|
|
563
|
+
with_pinned_generation { framework_sources_within_pin(keyword, limit: limit) }
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# @api private
|
|
567
|
+
def framework_sources_within_pin(keyword, limit: 20)
|
|
333
568
|
# Multi-word keywords ("ActiveRecord callbacks") are split on
|
|
334
569
|
# whitespace and ANDed. Single-word queries behave as before.
|
|
335
570
|
tokens = keyword.to_s.strip.split(/\s+/)
|
|
@@ -375,6 +610,11 @@ module Woods
|
|
|
375
610
|
# @param types [Array<String>, nil] Filter to these singular type names
|
|
376
611
|
# @return [Array<Hash>] Units sorted by last_modified descending
|
|
377
612
|
def recent_changes(limit: 10, types: nil)
|
|
613
|
+
with_pinned_generation { recent_changes_within_pin(limit: limit, types: types) }
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# @api private
|
|
617
|
+
def recent_changes_within_pin(limit: 10, types: nil)
|
|
378
618
|
dirs = if types
|
|
379
619
|
types.filter_map { |t| TYPE_TO_DIR[t] }
|
|
380
620
|
else
|
|
@@ -410,24 +650,362 @@ module Woods
|
|
|
410
650
|
end
|
|
411
651
|
|
|
412
652
|
# @return [Hash] Raw dependency graph data from JSON
|
|
653
|
+
#
|
|
654
|
+
# The single parse point for dependency_graph.json per generation
|
|
655
|
+
# (audit P6): {#dependency_graph} builds the typed graph from this
|
|
656
|
+
# same hash (DependencyGraph.from_h does not mutate its input), so a
|
|
657
|
+
# generation that touches both accessors holds one parsed copy.
|
|
658
|
+
#
|
|
659
|
+
# The parsed structure is deep-frozen before the memo publishes it.
|
|
660
|
+
# It is shared state for the whole generation — the typed graph, the
|
|
661
|
+
# memoized edge normalization, traversal, and the server graph tools
|
|
662
|
+
# all read from it — so a caller mutation of a nested edge or node
|
|
663
|
+
# would reach the internal copy and corrupt every later read. Frozen
|
|
664
|
+
# at parse time, not lazily, so even the first accessor's returned
|
|
665
|
+
# value is immutable.
|
|
413
666
|
def raw_graph_data
|
|
414
|
-
|
|
667
|
+
ensure_fresh!
|
|
668
|
+
@raw_graph_data ||= deep_freeze_json(parse_json('dependency_graph.json'))
|
|
415
669
|
end
|
|
416
670
|
|
|
417
671
|
private
|
|
418
672
|
|
|
673
|
+
def wait_for_generation_access(thread)
|
|
674
|
+
@freshness_condition.wait(@freshness_mutex) while generation_access_blocked?(thread)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def generation_access_blocked?(thread)
|
|
678
|
+
return false if @exclusive_owner.equal?(thread) || @pin_owners.key?(thread)
|
|
679
|
+
|
|
680
|
+
!@exclusive_owner.nil? || @exclusive_waiters.positive? || @refresh_waiters.positive?
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# Wait for held pins to drain when the generation moved while they were
|
|
684
|
+
# held, so the pin about to be taken adopts the new generation instead
|
|
685
|
+
# of riding the retired one (MCP-5).
|
|
686
|
+
#
|
|
687
|
+
# Exemptions keep the drain from deadlocking against itself:
|
|
688
|
+
# - a thread that already holds a pin (a NESTED pin) never waits — the
|
|
689
|
+
# drain it would wait for can only happen after that thread returns,
|
|
690
|
+
# and its nested read must stay on its owner's generation anyway;
|
|
691
|
+
# - the reads of an existing pin go through {#ensure_fresh!}, not
|
|
692
|
+
# here, and stay pinned as before.
|
|
693
|
+
#
|
|
694
|
+
# Registering as a waiter blocks other new pins ({#generation_access_blocked?})
|
|
695
|
+
# for the same reason an exclusive reload does: without it, a steady
|
|
696
|
+
# arrival rate keeps the depth off zero forever. The waiter itself holds
|
|
697
|
+
# no pin, so it never blocks the drain it is waiting on.
|
|
698
|
+
#
|
|
699
|
+
# Callers hold `@freshness_mutex`.
|
|
700
|
+
#
|
|
701
|
+
# @param thread [Thread] the thread taking the pin
|
|
702
|
+
# @return [void]
|
|
703
|
+
def drain_for_pending_refresh(thread)
|
|
704
|
+
return unless @auto_refresh
|
|
705
|
+
return if @pin_depth.zero?
|
|
706
|
+
return if @pin_owners.key?(thread)
|
|
707
|
+
return unless generation_moved?
|
|
708
|
+
|
|
709
|
+
@refresh_waiters += 1
|
|
710
|
+
begin
|
|
711
|
+
@freshness_condition.wait(@freshness_mutex) until @pin_depth.zero? && @exclusive_owner.nil?
|
|
712
|
+
ensure
|
|
713
|
+
@refresh_waiters -= 1
|
|
714
|
+
@freshness_condition.broadcast
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Has the published generation moved since the caches were populated?
|
|
719
|
+
# Signature-only (one `File.stat`) — the same cheap probe
|
|
720
|
+
# {#refresh_if_stale} guards its parse with, without consuming it.
|
|
721
|
+
#
|
|
722
|
+
# @return [Boolean]
|
|
723
|
+
def generation_moved?
|
|
724
|
+
signature = generation_signature
|
|
725
|
+
!signature.nil? && signature != @generation_signature
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def release_generation_pin(thread)
|
|
729
|
+
@freshness_mutex.synchronize do
|
|
730
|
+
@pin_depth -= 1
|
|
731
|
+
@pin_owners[thread] -= 1
|
|
732
|
+
@pin_owners.delete(thread) if @pin_owners[thread].zero?
|
|
733
|
+
if @pin_depth.zero?
|
|
734
|
+
release_payload_retention_pin
|
|
735
|
+
@freshness_condition.broadcast
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Keep the immutable payload backing a pinned read on disk across
|
|
741
|
+
# processes. The in-memory pin counters above coordinate threads that
|
|
742
|
+
# share this reader, but a writer in another process cannot see them and
|
|
743
|
+
# retention could otherwise remove the loaded generation before a later
|
|
744
|
+
# artifact in the same request was opened.
|
|
745
|
+
#
|
|
746
|
+
# The reader opens the generation's existing manifest read-only and
|
|
747
|
+
# takes a shared advisory lock. PayloadStore takes an exclusive,
|
|
748
|
+
# non-blocking lock on the same inode before pruning. Re-reading the
|
|
749
|
+
# generation after acquiring closes both races around the lock: if a
|
|
750
|
+
# publish landed first we adopt it and retry, and if pruning landed first
|
|
751
|
+
# the manifest open (or the directory check) fails and we retry from the
|
|
752
|
+
# new published pointer.
|
|
753
|
+
#
|
|
754
|
+
# The retry is bounded (CORE-4). A payload directory that exists without
|
|
755
|
+
# its `manifest.json` — only reachable by tampering, since every
|
|
756
|
+
# published payload carries one — leaves the pointer unchanged, so every
|
|
757
|
+
# iteration would take the identical path with no sleep and no cap: a
|
|
758
|
+
# 100%-CPU hang of the request thread instead of a degraded read. A
|
|
759
|
+
# second consecutive ENOENT against the SAME expected directory means
|
|
760
|
+
# retrying cannot help, so the read proceeds unpinned, exactly as it
|
|
761
|
+
# does for a flat index.
|
|
762
|
+
#
|
|
763
|
+
# @return [void]
|
|
764
|
+
def acquire_payload_retention_pin
|
|
765
|
+
missing_manifest_dir = nil
|
|
766
|
+
saw_missing_manifest = false
|
|
767
|
+
loop do
|
|
768
|
+
expected_dir = current_payload_dir
|
|
769
|
+
return if expected_dir == @index_dir
|
|
770
|
+
|
|
771
|
+
# Intentionally outlives this method; the last overlapping pin
|
|
772
|
+
# closes it in #release_payload_retention_pin.
|
|
773
|
+
file = File.open( # rubocop:disable Style/FileOpen
|
|
774
|
+
expected_dir.join('manifest.json').to_s, File::RDONLY
|
|
775
|
+
)
|
|
776
|
+
file.flock(File::LOCK_SH)
|
|
777
|
+
marker = @generation.current
|
|
778
|
+
resolved = resolve_payload_dir(marker)
|
|
779
|
+
marker_still_matches = marker.number.zero? || (same_generation?(marker) && resolved == expected_dir)
|
|
780
|
+
if marker_still_matches && expected_dir.directory?
|
|
781
|
+
@payload_retention_pin = file
|
|
782
|
+
return
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
file.flock(File::LOCK_UN)
|
|
786
|
+
file.close
|
|
787
|
+
load_generation(marker) unless marker.number.zero?
|
|
788
|
+
rescue Errno::ENOENT
|
|
789
|
+
file&.close unless file&.closed?
|
|
790
|
+
return if saw_missing_manifest && missing_manifest_dir == expected_dir
|
|
791
|
+
|
|
792
|
+
saw_missing_manifest = true
|
|
793
|
+
missing_manifest_dir = expected_dir
|
|
794
|
+
marker = @generation.current
|
|
795
|
+
load_generation(marker) unless marker.number.zero?
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# @return [void]
|
|
800
|
+
def release_payload_retention_pin
|
|
801
|
+
@payload_retention_pin&.flock(File::LOCK_UN)
|
|
802
|
+
@payload_retention_pin&.close
|
|
803
|
+
@payload_retention_pin = nil
|
|
804
|
+
rescue IOError, SystemCallError
|
|
805
|
+
@payload_retention_pin = nil
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def acquire_exclusive_generation(thread)
|
|
809
|
+
@freshness_mutex.synchronize do
|
|
810
|
+
raise ThreadError, 'Cannot start an exclusive reload from a pinned generation' if @pin_owners.key?(thread)
|
|
811
|
+
raise ThreadError, 'This thread already owns an exclusive reload' if @exclusive_owner.equal?(thread)
|
|
812
|
+
|
|
813
|
+
@exclusive_waiters += 1
|
|
814
|
+
begin
|
|
815
|
+
@freshness_condition.wait(@freshness_mutex) until @pin_depth.zero? && @exclusive_owner.nil?
|
|
816
|
+
@exclusive_owner = thread
|
|
817
|
+
ensure
|
|
818
|
+
@exclusive_waiters -= 1
|
|
819
|
+
@freshness_condition.broadcast
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def release_exclusive_generation(thread)
|
|
825
|
+
@freshness_mutex.synchronize do
|
|
826
|
+
@exclusive_owner = nil if @exclusive_owner.equal?(thread)
|
|
827
|
+
@freshness_condition.broadcast
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# The body of {#ensure_fresh!}. Callers must hold `@freshness_mutex`; it
|
|
832
|
+
# is split out so {#with_pinned_generation} can run it inside the same
|
|
833
|
+
# critical section that increments the pin depth.
|
|
834
|
+
#
|
|
835
|
+
# @return [Integer, nil] the generation now loaded, or nil when the
|
|
836
|
+
# caches were already current
|
|
837
|
+
def refresh_if_stale
|
|
838
|
+
return nil if @pin_depth.positive?
|
|
839
|
+
|
|
840
|
+
signature = generation_signature
|
|
841
|
+
return nil if signature.nil? || signature == @generation_signature
|
|
842
|
+
|
|
843
|
+
@generation_signature = signature
|
|
844
|
+
marker = @generation.current
|
|
845
|
+
return nil if marker.number.zero? || same_generation?(marker)
|
|
846
|
+
|
|
847
|
+
load_generation(marker)
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# Adopt one already-read marker as the reader's loaded generation.
|
|
851
|
+
# Callers hold @freshness_mutex.
|
|
852
|
+
#
|
|
853
|
+
# @param marker [Woods::Generation::Marker]
|
|
854
|
+
# @return [void]
|
|
855
|
+
def load_generation(marker)
|
|
856
|
+
reload!
|
|
857
|
+
@payload_dir = resolve_payload_dir(marker)
|
|
858
|
+
@loaded_token = marker.token
|
|
859
|
+
@loaded_generation = marker.number
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# Is there an index here at all?
|
|
863
|
+
#
|
|
864
|
+
# A flat index answers with the manifest at the root. An index that
|
|
865
|
+
# publishes per-generation payloads has no manifest there — every payload
|
|
866
|
+
# artifact lives in the directory the published generation names — so the
|
|
867
|
+
# pointer is followed before concluding the directory is not an index.
|
|
868
|
+
#
|
|
869
|
+
# @return [Boolean]
|
|
870
|
+
def manifest_present?
|
|
871
|
+
return true if @index_dir.join('manifest.json').file?
|
|
872
|
+
|
|
873
|
+
marker = Woods::Generation.new(output_dir: @index_dir).current
|
|
874
|
+
resolve_payload_dir(marker).join('manifest.json').file?
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# The loaded generation's payload directory, without a freshness check.
|
|
878
|
+
#
|
|
879
|
+
# Internal reads call this rather than {#payload_dir}: they are already
|
|
880
|
+
# inside a public read that checked freshness once, and re-checking per
|
|
881
|
+
# artifact would take the freshness mutex and stat generation.json for
|
|
882
|
+
# every unit file a request touches.
|
|
883
|
+
#
|
|
884
|
+
# @return [Pathname]
|
|
885
|
+
def current_payload_dir
|
|
886
|
+
@payload_dir || @index_dir
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Resolve a generation's payload pointer to an on-disk directory.
|
|
890
|
+
#
|
|
891
|
+
# Delegates to {Woods::Generation#payload_dir} so every reader of a
|
|
892
|
+
# payload artifact — the exporters, the validator, the daemon and this
|
|
893
|
+
# reader — follows the pointer by exactly the same rules.
|
|
894
|
+
#
|
|
895
|
+
# @param marker [Woods::Generation::Marker]
|
|
896
|
+
# @return [Pathname]
|
|
897
|
+
def resolve_payload_dir(marker)
|
|
898
|
+
Woods::Generation.new(output_dir: @index_dir).payload_dir(marker)
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# Compare the *token*, not the number.
|
|
902
|
+
#
|
|
903
|
+
# `bump!` is a read-modify-write, so two writers that overlap can both
|
|
904
|
+
# publish the same number — which the design permits, since a manual rake
|
|
905
|
+
# run proceeds after waiting for the lock. A reader already loaded at N+1
|
|
906
|
+
# would then see `published == loaded`, conclude it was current, and hold
|
|
907
|
+
# caches describing the *other* writer's N+1 indefinitely: a stale index
|
|
908
|
+
# that believes it is fresh, which is the one failure generations exist to
|
|
909
|
+
# prevent.
|
|
910
|
+
#
|
|
911
|
+
# The token is a fresh random value per publish, so it distinguishes two
|
|
912
|
+
# collapsed bumps that the counter cannot. Falls back to the number for
|
|
913
|
+
# generation files written before tokens existed.
|
|
914
|
+
def same_generation?(marker)
|
|
915
|
+
return @loaded_generation == marker.number if marker.token.nil?
|
|
916
|
+
|
|
917
|
+
@loaded_token == marker.token
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# Touch every lazy accessor, recording rather than raising per-step
|
|
921
|
+
# failures so a missing optional artefact (graph_analysis.json on an index
|
|
922
|
+
# written before it existed) never blocks the rest.
|
|
923
|
+
#
|
|
924
|
+
# @return [Hash] `{step => true | Exception}`
|
|
925
|
+
def warmup_steps
|
|
926
|
+
steps = {
|
|
927
|
+
manifest: -> { manifest },
|
|
928
|
+
summary: -> { summary },
|
|
929
|
+
dependency_graph: -> { dependency_graph },
|
|
930
|
+
graph_analysis: -> { graph_analysis },
|
|
931
|
+
identifier_map: -> { identifier_map }
|
|
932
|
+
}
|
|
933
|
+
steps.each_with_object({}) do |(step, runner), result|
|
|
934
|
+
runner.call
|
|
935
|
+
result[step] = true
|
|
936
|
+
rescue StandardError => e
|
|
937
|
+
result[step] = e
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
|
|
419
941
|
# Compile a case-insensitive regex from a query string.
|
|
420
942
|
#
|
|
421
943
|
# Treats the query as a raw Ruby regex pattern. Falls back to an escaped
|
|
422
944
|
# literal match (with a :note field added by callers) when the pattern is
|
|
423
945
|
# invalid.
|
|
424
946
|
#
|
|
947
|
+
# The compiled pattern carries the {SEARCH_PATTERN_TIMEOUT} per-match
|
|
948
|
+
# wall-clock limit on Ruby 3.2+: a pattern with catastrophic
|
|
949
|
+
# backtracking raises Regexp::TimeoutError on its first overrun instead
|
|
950
|
+
# of stalling the dispatch thread indefinitely, which {#search_within_pin}
|
|
951
|
+
# converts into a partial response.
|
|
952
|
+
#
|
|
425
953
|
# @param query [String] Raw regex pattern
|
|
426
954
|
# @return [Regexp] Compiled case-insensitive pattern
|
|
427
955
|
def compile_search_pattern(query)
|
|
428
|
-
|
|
956
|
+
compile_case_insensitive_pattern(query)
|
|
429
957
|
rescue RegexpError
|
|
430
|
-
|
|
958
|
+
compile_case_insensitive_pattern(Regexp.escape(query))
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# @param pattern [String] Regex source (already escaped when falling back)
|
|
962
|
+
# @return [Regexp]
|
|
963
|
+
def compile_case_insensitive_pattern(pattern)
|
|
964
|
+
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2')
|
|
965
|
+
Regexp.new(pattern, Regexp::IGNORECASE, timeout: SEARCH_PATTERN_TIMEOUT)
|
|
966
|
+
else
|
|
967
|
+
Regexp.new(pattern, Regexp::IGNORECASE)
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# Is this the engine-level per-match timeout, Regexp::TimeoutError?
|
|
972
|
+
#
|
|
973
|
+
# The constant does not exist before Ruby 3.2, and Ruby resolves
|
|
974
|
+
# rescue-class expressions lazily, when an exception occurs — naming
|
|
975
|
+
# the constant in a rescue clause turned any *other* in-block failure
|
|
976
|
+
# into `NameError: uninitialized constant Regexp::TimeoutError` on
|
|
977
|
+
# Ruby 3.0/3.1, masking the original error. Rescue StandardError and
|
|
978
|
+
# recognize the timeout through a defined? check instead; everything
|
|
979
|
+
# else is re-raised unchanged.
|
|
980
|
+
#
|
|
981
|
+
# @param error [StandardError]
|
|
982
|
+
# @return [Boolean]
|
|
983
|
+
def regexp_timeout_error?(error)
|
|
984
|
+
defined?(Regexp::TimeoutError) && error.is_a?(Regexp::TimeoutError)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# A cheap stand-in for "has the generation file been rewritten?", so the
|
|
988
|
+
# common case costs one `File.stat` of a ~100-byte file instead of a parse.
|
|
989
|
+
#
|
|
990
|
+
# The inode is load-bearing, not belt-and-braces. `[mtime, size]` alone
|
|
991
|
+
# misses a bump whenever two writes land in the same mtime tick with the
|
|
992
|
+
# same payload length — and equal length is the daemon's *steady state*:
|
|
993
|
+
# reason `"incremental"` every cycle, fixed-width number and timestamp.
|
|
994
|
+
# Coarse mtime granularity is not exotic either; it includes the
|
|
995
|
+
# volume-mounted Docker deployment the Index Server is documented for. The
|
|
996
|
+
# reader would then serve a stale index indefinitely, which is the exact
|
|
997
|
+
# silent staleness generations exist to end.
|
|
998
|
+
#
|
|
999
|
+
# {AtomicFile} renames a fresh Tempfile over the target on every write, so
|
|
1000
|
+
# a new inode is guaranteed per publish and this costs nothing extra.
|
|
1001
|
+
#
|
|
1002
|
+
# @return [Array, nil] opaque signature, or nil when there is no
|
|
1003
|
+
# generation file
|
|
1004
|
+
def generation_signature
|
|
1005
|
+
stat = File.stat(@generation.path)
|
|
1006
|
+
[stat.mtime.to_f, stat.size, stat.ino]
|
|
1007
|
+
rescue SystemCallError
|
|
1008
|
+
nil
|
|
431
1009
|
end
|
|
432
1010
|
|
|
433
1011
|
# Case-insensitive literal prefix/suffix check on an identifier.
|
|
@@ -452,8 +1030,65 @@ module Woods
|
|
|
452
1030
|
|
|
453
1031
|
# Memoized normalized edges — converts bare strings (old format) to hashes once.
|
|
454
1032
|
# Cleared by reload! alongside raw_graph_data.
|
|
1033
|
+
#
|
|
1034
|
+
# `edges` holds one type's edges per identifier. Where an identifier
|
|
1035
|
+
# names units of several types the rest live in `variants`, and a
|
|
1036
|
+
# traversal that read only the primary would report a unit as having no
|
|
1037
|
+
# dependencies at all. They are unioned here so traversal follows the
|
|
1038
|
+
# identifier's whole out-edge set.
|
|
455
1039
|
def normalized_graph_edges
|
|
456
|
-
@normalized_graph_edges ||=
|
|
1040
|
+
@normalized_graph_edges ||= begin
|
|
1041
|
+
edges = normalize_all_edges(raw_graph_data['edges'] || {})
|
|
1042
|
+
variant_records.each do |record|
|
|
1043
|
+
identifier = record['identifier']
|
|
1044
|
+
next unless identifier
|
|
1045
|
+
|
|
1046
|
+
extra = normalize_all_edges(identifier => Array(record['edges']))
|
|
1047
|
+
edges[identifier] = ((edges[identifier] || []) + extra[identifier]).uniq
|
|
1048
|
+
end
|
|
1049
|
+
edges
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
# Every unit type registered under each identifier, sorted.
|
|
1054
|
+
#
|
|
1055
|
+
# One entry for all but the handful of identifiers a codebase reuses
|
|
1056
|
+
# across types; those get one per coexisting unit.
|
|
1057
|
+
#
|
|
1058
|
+
# @return [Hash{String => Array<String>}]
|
|
1059
|
+
def graph_node_types
|
|
1060
|
+
@graph_node_types ||= begin
|
|
1061
|
+
types = (raw_graph_data['nodes'] || {}).transform_values { |node| [node['type']].compact }
|
|
1062
|
+
variant_records.each do |record|
|
|
1063
|
+
identifier = record['identifier']
|
|
1064
|
+
next unless identifier && record['type']
|
|
1065
|
+
|
|
1066
|
+
(types[identifier] ||= []) << record['type']
|
|
1067
|
+
end
|
|
1068
|
+
types.transform_values { |list| list.uniq.sort }
|
|
1069
|
+
end
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# Does the graph span more than one database?
|
|
1073
|
+
#
|
|
1074
|
+
# Nodes carry `database` only from Rails 6.1 on, and only for models, so
|
|
1075
|
+
# an app with one database (or none recorded) answers false and the
|
|
1076
|
+
# traversal shape is unchanged for it.
|
|
1077
|
+
#
|
|
1078
|
+
# @return [Boolean]
|
|
1079
|
+
def multi_database_graph?
|
|
1080
|
+
return @multi_database_graph if defined?(@multi_database_graph)
|
|
1081
|
+
|
|
1082
|
+
databases = (raw_graph_data['nodes'] || {}).each_value.filter_map { |node| node['database'] }
|
|
1083
|
+
@multi_database_graph = databases.uniq.size > 1
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
# @return [Array<Hash>] the graph's `variants` section, empty when the
|
|
1087
|
+
# graph has no identifier shared across types (and for every graph
|
|
1088
|
+
# written before the section existed)
|
|
1089
|
+
def variant_records
|
|
1090
|
+
records = raw_graph_data['variants']
|
|
1091
|
+
records.is_a?(Array) ? records.grep(Hash) : []
|
|
457
1092
|
end
|
|
458
1093
|
|
|
459
1094
|
# Build identifier → { type_dir, filename } map from all _index.json files.
|
|
@@ -480,42 +1115,118 @@ module Woods
|
|
|
480
1115
|
def read_index(dir)
|
|
481
1116
|
@index_cache ||= {}
|
|
482
1117
|
@index_cache[dir] ||= begin
|
|
483
|
-
path =
|
|
484
|
-
path.file? ? JSON.parse(path
|
|
1118
|
+
path = current_payload_dir.join(dir, '_index.json')
|
|
1119
|
+
path.file? ? JSON.parse(read_utf8(path)) : []
|
|
485
1120
|
end
|
|
486
1121
|
end
|
|
487
1122
|
|
|
488
1123
|
# Load a unit JSON file with LRU cache eviction.
|
|
1124
|
+
#
|
|
1125
|
+
# The bookkeeping runs under {@cache_mutex}; the file read deliberately
|
|
1126
|
+
# does not, so concurrent readers still parse different units in
|
|
1127
|
+
# parallel. `woods-mcp-http` executes tool handlers on its Rack server's
|
|
1128
|
+
# request threads, and unsynchronized bookkeeping drifts: two threads
|
|
1129
|
+
# can both observe `size >= MAX_UNIT_CACHE` and both evict, or both push
|
|
1130
|
+
# the same key so the order array accumulates a duplicate. A duplicate
|
|
1131
|
+
# for a key never read again is never cleaned up (only a cache *hit*
|
|
1132
|
+
# dedupes it), so `shift` starts evicting keys that are no longer in the
|
|
1133
|
+
# cache and the cache creeps past its cap — a slow leak rather than a
|
|
1134
|
+
# wrong answer, but a leak.
|
|
1135
|
+
#
|
|
1136
|
+
# Values are unaffected either way: both threads parse the same file.
|
|
489
1137
|
def load_unit(type_dir, filename)
|
|
490
1138
|
cache_key = "#{type_dir}/#{filename}"
|
|
1139
|
+
path = current_payload_dir.join(type_dir, filename)
|
|
1140
|
+
open_unit(path.to_s) do |file|
|
|
1141
|
+
signature = unit_file_signature(file.stat)
|
|
1142
|
+
cached = @cache_mutex.synchronize do
|
|
1143
|
+
if @unit_cache.key?(cache_key) && @unit_cache_signatures[cache_key] == signature
|
|
1144
|
+
# Move to end (most recently used)
|
|
1145
|
+
@unit_cache_order.delete(cache_key)
|
|
1146
|
+
@unit_cache_order.push(cache_key)
|
|
1147
|
+
@unit_cache[cache_key]
|
|
1148
|
+
elsif @unit_cache.key?(cache_key)
|
|
1149
|
+
@unit_cache.delete(cache_key)
|
|
1150
|
+
@unit_cache_signatures.delete(cache_key)
|
|
1151
|
+
@unit_cache_order.delete(cache_key)
|
|
1152
|
+
nil
|
|
1153
|
+
end
|
|
1154
|
+
end
|
|
1155
|
+
return cached if cached
|
|
491
1156
|
|
|
492
|
-
|
|
493
|
-
# Move to end (most recently used)
|
|
494
|
-
@unit_cache_order.delete(cache_key)
|
|
495
|
-
@unit_cache_order.push(cache_key)
|
|
496
|
-
return @unit_cache[cache_key]
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
path = @index_dir.join(type_dir, filename)
|
|
500
|
-
return nil unless path.file?
|
|
1157
|
+
data = JSON.parse(file.read)
|
|
501
1158
|
|
|
502
|
-
|
|
1159
|
+
@cache_mutex.synchronize do
|
|
1160
|
+
# Evict oldest if at capacity
|
|
1161
|
+
if @unit_cache.size >= MAX_UNIT_CACHE && !@unit_cache.key?(cache_key)
|
|
1162
|
+
oldest = @unit_cache_order.shift
|
|
1163
|
+
@unit_cache.delete(oldest)
|
|
1164
|
+
@unit_cache_signatures.delete(oldest)
|
|
1165
|
+
end
|
|
503
1166
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
1167
|
+
@unit_cache[cache_key] = data
|
|
1168
|
+
@unit_cache_signatures[cache_key] = signature
|
|
1169
|
+
@unit_cache_order.delete(cache_key)
|
|
1170
|
+
@unit_cache_order.push(cache_key)
|
|
1171
|
+
end
|
|
1172
|
+
data
|
|
508
1173
|
end
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
def unit_file_signature(stat)
|
|
1177
|
+
[stat.dev, stat.ino, stat.size, stat.mtime.to_r, stat.ctime.to_r]
|
|
1178
|
+
end
|
|
509
1179
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
data
|
|
1180
|
+
def open_unit(path, &block)
|
|
1181
|
+
File.open(path, 'rb', &block)
|
|
513
1182
|
end
|
|
514
1183
|
|
|
515
|
-
#
|
|
1184
|
+
# Read an index artifact as UTF-8, whatever the host locale.
|
|
1185
|
+
#
|
|
1186
|
+
# Bare Pathname#read tags the bytes with Encoding.default_external. On
|
|
1187
|
+
# a C/US-ASCII host that tag is US-ASCII, so a single non-ASCII byte in
|
|
1188
|
+
# an artifact (a branch like "feature/café" in manifest.json, a unit
|
|
1189
|
+
# identifier, prose in SUMMARY.md) makes JSON.parse raise
|
|
1190
|
+
# Encoding::InvalidByteSequenceError and leaves bare string reads with
|
|
1191
|
+
# an invalid encoding — search, lookup, dependencies, dependents,
|
|
1192
|
+
# framework, and recent_changes then surface as misleading
|
|
1193
|
+
# corrupt_artifact results. Reading binary (the mode {#load_unit} has
|
|
1194
|
+
# always used) and forcing UTF-8 keeps the bytes byte-identical while
|
|
1195
|
+
# giving them the encoding extraction writes artifacts in.
|
|
1196
|
+
#
|
|
1197
|
+
# @param path [Pathname] Artifact path inside the loaded generation
|
|
1198
|
+
# @return [String] Contents, tagged UTF-8
|
|
1199
|
+
def read_utf8(path)
|
|
1200
|
+
path.binread.force_encoding(Encoding::UTF_8)
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
# Parse a JSON payload file (manifest, graph, analysis) resolved through
|
|
1204
|
+
# the loaded generation's {#payload_dir}.
|
|
516
1205
|
def parse_json(filename)
|
|
517
|
-
path =
|
|
518
|
-
JSON.parse(path
|
|
1206
|
+
path = current_payload_dir.join(filename)
|
|
1207
|
+
JSON.parse(read_utf8(path))
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
# Deep-freeze a parsed JSON structure (hashes, arrays, and the strings
|
|
1211
|
+
# and primitives inside them) so a shared parse result cannot be
|
|
1212
|
+
# mutated through any reference.
|
|
1213
|
+
#
|
|
1214
|
+
# JSON.parse(..., freeze: true) would do this natively, but that
|
|
1215
|
+
# keyword needs json >= 2.7.0 while the supported floor (Ruby 3.0)
|
|
1216
|
+
# bundles json 2.5 — a host app on the floor would get ArgumentError
|
|
1217
|
+
# from this path. A small recursive walk is version-independent.
|
|
1218
|
+
#
|
|
1219
|
+
# @param value [Object]
|
|
1220
|
+
# @return [Object] the same structure, frozen
|
|
1221
|
+
def deep_freeze_json(value)
|
|
1222
|
+
case value
|
|
1223
|
+
when Hash then value.each do |key, item|
|
|
1224
|
+
deep_freeze_json(key)
|
|
1225
|
+
deep_freeze_json(item)
|
|
1226
|
+
end.freeze
|
|
1227
|
+
when Array then value.each { |item| deep_freeze_json(item) }.freeze
|
|
1228
|
+
else value.freeze
|
|
1229
|
+
end
|
|
519
1230
|
end
|
|
520
1231
|
|
|
521
1232
|
# BFS traversal in either direction.
|
|
@@ -554,12 +1265,12 @@ module Woods
|
|
|
554
1265
|
resolve_reverse_neighbors(graph_data, normalized_edges, current, via_set)
|
|
555
1266
|
end
|
|
556
1267
|
|
|
557
|
-
# Filter by node type if requested
|
|
1268
|
+
# Filter by node type if requested. An identifier naming units of
|
|
1269
|
+
# several types matches when any of them does — excluding it because
|
|
1270
|
+
# the type that happens to sort first is not the requested one would
|
|
1271
|
+
# hide a unit the filter asked for.
|
|
558
1272
|
filtered = if type_set
|
|
559
|
-
neighbors.select
|
|
560
|
-
node_meta = nodes_data[n]
|
|
561
|
-
node_meta && type_set.include?(node_meta['type'])
|
|
562
|
-
end
|
|
1273
|
+
neighbors.select { |n| graph_node_types[n]&.any? { |t| type_set.include?(t) } }
|
|
563
1274
|
else
|
|
564
1275
|
neighbors
|
|
565
1276
|
end
|
|
@@ -569,11 +1280,19 @@ module Woods
|
|
|
569
1280
|
# node's deps list already shows this node as a child.
|
|
570
1281
|
will_expand = current_depth < depth
|
|
571
1282
|
node_meta = nodes_data[current]
|
|
572
|
-
|
|
1283
|
+
entry = {
|
|
573
1284
|
type: node_meta&.dig('type'),
|
|
574
1285
|
depth: current_depth,
|
|
575
1286
|
deps: will_expand ? filtered : []
|
|
576
1287
|
}
|
|
1288
|
+
# Only when the identifier is genuinely ambiguous, so the shape is
|
|
1289
|
+
# unchanged for every node in an index with no shared identifiers.
|
|
1290
|
+
node_types = graph_node_types[current] || []
|
|
1291
|
+
entry[:types] = node_types if node_types.size > 1
|
|
1292
|
+
# Likewise: a single-database app learns nothing from a column that
|
|
1293
|
+
# answers the same thing on every row (B-183).
|
|
1294
|
+
entry[:database] = node_meta&.dig('database') if multi_database_graph?
|
|
1295
|
+
result_nodes[current] = entry
|
|
577
1296
|
|
|
578
1297
|
next unless will_expand
|
|
579
1298
|
|