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
data/lib/tasks/woods.rake
CHANGED
|
@@ -15,19 +15,380 @@
|
|
|
15
15
|
# bundle exec rake woods:self_analyze # Analyze gem's own source
|
|
16
16
|
# bundle exec rake woods:flow[EntryPoint] # Generate execution flow
|
|
17
17
|
|
|
18
|
+
# Reading Woods JSON artifacts with a bare File.read tags the result with the
|
|
19
|
+
# process's default external encoding — US-ASCII under LANG=C, the common
|
|
20
|
+
# case in CI and Docker — so a non-ASCII byte (e.g. a git branch name in the
|
|
21
|
+
# manifest) raises Encoding::InvalidByteSequenceError. AtomicFile.write is
|
|
22
|
+
# binmode, so the read side must go through AtomicFile.read to match.
|
|
23
|
+
require 'woods/atomic_file'
|
|
24
|
+
require 'woods/generation'
|
|
25
|
+
require 'woods/git_command'
|
|
26
|
+
|
|
18
27
|
namespace :woods do
|
|
28
|
+
# ── Multi-instance helpers (#164 phase 4) ────────────────────────────────
|
|
29
|
+
#
|
|
30
|
+
# Worktrees are disjoint by construction (each has its own Rails.root and
|
|
31
|
+
# its own output dir), so these only ever mediate writers against the *same*
|
|
32
|
+
# index: a manual rake run, a hook-triggered sync, and the watch daemon.
|
|
33
|
+
|
|
34
|
+
# Run a block holding the extraction lock, waiting for another writer to
|
|
35
|
+
# finish.
|
|
36
|
+
#
|
|
37
|
+
# This used to proceed *without* the lock after 30s, on the reasoning that a
|
|
38
|
+
# daemon cycle is milliseconds so a longer wait meant something unusual. That
|
|
39
|
+
# reasoning was wrong in the case that matters: a cycle includes a
|
|
40
|
+
# storm-triggered `extract_all`, which on a large host app runs for minutes.
|
|
41
|
+
# Proceeding then means two writers load `dependency_graph.json`, mutate
|
|
42
|
+
# divergent copies, and the last one silently discards the other's work — then
|
|
43
|
+
# bumps the generation, marking the clobbered graph fresh. Per-file atomic
|
|
44
|
+
# writes do not help, because the file *set* is not atomic.
|
|
45
|
+
#
|
|
46
|
+
# So the wait is now generous and the failure explicit. `WOODS_LOCK_WAIT`
|
|
47
|
+
# overrides it; exceeding it exits non-zero rather than corrupting the index,
|
|
48
|
+
# which is the outcome a CI job or a developer can actually act on.
|
|
49
|
+
# Fail the task when a run reported per-item errors (INF-4 / EXP-4).
|
|
50
|
+
#
|
|
51
|
+
# A printed-but-green run is invisible in the CI/cron pipelines these tasks
|
|
52
|
+
# target: a revoked API key, a full vector store, or a 401 on every page
|
|
53
|
+
# prints `Errors: N` and the job stays green while the index or the export
|
|
54
|
+
# target rots. `woods:unblocked_sync` and `woods:obsidian` already exit 1 on
|
|
55
|
+
# the same shape, and #270 fixed it for the extraction family; the embedding
|
|
56
|
+
# and Notion tasks carry identical CI exposure.
|
|
57
|
+
#
|
|
58
|
+
# Partial progress is deliberately *not* a carve-out here. The unblocked
|
|
59
|
+
# exporter has one (budget exhaustion with progress is the expected
|
|
60
|
+
# cold-start shape and converges on the next run); an embedding or Notion
|
|
61
|
+
# error is a genuine failure per unit and does not self-heal, so any
|
|
62
|
+
# non-zero count fails.
|
|
63
|
+
#
|
|
64
|
+
# @param errors [Integer, Array, nil] the run's error count or error list
|
|
65
|
+
# @return [void]
|
|
66
|
+
def woods_exit_on_reported_errors(errors)
|
|
67
|
+
# The embed indexer reports a count; the exporters report a list.
|
|
68
|
+
# (`Integer#size` exists and answers 8, so this must branch on the type.)
|
|
69
|
+
count = errors.is_a?(Numeric) ? errors.to_i : Array(errors).size
|
|
70
|
+
return if count.zero?
|
|
71
|
+
|
|
72
|
+
puts
|
|
73
|
+
puts 'Run completed with errors — failing so CI surfaces it.'
|
|
74
|
+
exit 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def woods_with_extraction_lock(output_dir, wait: nil, &block)
|
|
78
|
+
# Requires first. The default wait reads a constant from the daemon, so
|
|
79
|
+
# resolving it above these lines NameError'd every write task — the same
|
|
80
|
+
# load-order bug as the missing require in `woods:watch`, reintroduced one
|
|
81
|
+
# method over by the fix for it.
|
|
82
|
+
require 'woods/coordination/pipeline_lock'
|
|
83
|
+
require 'woods/coordination/lock_heartbeat'
|
|
84
|
+
require 'woods/watch/daemon'
|
|
85
|
+
|
|
86
|
+
wait ||= Float(ENV.fetch('WOODS_LOCK_WAIT', Woods::Watch::Daemon::LOCK_STALE_TIMEOUT))
|
|
87
|
+
|
|
88
|
+
lock = Woods::Coordination::PipelineLock.new(
|
|
89
|
+
lock_dir: output_dir.to_s,
|
|
90
|
+
name: Woods::Watch::Daemon::LOCK_NAME,
|
|
91
|
+
stale_timeout: Woods::Watch::Daemon::LOCK_STALE_TIMEOUT
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
woods_abort_on_lock_timeout(wait) unless woods_acquire_within(lock, wait)
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
Woods::Coordination::LockHeartbeat.run(lock, &block)
|
|
98
|
+
ensure
|
|
99
|
+
lock.release
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Poll for the lock until `wait` seconds have elapsed.
|
|
104
|
+
#
|
|
105
|
+
# Monotonic, so a clock adjustment mid-wait cannot cut the window short or
|
|
106
|
+
# extend it indefinitely.
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean] whether the lock was acquired
|
|
109
|
+
def woods_acquire_within(lock, wait)
|
|
110
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
|
|
111
|
+
acquired = lock.acquire
|
|
112
|
+
until acquired || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
113
|
+
sleep 0.25
|
|
114
|
+
acquired = lock.acquire
|
|
115
|
+
end
|
|
116
|
+
acquired
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def woods_abort_on_lock_timeout(wait)
|
|
120
|
+
warn "ERROR: another writer has held the extraction lock for #{wait.round}s."
|
|
121
|
+
warn 'Refusing to write: two concurrent writers rewrite the dependency graph from divergent'
|
|
122
|
+
warn 'copies, and the loser\'s work is discarded under a generation that says "fresh".'
|
|
123
|
+
warn 'Set WOODS_LOCK_WAIT to wait longer, or stop the other writer.'
|
|
124
|
+
exit 1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Is a watch daemon already maintaining this index?
|
|
128
|
+
#
|
|
129
|
+
# A session-start or worktree hook that fires `woods:incremental` on a tree
|
|
130
|
+
# a daemon is already watching is pure duplicated work — and it contends for
|
|
131
|
+
# the lock the daemon needs. Set WOODS_IGNORE_WATCH=1 to run anyway.
|
|
132
|
+
#
|
|
133
|
+
# Liveness alone is not coverage, and the difference matters. A `:running`
|
|
134
|
+
# daemon reconciles everything modified since the index's last successful
|
|
135
|
+
# publish when it starts (Daemon#catch_up), so changes that predate it are
|
|
136
|
+
# covered whether or not it witnessed them — that is what makes standing down
|
|
137
|
+
# safe. A `:degraded` daemon is alive but *cannot* currently update, so
|
|
138
|
+
# standing down for it would report success over work nothing is doing.
|
|
139
|
+
#
|
|
140
|
+
# @return [Symbol] `:none`, `:running`, or `:degraded`
|
|
141
|
+
def woods_daemon_coverage(output_dir)
|
|
142
|
+
return :none if ENV['WOODS_IGNORE_WATCH'] == '1'
|
|
143
|
+
|
|
144
|
+
require 'woods/watch/status'
|
|
145
|
+
status = Woods::Watch::Status.new(output_dir: output_dir)
|
|
146
|
+
return :none unless status.alive?
|
|
147
|
+
|
|
148
|
+
status.read['state'] == 'degraded' ? :degraded : :running
|
|
149
|
+
rescue StandardError
|
|
150
|
+
:none
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Delete the index without yanking it out from under another writer (#170).
|
|
154
|
+
#
|
|
155
|
+
# The old `woods:clean` body was a bare rm_rf. Run mid-daemon-cycle it
|
|
156
|
+
# deleted the daemon's own extraction lock, so the "writers serialize on
|
|
157
|
+
# PipelineLock" invariant evaporated at the exact moment a writer was
|
|
158
|
+
# mid-graph-rewrite. Now: refuse while a daemon is alive (same stand-down
|
|
159
|
+
# check as `woods:incremental`; WOODS_IGNORE_WATCH=1 overrides), then take
|
|
160
|
+
# the extraction lock like every other writer, delete everything EXCEPT the
|
|
161
|
+
# lock file itself, and let release remove the lock last.
|
|
162
|
+
#
|
|
163
|
+
# The directory the published generation's payload lives in.
|
|
164
|
+
#
|
|
165
|
+
# Read-side tasks resolve through this rather than the output root: an index
|
|
166
|
+
# publishing per-generation payloads keeps only `generation.json`, `dumps/`,
|
|
167
|
+
# `payloads/` and the lock files at the root. A flat index resolves to the
|
|
168
|
+
# root unchanged.
|
|
169
|
+
#
|
|
170
|
+
# @param output_dir [Pathname, String] index directory
|
|
171
|
+
# @return [Pathname]
|
|
172
|
+
def woods_payload_dir(output_dir)
|
|
173
|
+
Woods::Generation.new(output_dir: output_dir).payload_dir
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @param output_dir [Pathname, String] index directory
|
|
177
|
+
# @param wait [Numeric, nil] seconds to wait for the lock (default: the
|
|
178
|
+
# shared writer wait; injectable so a spec need not sit out the window)
|
|
179
|
+
# @return [Symbol] `:cleaned`, or `:refused` when a live daemon is
|
|
180
|
+
# maintaining this index
|
|
181
|
+
def woods_clean_index(output_dir, wait: nil)
|
|
182
|
+
require 'woods/watch/daemon'
|
|
183
|
+
|
|
184
|
+
output_dir = Pathname.new(output_dir)
|
|
185
|
+
|
|
186
|
+
unless woods_daemon_coverage(output_dir) == :none
|
|
187
|
+
warn 'ERROR: a watch daemon is maintaining this index — refusing to delete it out from under it.'
|
|
188
|
+
warn 'Stop the daemon first, or set WOODS_IGNORE_WATCH=1 to clean anyway.'
|
|
189
|
+
return :refused
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
lock_name = Woods::Watch::Daemon::LOCK_NAME
|
|
193
|
+
woods_with_extraction_lock(output_dir, wait: wait) do
|
|
194
|
+
woods_sweep_index_dir(output_dir, lock_name)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The lock was released (and its file removed) above; drop the guard so
|
|
198
|
+
# the directory can empty, then remove the now-empty directory. Another
|
|
199
|
+
# writer may legitimately have recreated content between release and here
|
|
200
|
+
# — a non-empty directory is left alone rather than forced.
|
|
201
|
+
woods_remove_if_empty(output_dir, lock_name)
|
|
202
|
+
:cleaned
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Delete every index artifact except the lock file and its transaction
|
|
206
|
+
# guard. The guard lives inside the lock directory and a contender may hold
|
|
207
|
+
# its flock right now — deleting it mid-sweep would split the flock across
|
|
208
|
+
# two inodes and defeat the mutual exclusion it provides.
|
|
209
|
+
def woods_sweep_index_dir(output_dir, lock_name)
|
|
210
|
+
preserved = [
|
|
211
|
+
output_dir.join("#{lock_name}.lock"),
|
|
212
|
+
output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name))
|
|
213
|
+
]
|
|
214
|
+
output_dir.children.each do |entry|
|
|
215
|
+
FileUtils.rm_rf(entry) unless preserved.include?(entry)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def woods_remove_if_empty(output_dir, lock_name)
|
|
220
|
+
FileUtils.rm_f(output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name)))
|
|
221
|
+
Dir.rmdir(output_dir)
|
|
222
|
+
rescue SystemCallError
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The root containing the Rakefile that loaded this task file.
|
|
227
|
+
#
|
|
228
|
+
# `woods:watch_status` intentionally avoids Rails boot, so it cannot ask
|
|
229
|
+
# `Rails.root` for the conventional output path. `Dir.pwd` is not a stable
|
|
230
|
+
# substitute: `rake -f /app/Rakefile` and worktree launchers may invoke the
|
|
231
|
+
# task from somewhere else. Rake retains the selected Rakefile even when it
|
|
232
|
+
# does not chdir, which gives the same application root without loading the
|
|
233
|
+
# environment.
|
|
234
|
+
#
|
|
235
|
+
# @return [String] absolute directory containing the active Rakefile
|
|
236
|
+
def woods_task_root
|
|
237
|
+
rakefile = Rake.application.rakefile
|
|
238
|
+
return Rake.application.original_dir if rakefile.nil? || rakefile.empty?
|
|
239
|
+
|
|
240
|
+
# Rake may keep this relative (`Rakefile`) after searching upward from a
|
|
241
|
+
# nested invocation. At task execution its cwd is the directory it loaded
|
|
242
|
+
# that relative file from. An explicit absolute `-f` is already complete.
|
|
243
|
+
File.dirname(File.expand_path(rakefile))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Changed paths across a git range, for `woods:incremental`'s CI branches.
|
|
247
|
+
#
|
|
248
|
+
# `git diff --name-only` split on lines corrupted three things at once: a
|
|
249
|
+
# path containing a newline split into two entries; a non-ASCII path came
|
|
250
|
+
# back octal-escaped inside quotes under git's default `core.quotePath`,
|
|
251
|
+
# which the dispatcher then can't match to any rule; and a rename reported
|
|
252
|
+
# only the new path, so the old path's unit was never pruned. `-z` +
|
|
253
|
+
# `--name-status` + `--no-renames` fixes all three: NUL-delimited records,
|
|
254
|
+
# `core.quotePath=false` unescaped, and a rename decomposed by git itself
|
|
255
|
+
# into a separate `A <new>` and `D <old>` record rather than one `R` record
|
|
256
|
+
# naming both.
|
|
257
|
+
#
|
|
258
|
+
# The diff is rooted at the extracted application (`git -C Rails.root`),
|
|
259
|
+
# consistent with {Woods::GitProvenance} (#262): extraction launched from
|
|
260
|
+
# another checkout must not diff that checkout's history.
|
|
261
|
+
#
|
|
262
|
+
# The child status is carried out, not discarded (M1): `Open3.capture2`
|
|
263
|
+
# turned an unresolvable range — a GitLab zero-SHA, an unfetched GitHub base
|
|
264
|
+
# ref, garbage — into an empty change set the caller could not tell apart
|
|
265
|
+
# from "nothing changed", and `woods:incremental` exited 0 over a sync that
|
|
266
|
+
# never ran.
|
|
267
|
+
#
|
|
268
|
+
# @param range [String] a git diff range/revision expression
|
|
269
|
+
# @param root [Pathname, String] repository root the diff runs against
|
|
270
|
+
# @return [Array(Array<String>, String, nil)] the changed paths (both halves
|
|
271
|
+
# of any rename included), or nil paths plus a human-readable failure when
|
|
272
|
+
# the range could not be resolved
|
|
273
|
+
def woods_changed_paths_for_range(range, root: Rails.root)
|
|
274
|
+
require 'open3'
|
|
275
|
+
output, error, status = Open3.capture3(
|
|
276
|
+
*Woods::GitCommand.argv(
|
|
277
|
+
root, '-c', 'core.quotePath=false',
|
|
278
|
+
'diff', '--name-status', '-z', '--no-renames', range
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
return [woods_parse_git_diff_name_status(output), nil] if status.success?
|
|
282
|
+
|
|
283
|
+
[nil, "#{error.strip} (git exited #{status.exitstatus})"]
|
|
284
|
+
rescue SystemCallError => e
|
|
285
|
+
# No git binary at all (a slim container image, git removed after
|
|
286
|
+
# checkout). Errno::ENOENT out of `Open3.capture3` used to kill the task
|
|
287
|
+
# with a backtrace: non-zero, so safe, but it bypassed the decision below
|
|
288
|
+
# — the `:running`-daemon stand-down branch was unreachable, and the
|
|
289
|
+
# operator got a stack trace instead of the remediation text. An absent
|
|
290
|
+
# binary is an unresolvable range like any other (INF-12).
|
|
291
|
+
[nil, "git unavailable: #{e.message}"]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The changed-path set `woods:incremental` will process, or a stand-down.
|
|
295
|
+
#
|
|
296
|
+
# An explicit `CHANGED_FILES` list bypasses git entirely. Otherwise the
|
|
297
|
+
# range comes from the CI environment (GitLab's before-SHA, GitHub's base
|
|
298
|
+
# ref) or defaults to the last commit.
|
|
299
|
+
#
|
|
300
|
+
# A failed range is a decision, not a skip (M1): the changed-file set is
|
|
301
|
+
# unknown, so silently extracting nothing would exit 0 over work that never
|
|
302
|
+
# happened and leave CI drift unbounded. When a `:running` daemon maintains
|
|
303
|
+
# the index, its start-up catch-up covers whatever changed, so standing down
|
|
304
|
+
# with a printed reason is safe; a degraded daemon covers nothing, so the
|
|
305
|
+
# run fails like any other uncovered case. The decision runs BEFORE the
|
|
306
|
+
# task's empty-range exit — a failed range must never be mistaken for an
|
|
307
|
+
# empty one.
|
|
308
|
+
#
|
|
309
|
+
# @param output_dir [Pathname, String] index directory, for daemon coverage
|
|
310
|
+
# @return [Array<String>] changed paths
|
|
311
|
+
def woods_incremental_changed_paths(output_dir)
|
|
312
|
+
explicit = ENV.fetch('CHANGED_FILES', nil)
|
|
313
|
+
return explicit.split(',').map(&:strip) if explicit
|
|
314
|
+
|
|
315
|
+
range = woods_incremental_range
|
|
316
|
+
changed_files, failure = woods_changed_paths_for_range(range)
|
|
317
|
+
return changed_files unless failure
|
|
318
|
+
|
|
319
|
+
if woods_daemon_coverage(output_dir) == :running
|
|
320
|
+
puts "Could not resolve the git diff range #{range.inspect} (#{failure})."
|
|
321
|
+
puts 'A watch daemon is maintaining this index, so its catch-up covers the changed paths — standing down.'
|
|
322
|
+
puts 'To extract now anyway, repair or provide the range (check the CI env refs),'
|
|
323
|
+
puts 'set CHANGED_FILES explicitly, or run a full woods:extract.'
|
|
324
|
+
exit 0
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
warn "ERROR: could not resolve the git diff range #{range.inspect}: #{failure}"
|
|
328
|
+
warn 'The changed-file set is unknown, so incremental extraction would silently index nothing.'
|
|
329
|
+
warn 'Fix the range (a zero SHA or an unfetched base ref resolve to nothing), or run a full woods:extract.'
|
|
330
|
+
exit 1
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# The git range `woods:incremental` diffs, from the CI environment or the
|
|
334
|
+
# last-commit default.
|
|
335
|
+
#
|
|
336
|
+
# @return [String]
|
|
337
|
+
def woods_incremental_range
|
|
338
|
+
if ENV['CI_COMMIT_BEFORE_SHA']
|
|
339
|
+
# GitLab CI
|
|
340
|
+
"#{ENV['CI_COMMIT_BEFORE_SHA']}..#{ENV.fetch('CI_COMMIT_SHA', nil)}"
|
|
341
|
+
elsif ENV['GITHUB_BASE_REF']
|
|
342
|
+
# GitHub Actions PR
|
|
343
|
+
"origin/#{ENV['GITHUB_BASE_REF']}...HEAD"
|
|
344
|
+
else
|
|
345
|
+
# Default: changes since last commit
|
|
346
|
+
'HEAD~1'
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Parse NUL-delimited `git diff --name-status -z --no-renames` output.
|
|
351
|
+
#
|
|
352
|
+
# Each record is `<status>\0<path>\0` — `--no-renames` guarantees a single
|
|
353
|
+
# path per record, since it is what stops git emitting a two-path `R`/`C`
|
|
354
|
+
# record in the first place. Status letters are not inspected beyond "did
|
|
355
|
+
# git report anything at all"; a deleted path still needs to reach the
|
|
356
|
+
# change set so its unit can be pruned.
|
|
357
|
+
#
|
|
358
|
+
# A bare `Open3.capture2` read is tagged with the process's default
|
|
359
|
+
# external encoding — US-ASCII under `LANG=C`, this daemon's usual
|
|
360
|
+
# environment (see `AtomicFile.read`'s gotcha) — so a UTF-8 path is a
|
|
361
|
+
# US-ASCII string containing invalid bytes until re-tagged.
|
|
362
|
+
#
|
|
363
|
+
# @param output [String] raw NUL-delimited git output
|
|
364
|
+
# @return [Array<String>] changed paths
|
|
365
|
+
def woods_parse_git_diff_name_status(output)
|
|
366
|
+
fields = output.dup.force_encoding(Encoding::UTF_8).split("\x00")
|
|
367
|
+
paths = []
|
|
368
|
+
fields.each_slice(2) do |status, path|
|
|
369
|
+
break if path.nil?
|
|
370
|
+
|
|
371
|
+
paths << path unless status.nil? || status.empty?
|
|
372
|
+
end
|
|
373
|
+
paths
|
|
374
|
+
end
|
|
375
|
+
|
|
19
376
|
desc 'Full extraction of codebase for indexing'
|
|
20
377
|
task extract: :environment do
|
|
21
378
|
require 'woods/extractor'
|
|
22
379
|
|
|
23
|
-
output_dir = ENV.fetch('WOODS_OUTPUT',
|
|
380
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
|
|
24
381
|
|
|
25
382
|
puts 'Starting full codebase extraction...'
|
|
26
383
|
puts "Output directory: #{output_dir}"
|
|
27
384
|
puts
|
|
28
385
|
|
|
29
386
|
extractor = Woods::Extractor.new(output_dir: output_dir)
|
|
30
|
-
results =
|
|
387
|
+
results = woods_with_extraction_lock(output_dir) do
|
|
388
|
+
extracted = extractor.extract_all
|
|
389
|
+
extractor.raise_on_publication_failure!
|
|
390
|
+
extracted
|
|
391
|
+
end
|
|
31
392
|
|
|
32
393
|
puts
|
|
33
394
|
puts 'Extraction complete!'
|
|
@@ -48,70 +409,48 @@ namespace :woods do
|
|
|
48
409
|
task incremental: :environment do
|
|
49
410
|
require 'woods/extractor'
|
|
50
411
|
|
|
51
|
-
output_dir = ENV.fetch('WOODS_OUTPUT',
|
|
412
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
|
|
52
413
|
|
|
53
|
-
# Determine changed files from CI environment or git
|
|
54
|
-
|
|
414
|
+
# Determine changed files from CI environment or git. A failed range is
|
|
415
|
+
# decided here too — stand down under a covering daemon, or fail loudly —
|
|
416
|
+
# BEFORE the empty-range exit below can mistake it for "nothing changed"
|
|
417
|
+
# (M1).
|
|
418
|
+
changed_files = woods_incremental_changed_paths(output_dir)
|
|
55
419
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"#{ENV['CI_COMMIT_BEFORE_SHA']}..#{ENV.fetch('CI_COMMIT_SHA', nil)}")
|
|
63
|
-
output.lines.map(&:strip)
|
|
64
|
-
elsif ENV['GITHUB_BASE_REF']
|
|
65
|
-
# GitHub Actions PR
|
|
66
|
-
output, = Open3.capture2('git', 'diff', '--name-only',
|
|
67
|
-
"origin/#{ENV['GITHUB_BASE_REF']}...HEAD")
|
|
68
|
-
output.lines.map(&:strip)
|
|
69
|
-
else
|
|
70
|
-
# Default: changes since last commit
|
|
71
|
-
output, = Open3.capture2('git', 'diff', '--name-only', 'HEAD~1')
|
|
72
|
-
output.lines.map(&:strip)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Filter to relevant files
|
|
76
|
-
relevant_patterns = [
|
|
77
|
-
%r{^app/models/},
|
|
78
|
-
%r{^app/controllers/},
|
|
79
|
-
%r{^app/services/},
|
|
80
|
-
%r{^app/components/},
|
|
81
|
-
%r{^app/views/components/},
|
|
82
|
-
%r{^app/views/.*\.rb$}, # Phlex views
|
|
83
|
-
%r{^app/interactors/},
|
|
84
|
-
%r{^app/operations/},
|
|
85
|
-
%r{^app/commands/},
|
|
86
|
-
%r{^app/use_cases/},
|
|
87
|
-
%r{^app/jobs/},
|
|
88
|
-
%r{^app/workers/}, # Sidekiq workers
|
|
89
|
-
%r{^app/mailers/},
|
|
90
|
-
%r{^app/graphql/}, # GraphQL types/mutations/resolvers
|
|
91
|
-
%r{^app/serializers/},
|
|
92
|
-
%r{^app/decorators/},
|
|
93
|
-
%r{^app/blueprinters/},
|
|
94
|
-
%r{^db/migrate/},
|
|
95
|
-
%r{^db/schema\.rb$}, # Schema changes affect model metadata
|
|
96
|
-
%r{^config/routes\.rb$},
|
|
97
|
-
/^Gemfile\.lock$/ # Dependency changes trigger framework re-index
|
|
98
|
-
]
|
|
99
|
-
|
|
100
|
-
changed_files = changed_files.select do |f|
|
|
101
|
-
relevant_patterns.any? { |p| f.match?(p) }
|
|
102
|
-
end
|
|
420
|
+
# Filter to paths that imply extraction work. The rule set lives in
|
|
421
|
+
# PathDispatcher alongside the dispatch itself — a second hand-maintained
|
|
422
|
+
# pattern list here would drift, and a path this filter drops never
|
|
423
|
+
# reaches the index however good the dispatch behind it is (#164).
|
|
424
|
+
dispatcher = Woods::PathDispatcher.new
|
|
425
|
+
changed_files = changed_files.reject(&:empty?).select { |f| dispatcher.relevant?(f) }
|
|
103
426
|
|
|
104
427
|
if changed_files.empty?
|
|
105
428
|
puts 'No relevant files changed. Skipping extraction.'
|
|
106
429
|
exit 0
|
|
107
430
|
end
|
|
108
431
|
|
|
432
|
+
case woods_daemon_coverage(output_dir)
|
|
433
|
+
when :running
|
|
434
|
+
puts 'A watch daemon is maintaining this index — skipping.'
|
|
435
|
+
puts 'It reconciles anything changed since the last publish when it starts, so these are covered.'
|
|
436
|
+
puts 'Set WOODS_IGNORE_WATCH=1 to extract anyway.'
|
|
437
|
+
exit 0
|
|
438
|
+
when :degraded
|
|
439
|
+
# Alive but unable to update. Standing down here would exit 0 over work
|
|
440
|
+
# nothing is actually doing.
|
|
441
|
+
puts 'Warning: a watch daemon is alive but degraded — extracting anyway rather than assuming coverage.'
|
|
442
|
+
end
|
|
443
|
+
|
|
109
444
|
puts "Incremental extraction for #{changed_files.size} changed files..."
|
|
110
445
|
changed_files.each { |f| puts " - #{f}" }
|
|
111
446
|
puts
|
|
112
447
|
|
|
113
448
|
extractor = Woods::Extractor.new(output_dir: output_dir)
|
|
114
|
-
affected =
|
|
449
|
+
affected = woods_with_extraction_lock(output_dir) do
|
|
450
|
+
extracted = extractor.extract_changed(changed_files)
|
|
451
|
+
extractor.raise_on_publication_failure!
|
|
452
|
+
extracted
|
|
453
|
+
end
|
|
115
454
|
|
|
116
455
|
puts
|
|
117
456
|
puts "Re-extracted #{affected.size} affected units."
|
|
@@ -120,99 +459,190 @@ namespace :woods do
|
|
|
120
459
|
desc 'Tend the garden — incremental extraction (alias for incremental)'
|
|
121
460
|
task tend: :incremental
|
|
122
461
|
|
|
462
|
+
desc 'Watch the app and keep the index current (resident daemon)'
|
|
463
|
+
task watch: :environment do
|
|
464
|
+
# Both, and the extractor is not optional. The daemon's default
|
|
465
|
+
# extractor_factory names Woods::Extractor lazily, so omitting this require
|
|
466
|
+
# loaded and started cleanly and then NameError'd on the first real cycle —
|
|
467
|
+
# which the failure posture turns into a permanently degraded daemon rather
|
|
468
|
+
# than a crash. Every spec pre-requires the extractor in its own setup, so
|
|
469
|
+
# the suite stayed green over a broken entry point.
|
|
470
|
+
require 'woods/extractor'
|
|
471
|
+
require 'woods/watch/daemon'
|
|
472
|
+
|
|
473
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
|
|
474
|
+
|
|
475
|
+
daemon = Woods::Watch::Daemon.new(
|
|
476
|
+
output_dir: output_dir,
|
|
477
|
+
root: Rails.root,
|
|
478
|
+
debounce: Float(ENV.fetch('WOODS_WATCH_DEBOUNCE', Woods::Watch::Daemon::DEFAULT_DEBOUNCE)),
|
|
479
|
+
full_extraction_threshold: Integer(
|
|
480
|
+
ENV.fetch('WOODS_WATCH_FULL_THRESHOLD', Woods::Watch::Daemon::DEFAULT_FULL_EXTRACTION_THRESHOLD)
|
|
481
|
+
),
|
|
482
|
+
# The documented fix for a container watching a bind mount, where native
|
|
483
|
+
# FS events do not propagate and the daemon would sit silent. Nothing
|
|
484
|
+
# exposed it before, which made the advice unfollowable.
|
|
485
|
+
force_polling: ENV['WOODS_WATCH_POLL'] == '1', # container autodetect also applies; see Watcher.containerized?
|
|
486
|
+
idle_timeout: ENV.fetch('WOODS_WATCH_IDLE_TIMEOUT', nil) && Float(ENV.fetch('WOODS_WATCH_IDLE_TIMEOUT')),
|
|
487
|
+
catch_up: ENV['WOODS_WATCH_CATCH_UP'] != '0',
|
|
488
|
+
logger: Rails.logger
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# The trap sets a flag and nothing else. `daemon.stop` reaches
|
|
492
|
+
# `Listen::Listener#stop`, which drives a state machine behind mutexes —
|
|
493
|
+
# and taking a mutex in trap context raises ThreadError on some Ruby
|
|
494
|
+
# versions, turning Ctrl-C into a crash instead of a clean shutdown. A tiny
|
|
495
|
+
# supervisor thread does the real work outside trap context.
|
|
496
|
+
stop_requested = Queue.new
|
|
497
|
+
%w[INT TERM].each { |sig| Signal.trap(sig) { stop_requested.push(sig) } }
|
|
498
|
+
Thread.new do
|
|
499
|
+
stop_requested.pop
|
|
500
|
+
daemon.stop
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
puts "Watching #{Rails.root} — index at #{output_dir}"
|
|
504
|
+
puts 'Ctrl-C to stop.'
|
|
505
|
+
puts
|
|
506
|
+
|
|
507
|
+
reason = daemon.run
|
|
508
|
+
|
|
509
|
+
if reason == :restart_required
|
|
510
|
+
# Boot-captured state changed; Rails cannot reload it. Exit non-zero so
|
|
511
|
+
# a supervisor (foreman, systemd, `docker compose` restart policy)
|
|
512
|
+
# brings the process back with the new configuration.
|
|
513
|
+
warn 'Restart required — boot-captured configuration changed. Exiting for a supervisor to restart.'
|
|
514
|
+
exit 75 # EX_TEMPFAIL
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
puts 'Watcher stopped.'
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
desc 'Keep watch over the woods — resident index daemon (alias for watch)'
|
|
521
|
+
task guard: :watch
|
|
522
|
+
|
|
523
|
+
desc 'Report whether a watch daemon is maintaining this index (exit 0 if alive)'
|
|
524
|
+
# Deliberately not `=> :environment`. This reads one small JSON file, and the
|
|
525
|
+
# whole point is that a worktree hook can call it before deciding whether to
|
|
526
|
+
# do real work — paying a full Rails boot to find out would cost more than the
|
|
527
|
+
# sync it is trying to avoid. WOODS_OUTPUT covers the non-default layout;
|
|
528
|
+
# otherwise the conventional path is derived without booting.
|
|
529
|
+
task :watch_status do
|
|
530
|
+
require 'woods/watch/status'
|
|
531
|
+
require 'json'
|
|
532
|
+
|
|
533
|
+
output_dir = ENV.fetch('WOODS_OUTPUT') { File.join(woods_task_root, 'tmp/woods') }
|
|
534
|
+
status = Woods::Watch::Status.new(output_dir: output_dir)
|
|
535
|
+
|
|
536
|
+
puts JSON.pretty_generate(status.read)
|
|
537
|
+
# Exit status is the point: a worktree hook can `rake woods:watch_status ||
|
|
538
|
+
# start_daemon` without parsing anything.
|
|
539
|
+
exit(status.alive? ? 0 : 1)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
desc 'Re-run named extractors wholesale, e.g. woods:refresh[routes,middleware]'
|
|
543
|
+
task :refresh, [:extractor] => :environment do |_task, args|
|
|
544
|
+
require 'woods/extractor'
|
|
545
|
+
|
|
546
|
+
keys = [args[:extractor], *args.extras].compact.map(&:strip).reject(&:empty?)
|
|
547
|
+
|
|
548
|
+
if keys.empty?
|
|
549
|
+
puts 'Usage: rake "woods:refresh[routes]" (comma-separate for several)'
|
|
550
|
+
puts
|
|
551
|
+
puts 'Whole-app extractors — no per-file entry point, so these are the'
|
|
552
|
+
puts 'ones a targeted refresh is normally for:'
|
|
553
|
+
puts " #{Woods::Extractor::WHOLE_APP_EXTRACTORS.keys.sort.join(', ')}"
|
|
554
|
+
puts
|
|
555
|
+
puts 'Any extractor key is accepted:'
|
|
556
|
+
puts " #{Woods::Extractor::EXTRACTORS.keys.sort.join(', ')}"
|
|
557
|
+
exit 1
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
|
|
561
|
+
extractor = Woods::Extractor.new(output_dir: output_dir)
|
|
562
|
+
|
|
563
|
+
# A refresh is a fourth writer against this index, and it rewrites the whole
|
|
564
|
+
# dependency graph. Two writers loading the persisted graph, mutating
|
|
565
|
+
# divergent copies and writing back means the last one silently discards the
|
|
566
|
+
# other's work — and then bumps the generation, telling readers the
|
|
567
|
+
# clobbered state is fresh. Atomic writes do not help: each write is
|
|
568
|
+
# individually intact, the *set* is not. So it serializes like the others.
|
|
569
|
+
begin
|
|
570
|
+
result = woods_with_extraction_lock(output_dir) do
|
|
571
|
+
refreshed = extractor.refresh(*keys)
|
|
572
|
+
extractor.raise_on_publication_failure!
|
|
573
|
+
refreshed
|
|
574
|
+
end
|
|
575
|
+
rescue ArgumentError => e
|
|
576
|
+
puts "ERROR: #{e.message}"
|
|
577
|
+
puts "Known extractors: #{Woods::Extractor::EXTRACTORS.keys.sort.join(', ')}"
|
|
578
|
+
exit 1
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
puts "Refreshed: #{result[:types].join(', ')}"
|
|
582
|
+
puts "Warning: ignored unknown extractor(s): #{result[:unknown].join(', ')}" if result[:unknown].any?
|
|
583
|
+
puts "#{result[:touched].size} unit(s) written or removed."
|
|
584
|
+
end
|
|
585
|
+
|
|
123
586
|
desc 'Extract only Rails/gem framework sources (run when dependencies change)'
|
|
124
587
|
task extract_framework: :environment do
|
|
125
|
-
require 'woods/
|
|
588
|
+
require 'woods/extractor'
|
|
126
589
|
|
|
127
|
-
output_dir = ENV.fetch('WOODS_OUTPUT',
|
|
590
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
|
|
128
591
|
|
|
129
592
|
puts 'Extracting Rails and gem framework sources...'
|
|
130
593
|
puts "Rails version: #{Rails.version}"
|
|
131
594
|
puts
|
|
132
595
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
596
|
+
# Back-compat alias for `woods:refresh[rails_source]` (#169). The old body
|
|
597
|
+
# hand-wrote unit JSON with a bare File.write — no AtomicFile, no path
|
|
598
|
+
# normalization, no _index.json, no manifest counts, no PipelineLock, no
|
|
599
|
+
# generation bump — so the units it produced were invisible to
|
|
600
|
+
# generation-keyed readers and went stale forever. Routing through
|
|
601
|
+
# Extractor#refresh sends framework sources through the same write
|
|
602
|
+
# pipeline (and the same writer lock) as every other unit type. This runs
|
|
603
|
+
# regardless of `include_framework_sources` — an explicit invocation is
|
|
604
|
+
# the escape hatch the knob deliberately leaves open.
|
|
605
|
+
extractor = Woods::Extractor.new(output_dir: output_dir)
|
|
606
|
+
result = woods_with_extraction_lock(output_dir) do
|
|
607
|
+
refreshed = extractor.refresh(:rails_source)
|
|
608
|
+
extractor.raise_on_publication_failure!
|
|
609
|
+
refreshed
|
|
146
610
|
end
|
|
147
611
|
|
|
148
|
-
puts "Extracted #{
|
|
149
|
-
puts "Output: #{
|
|
612
|
+
puts "Extracted #{result[:touched].size} framework source unit(s)."
|
|
613
|
+
puts "Output: #{Pathname.new(output_dir).join('rails_source')}"
|
|
150
614
|
end
|
|
151
615
|
|
|
152
616
|
desc 'Validate extracted index integrity'
|
|
153
617
|
task validate: :environment do
|
|
154
|
-
|
|
618
|
+
require 'woods/resilience/index_validator'
|
|
619
|
+
|
|
620
|
+
output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir))
|
|
155
621
|
|
|
156
622
|
unless output_dir.exist?
|
|
157
623
|
puts "ERROR: Index directory does not exist: #{output_dir}"
|
|
158
624
|
exit 1
|
|
159
625
|
end
|
|
160
626
|
|
|
161
|
-
manifest_path = output_dir.join('manifest.json')
|
|
627
|
+
manifest_path = woods_payload_dir(output_dir).join('manifest.json')
|
|
162
628
|
unless manifest_path.exist?
|
|
163
629
|
puts 'ERROR: Manifest not found. Run extraction first.'
|
|
164
630
|
exit 1
|
|
165
631
|
end
|
|
166
632
|
|
|
167
|
-
manifest = JSON.parse(
|
|
633
|
+
manifest = JSON.parse(Woods::AtomicFile.read(manifest_path))
|
|
168
634
|
|
|
169
635
|
puts 'Validating index...'
|
|
170
636
|
puts " Extracted at: #{manifest['extracted_at']}"
|
|
171
637
|
puts " Git SHA: #{manifest['git_sha']}"
|
|
172
638
|
puts
|
|
173
639
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
type_dir = output_dir.join(type)
|
|
180
|
-
unless type_dir.exist?
|
|
181
|
-
errors << "Missing directory: #{type}"
|
|
182
|
-
next
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
actual_count = Dir[type_dir.join('*.json')].reject { |f| f.end_with?('_index.json') }.size
|
|
186
|
-
|
|
187
|
-
warnings << "#{type}: expected #{expected_count}, found #{actual_count}" if actual_count != expected_count
|
|
640
|
+
# One implementation of the check (B-128): the class the worktree
|
|
641
|
+
# integration spec asserts through is the one this task runs.
|
|
642
|
+
report = Woods::Resilience::IndexValidator.new(index_dir: output_dir.to_s, app_root: Rails.root.to_s).validate
|
|
643
|
+
errors = report.errors
|
|
644
|
+
warnings = report.warnings
|
|
188
645
|
|
|
189
|
-
# Validate each unit file is valid JSON
|
|
190
|
-
Dir[type_dir.join('*.json')].each do |file|
|
|
191
|
-
next if file.end_with?('_index.json')
|
|
192
|
-
|
|
193
|
-
begin
|
|
194
|
-
data = JSON.parse(File.read(file))
|
|
195
|
-
errors << "#{file}: missing identifier" unless data['identifier']
|
|
196
|
-
errors << "#{file}: missing source_code" unless data['source_code']
|
|
197
|
-
rescue JSON::ParserError => e
|
|
198
|
-
errors << "#{file}: invalid JSON - #{e.message}"
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
# Check dependency graph
|
|
204
|
-
graph_path = output_dir.join('dependency_graph.json')
|
|
205
|
-
if graph_path.exist?
|
|
206
|
-
begin
|
|
207
|
-
JSON.parse(File.read(graph_path))
|
|
208
|
-
rescue JSON::ParserError
|
|
209
|
-
errors << 'dependency_graph.json: invalid JSON'
|
|
210
|
-
end
|
|
211
|
-
else
|
|
212
|
-
errors << 'Missing dependency_graph.json'
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# Report
|
|
216
646
|
if errors.any?
|
|
217
647
|
puts 'ERRORS:'
|
|
218
648
|
errors.each { |e| puts " ✗ #{e}" }
|
|
@@ -238,15 +668,16 @@ namespace :woods do
|
|
|
238
668
|
|
|
239
669
|
desc 'Show index statistics'
|
|
240
670
|
task stats: :environment do
|
|
241
|
-
output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT',
|
|
671
|
+
output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir))
|
|
242
672
|
|
|
243
673
|
unless output_dir.exist?
|
|
244
674
|
puts 'Index directory does not exist. Run extraction first.'
|
|
245
675
|
exit 1
|
|
246
676
|
end
|
|
247
677
|
|
|
248
|
-
|
|
249
|
-
|
|
678
|
+
payload_dir = woods_payload_dir(output_dir)
|
|
679
|
+
manifest_path = payload_dir.join('manifest.json')
|
|
680
|
+
manifest = manifest_path.exist? ? JSON.parse(Woods::AtomicFile.read(manifest_path)) : {}
|
|
250
681
|
|
|
251
682
|
puts 'Woods Index Statistics'
|
|
252
683
|
puts '=' * 50
|
|
@@ -265,7 +696,7 @@ namespace :woods do
|
|
|
265
696
|
total_chunks = 0
|
|
266
697
|
|
|
267
698
|
(manifest['counts'] || {}).each do |type, count|
|
|
268
|
-
type_dir =
|
|
699
|
+
type_dir = payload_dir.join(type)
|
|
269
700
|
next unless type_dir.exist?
|
|
270
701
|
|
|
271
702
|
type_size = Dir[type_dir.join('*.json')].sum { |f| File.size(f) }
|
|
@@ -276,7 +707,7 @@ namespace :woods do
|
|
|
276
707
|
index_path = type_dir.join('_index.json')
|
|
277
708
|
type_chunks = 0
|
|
278
709
|
if index_path.exist?
|
|
279
|
-
index = JSON.parse(
|
|
710
|
+
index = JSON.parse(Woods::AtomicFile.read(index_path))
|
|
280
711
|
type_chunks = index.sum { |u| u['chunk_count'] || 0 }
|
|
281
712
|
total_chunks += type_chunks
|
|
282
713
|
end
|
|
@@ -289,9 +720,9 @@ namespace :woods do
|
|
|
289
720
|
puts
|
|
290
721
|
|
|
291
722
|
# Dependency graph stats
|
|
292
|
-
graph_path =
|
|
723
|
+
graph_path = payload_dir.join('dependency_graph.json')
|
|
293
724
|
if graph_path.exist?
|
|
294
|
-
graph = JSON.parse(
|
|
725
|
+
graph = JSON.parse(Woods::AtomicFile.read(graph_path))
|
|
295
726
|
stats = graph['stats'] || {}
|
|
296
727
|
puts 'Dependency Graph'
|
|
297
728
|
puts '-' * 50
|
|
@@ -305,11 +736,11 @@ namespace :woods do
|
|
|
305
736
|
|
|
306
737
|
desc 'Clean extracted index'
|
|
307
738
|
task clean: :environment do
|
|
308
|
-
output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT',
|
|
739
|
+
output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir))
|
|
309
740
|
|
|
310
741
|
if output_dir.exist?
|
|
311
742
|
puts "Removing #{output_dir}..."
|
|
312
|
-
|
|
743
|
+
exit 1 unless woods_clean_index(output_dir) == :cleaned
|
|
313
744
|
puts 'Done.'
|
|
314
745
|
else
|
|
315
746
|
puts 'Index directory does not exist.'
|
|
@@ -319,36 +750,38 @@ namespace :woods do
|
|
|
319
750
|
desc 'Clear the brush — remove index (alias for clean)'
|
|
320
751
|
task clear: :clean
|
|
321
752
|
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
753
|
+
# Resolve the configured retrieval stack and run one ad-hoc query (#178).
|
|
754
|
+
#
|
|
755
|
+
# Every backend — embedding provider, vector store, metadata store, graph
|
|
756
|
+
# store — is resolved through Woods::Builder from Woods.configuration,
|
|
757
|
+
# the same wiring `woods:embed` writes through
|
|
758
|
+
# (Woods::Tasks.build_embed_indexer). The old task body hardcoded
|
|
759
|
+
# Ollama + InMemory + SQLite + Memory, so on any other configured stack it
|
|
760
|
+
# queried backends the embed run never wrote to and silently returned
|
|
761
|
+
# nothing.
|
|
762
|
+
#
|
|
763
|
+
# In-memory stores start empty in a fresh process; hosts on the :local /
|
|
764
|
+
# :shared_filesystem presets should query through woods-mcp, which
|
|
765
|
+
# hydrates them from the dumps on disk.
|
|
766
|
+
#
|
|
767
|
+
# @param query [String] natural-language retrieval query
|
|
768
|
+
# @return [String] human-formatted retrieval output
|
|
769
|
+
def woods_run_retrieval(query)
|
|
326
770
|
require 'woods'
|
|
327
|
-
require 'woods/retriever'
|
|
328
|
-
require 'woods/embedding/provider'
|
|
329
|
-
require 'woods/storage/vector_store'
|
|
330
|
-
require 'woods/storage/metadata_store'
|
|
331
|
-
require 'woods/storage/graph_store'
|
|
332
771
|
require 'woods/formatting/human_adapter'
|
|
333
772
|
|
|
334
773
|
config = Woods.configuration
|
|
774
|
+
retriever = Woods::Builder.new(config).build_retriever
|
|
775
|
+
result = retriever.retrieve(query, budget: config.max_context_tokens)
|
|
335
776
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
metadata_store = Woods::Storage::MetadataStore::SQLite.new
|
|
339
|
-
graph_store = Woods::Storage::GraphStore::Memory.new
|
|
340
|
-
|
|
341
|
-
retriever = Woods::Retriever.new(
|
|
342
|
-
vector_store: vector_store,
|
|
343
|
-
metadata_store: metadata_store,
|
|
344
|
-
graph_store: graph_store,
|
|
345
|
-
embedding_provider: provider
|
|
346
|
-
)
|
|
777
|
+
Woods::Formatting::HumanAdapter.new.format(result)
|
|
778
|
+
end
|
|
347
779
|
|
|
348
|
-
|
|
780
|
+
# Internal debugging tool — hidden from `rails -T`
|
|
781
|
+
task :retrieve, [:query] => :environment do |_t, args|
|
|
782
|
+
query = args[:query] || raise('Usage: rake woods:retrieve[query]')
|
|
349
783
|
|
|
350
|
-
|
|
351
|
-
puts formatter.format(result)
|
|
784
|
+
puts woods_run_retrieval(query)
|
|
352
785
|
end
|
|
353
786
|
|
|
354
787
|
desc 'Embed all extracted units'
|
|
@@ -356,9 +789,20 @@ namespace :woods do
|
|
|
356
789
|
require 'woods'
|
|
357
790
|
require 'woods/tasks'
|
|
358
791
|
|
|
792
|
+
# Embedding writes dumps/, checkpoint.json and woods.json under the same
|
|
793
|
+
# output dir the extraction writers own — and `woods:clean` deletes them —
|
|
794
|
+
# so it serializes on the same PipelineLock (#170). One lock domain per
|
|
795
|
+
# index, deliberately coarse: an embed does block an extract it doesn't
|
|
796
|
+
# byte-conflict with, but the failure the lock prevents is a concurrent
|
|
797
|
+
# clean/extract silently clobbering a dump mid-promotion, and a second
|
|
798
|
+
# lock name would reintroduce exactly the unlocked-writer gap.
|
|
799
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
|
|
800
|
+
|
|
359
801
|
indexer = Woods::Tasks.build_embed_indexer
|
|
360
802
|
puts 'Embedding all extracted units...'
|
|
361
|
-
|
|
803
|
+
stats = woods_with_extraction_lock(output_dir) { indexer.index_all }
|
|
804
|
+
Woods::Tasks.print_embed_stats(stats, mode: :full)
|
|
805
|
+
woods_exit_on_reported_errors(stats[:errors])
|
|
362
806
|
end
|
|
363
807
|
|
|
364
808
|
desc 'Nest the data — embed all units (alias for embed)'
|
|
@@ -369,9 +813,14 @@ namespace :woods do
|
|
|
369
813
|
require 'woods'
|
|
370
814
|
require 'woods/tasks'
|
|
371
815
|
|
|
816
|
+
# Same lock domain as woods:embed — see the comment there (#170).
|
|
817
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
|
|
818
|
+
|
|
372
819
|
indexer = Woods::Tasks.build_embed_indexer
|
|
373
820
|
puts 'Embedding changed units (incremental)...'
|
|
374
|
-
|
|
821
|
+
stats = woods_with_extraction_lock(output_dir) { indexer.index_incremental }
|
|
822
|
+
Woods::Tasks.print_embed_stats(stats, mode: :incremental)
|
|
823
|
+
woods_exit_on_reported_errors(stats[:errors])
|
|
375
824
|
end
|
|
376
825
|
|
|
377
826
|
desc 'Hone the blade — incremental embedding (alias for embed_incremental)'
|
|
@@ -452,31 +901,31 @@ namespace :woods do
|
|
|
452
901
|
FileUtils.mkdir_p(docs_dir)
|
|
453
902
|
renderer = Woods::RubyAnalyzer::MermaidRenderer.new
|
|
454
903
|
|
|
904
|
+
# architecture.md embeds the call-graph, dependency-map, and dataflow
|
|
905
|
+
# renders as sections; emitting them as standalone files too duplicated
|
|
906
|
+
# ~13k generated lines in the docs tree for no reader benefit.
|
|
455
907
|
File.write(
|
|
456
908
|
File.join(docs_dir, 'architecture.md'),
|
|
457
909
|
renderer.render_architecture(units, graph_data, analysis)
|
|
458
910
|
)
|
|
459
911
|
|
|
460
|
-
File.write(
|
|
461
|
-
File.join(docs_dir, 'call-graph.md'),
|
|
462
|
-
"# Call Graph\n\n```mermaid\n#{renderer.render_call_graph(units)}\n```\n"
|
|
463
|
-
)
|
|
464
|
-
|
|
465
|
-
File.write(
|
|
466
|
-
File.join(docs_dir, 'dependency-map.md'),
|
|
467
|
-
"# Dependency Map\n\n```mermaid\n#{renderer.render_dependency_map(graph_data)}\n```\n"
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
File.write(
|
|
471
|
-
File.join(docs_dir, 'dataflow.md'),
|
|
472
|
-
"# Data Flow\n\n```mermaid\n#{renderer.render_dataflow(units)}\n```\n"
|
|
473
|
-
)
|
|
474
|
-
|
|
475
912
|
puts " JSON output: #{json_dir}"
|
|
476
913
|
puts " Mermaid docs: #{docs_dir}"
|
|
477
914
|
puts 'Self-analysis complete.'
|
|
478
915
|
end
|
|
479
916
|
|
|
917
|
+
# Internal developer tool. Unlike self_analyze, this publishes an ordinary
|
|
918
|
+
# index generation that the packaged Index MCP server can read. It is
|
|
919
|
+
# intentionally Woods-only and static: it does not boot Rails.
|
|
920
|
+
task :self_map, [:output_dir] do |_task, args|
|
|
921
|
+
require 'woods/gem_mapper'
|
|
922
|
+
|
|
923
|
+
gem_root = File.expand_path('../..', __dir__)
|
|
924
|
+
output_dir = args[:output_dir] || ENV.fetch('WOODS_SELF_MAP_OUTPUT', File.join(gem_root, 'tmp', 'woods_self_map'))
|
|
925
|
+
result = Woods::GemMapper.new(root: gem_root, output_dir: output_dir).map!
|
|
926
|
+
puts "Woods self-map #{result[:status]} (generation #{result[:generation]}) at #{output_dir}"
|
|
927
|
+
end
|
|
928
|
+
|
|
480
929
|
desc 'Generate execution flow document for a Rails entry point'
|
|
481
930
|
task :flow, [:entry_point] => :environment do |_t, args|
|
|
482
931
|
require 'json'
|
|
@@ -489,8 +938,8 @@ namespace :woods do
|
|
|
489
938
|
exit 1
|
|
490
939
|
end
|
|
491
940
|
|
|
492
|
-
output_dir = ENV.fetch('WOODS_OUTPUT',
|
|
493
|
-
graph_path = File.join(output_dir, 'dependency_graph.json')
|
|
941
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
|
|
942
|
+
graph_path = File.join(woods_payload_dir(output_dir).to_s, 'dependency_graph.json')
|
|
494
943
|
|
|
495
944
|
unless File.exist?(graph_path)
|
|
496
945
|
puts "ERROR: Dependency graph not found at #{graph_path}"
|
|
@@ -498,11 +947,11 @@ namespace :woods do
|
|
|
498
947
|
exit 1
|
|
499
948
|
end
|
|
500
949
|
|
|
501
|
-
graph_data = JSON.parse(
|
|
950
|
+
graph_data = JSON.parse(Woods::AtomicFile.read(graph_path))
|
|
502
951
|
graph = Woods::DependencyGraph.from_h(graph_data)
|
|
503
952
|
|
|
504
953
|
max_depth = ENV.fetch('MAX_DEPTH', 5).to_i
|
|
505
|
-
assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: output_dir)
|
|
954
|
+
assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: woods_payload_dir(output_dir).to_s)
|
|
506
955
|
flow = assembler.assemble(entry_point, max_depth: max_depth)
|
|
507
956
|
|
|
508
957
|
format = ENV.fetch('FORMAT', 'markdown').downcase
|
|
@@ -571,6 +1020,7 @@ namespace :woods do
|
|
|
571
1020
|
puts " Errors: #{stats[:errors].size}"
|
|
572
1021
|
stats[:errors].first(5).each { |e| puts " - #{e}" }
|
|
573
1022
|
puts " ... and #{stats[:errors].size - 5} more" if stats[:errors].size > 5
|
|
1023
|
+
woods_exit_on_reported_errors(stats[:errors])
|
|
574
1024
|
end
|
|
575
1025
|
end
|
|
576
1026
|
|
|
@@ -640,7 +1090,11 @@ namespace :woods do
|
|
|
640
1090
|
# post-merge pipelines (a dead token would otherwise stay green forever).
|
|
641
1091
|
# Exception: budget exhaustion *with* partial progress is the expected
|
|
642
1092
|
# cold-start shape; it converges on the next run.
|
|
643
|
-
|
|
1093
|
+
# Matched on the message because errors reach here as strings, not
|
|
1094
|
+
# exceptions. `budget exhausted` is the stable part of
|
|
1095
|
+
# BudgetExhaustedError's message — spec/unblocked/rate_limiter_spec.rb
|
|
1096
|
+
# pins it so this branch cannot be silently broken by a rewording.
|
|
1097
|
+
budget_only = stats[:errors].all? { |e| e.include?('budget exhausted') }
|
|
644
1098
|
unless budget_only && stats[:synced].positive?
|
|
645
1099
|
puts
|
|
646
1100
|
puts 'Sync completed with errors — failing so CI surfaces it.'
|