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
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require_relative 'filename_utils'
|
|
6
6
|
require_relative 'flow_assembler'
|
|
7
|
+
require_relative 'atomic_file'
|
|
7
8
|
|
|
8
9
|
module Woods
|
|
9
10
|
# Orchestrates pre-computation of request flow maps for all controller actions.
|
|
@@ -11,10 +12,20 @@ module Woods
|
|
|
11
12
|
# After the dependency graph is built, FlowPrecomputer iterates controller units,
|
|
12
13
|
# runs FlowAssembler for each action, and writes flow documents to disk.
|
|
13
14
|
#
|
|
15
|
+
# All persisted path values — `flow_index.json` entries and each controller
|
|
16
|
+
# unit's `metadata[:flow_paths]` — are RELATIVE to the output dir
|
|
17
|
+
# (e.g. "flows/OrdersController_create.json"), matching the #166 portability
|
|
18
|
+
# contract: extraction inside a container must not bake `/app/...` prefixes
|
|
19
|
+
# into artifacts a host reads through a volume mount, and identical trees at
|
|
20
|
+
# different roots must produce identical bytes (which is also what keeps
|
|
21
|
+
# {#canonical_json}'s churn-avoidance honest). Readers join the relative
|
|
22
|
+
# value against their own index dir (see the MCP server's
|
|
23
|
+
# `load_precomputed_flow`).
|
|
24
|
+
#
|
|
14
25
|
# @example
|
|
15
26
|
# precomputer = FlowPrecomputer.new(units: all_units, graph: dep_graph, output_dir: out)
|
|
16
27
|
# flow_map = precomputer.precompute
|
|
17
|
-
# flow_map["OrdersController#create"] #=> "
|
|
28
|
+
# flow_map["OrdersController#create"] #=> "flows/OrdersController_create.json"
|
|
18
29
|
#
|
|
19
30
|
class FlowPrecomputer
|
|
20
31
|
# Default maximum recursion depth for flow assembly
|
|
@@ -34,7 +45,15 @@ module Woods
|
|
|
34
45
|
|
|
35
46
|
# Pre-compute flow documents for all controller actions.
|
|
36
47
|
#
|
|
37
|
-
#
|
|
48
|
+
# Assembly is fail closed (M3 review round 3): a per-action failure
|
|
49
|
+
# raises instead of skipping the entry, so a partial index — missing
|
|
50
|
+
# entries alongside a freshly written graph — is never written and the
|
|
51
|
+
# caller aborts before publishing. The full extraction path shares this
|
|
52
|
+
# contract with the incremental delta.
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash{String => String}] Map of entry_point to output_dir-relative
|
|
55
|
+
# flow file path (e.g. "flows/OrdersController_create.json")
|
|
56
|
+
# @raise [Woods::ExtractionError] when any action fails to assemble
|
|
38
57
|
def precompute
|
|
39
58
|
FileUtils.mkdir_p(@flows_dir)
|
|
40
59
|
|
|
@@ -42,18 +61,8 @@ module Woods
|
|
|
42
61
|
flow_map = {}
|
|
43
62
|
|
|
44
63
|
controller_units.each do |unit|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
actions.each do |action|
|
|
49
|
-
entry_point = "#{unit.identifier}##{action}"
|
|
50
|
-
flow_path = assemble_and_write(assembler, entry_point, unit.identifier, action)
|
|
51
|
-
next unless flow_path
|
|
52
|
-
|
|
53
|
-
flow_map[entry_point] = flow_path
|
|
54
|
-
unit_flow_paths[action] = flow_path
|
|
55
|
-
end
|
|
56
|
-
|
|
64
|
+
entries, unit_flow_paths = assemble_controller_unit(assembler, unit)
|
|
65
|
+
flow_map.merge!(entries)
|
|
57
66
|
unit.metadata[:flow_paths] = unit_flow_paths if unit_flow_paths.any?
|
|
58
67
|
end
|
|
59
68
|
|
|
@@ -62,6 +71,64 @@ module Woods
|
|
|
62
71
|
flow_map
|
|
63
72
|
end
|
|
64
73
|
|
|
74
|
+
# Recompute the flow documents for the controllers an incremental run
|
|
75
|
+
# touched (M3), carrying every untouched controller's entries forward
|
|
76
|
+
# from the previous generation's flow_index.json — payload seeding
|
|
77
|
+
# hardlinks it into this run's payload directory — and rewriting the
|
|
78
|
+
# index with the merged result.
|
|
79
|
+
#
|
|
80
|
+
# Touched controllers replace their previous entries wholesale (a full
|
|
81
|
+
# run would emit exactly their current actions), so an action removed
|
|
82
|
+
# from a re-extracted controller leaves the index even though the file
|
|
83
|
+
# still exists. Controllers named in +removed_identifiers+ lost their
|
|
84
|
+
# unit this run (deleted or renamed) and leave the index entirely;
|
|
85
|
+
# {Woods::Extractor#sweep_orphaned_flow_files} then removes the
|
|
86
|
+
# documents nothing references anymore.
|
|
87
|
+
#
|
|
88
|
+
# @param touched_units [Array<ExtractedUnit>] the run's re-extracted
|
|
89
|
+
# controller units, rehydrated from the payload this run seeded
|
|
90
|
+
# @param removed_identifiers [Array<String>] controller identifiers
|
|
91
|
+
# whose unit the run pruned
|
|
92
|
+
# @param carried_identifiers [Array<String>] controllers the run
|
|
93
|
+
# re-extracted whose flows it established cannot have changed. Their
|
|
94
|
+
# index entries stay as they are and their annotation is read back out
|
|
95
|
+
# of those entries, because a re-extracted unit lost the annotation
|
|
96
|
+
# whether or not its flows moved. Skipping the assembly is the whole
|
|
97
|
+
# saving; skipping the annotation would diverge from a full run.
|
|
98
|
+
# @return [Hash{String => Hash{String => String}}] per-controller
|
|
99
|
+
# annotation (action => relative flow path) to write into the units'
|
|
100
|
+
# metadata; an empty hash means "no flows", which clears any
|
|
101
|
+
# annotation a previous run had written
|
|
102
|
+
def recompute_delta(touched_units:, removed_identifiers: [], carried_identifiers: [])
|
|
103
|
+
FileUtils.mkdir_p(@flows_dir)
|
|
104
|
+
|
|
105
|
+
assembler = FlowAssembler.new(graph: @graph, extracted_dir: @output_dir)
|
|
106
|
+
delta = {}
|
|
107
|
+
annotations = {}
|
|
108
|
+
|
|
109
|
+
touched_units.each do |unit|
|
|
110
|
+
# A per-action skip would publish an index missing that entry, so
|
|
111
|
+
# assembly raises. The incremental path must not advance publication
|
|
112
|
+
# over a broken assembly; the caller aborts before the generation
|
|
113
|
+
# bump.
|
|
114
|
+
entries, unit_flow_paths = assemble_controller_unit(assembler, unit)
|
|
115
|
+
annotations[unit.identifier] = unit_flow_paths
|
|
116
|
+
delta.merge!(entries)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
previous = previous_flow_index
|
|
120
|
+
Array(carried_identifiers).each do |identifier|
|
|
121
|
+
annotations[identifier] = carried_annotation(previous, identifier)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
replaced = touched_units.map(&:identifier) + Array(removed_identifiers)
|
|
125
|
+
carried = previous.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
|
|
126
|
+
|
|
127
|
+
write_flow_index(carried.merge(delta))
|
|
128
|
+
|
|
129
|
+
annotations
|
|
130
|
+
end
|
|
131
|
+
|
|
65
132
|
private
|
|
66
133
|
|
|
67
134
|
# Filter units to only controllers.
|
|
@@ -71,33 +138,119 @@ module Woods
|
|
|
71
138
|
@units.select { |u| u.type.to_s == 'controller' }
|
|
72
139
|
end
|
|
73
140
|
|
|
141
|
+
# Assemble and write every flow for one controller unit.
|
|
142
|
+
#
|
|
143
|
+
# Both entry points fail closed: a per-action failure aborts the run
|
|
144
|
+
# rather than publishing a flow index missing that entry (EXTB-14 — the
|
|
145
|
+
# old `fail_closed:` switch documented a log-and-skip contract for the
|
|
146
|
+
# full path that no caller ever asked for).
|
|
147
|
+
#
|
|
148
|
+
# @param assembler [FlowAssembler]
|
|
149
|
+
# @param unit [ExtractedUnit]
|
|
150
|
+
# @return [Array(Hash{String => String}, Hash{String => String})] the
|
|
151
|
+
# entry_point => relative-path entries for the flow index, and the
|
|
152
|
+
# action => relative-path annotation for the unit's metadata
|
|
153
|
+
# @raise [Woods::ExtractionError] when an action fails to assemble
|
|
154
|
+
def assemble_controller_unit(assembler, unit)
|
|
155
|
+
entries = {}
|
|
156
|
+
unit_flow_paths = {}
|
|
157
|
+
|
|
158
|
+
actions = unit.metadata[:actions] || unit.metadata['actions'] || []
|
|
159
|
+
actions.each do |action|
|
|
160
|
+
entry_point = "#{unit.identifier}##{action}"
|
|
161
|
+
flow_path = assemble_and_write(assembler, entry_point, unit.identifier, action)
|
|
162
|
+
|
|
163
|
+
entries[entry_point] = flow_path
|
|
164
|
+
unit_flow_paths[action] = flow_path
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
[entries, unit_flow_paths]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# One controller's annotation, rebuilt from the index entries it keeps.
|
|
171
|
+
#
|
|
172
|
+
# @param previous [Hash{String => String}] the previous flow index
|
|
173
|
+
# @param identifier [String] controller identifier
|
|
174
|
+
# @return [Hash{String => String}] action to relative flow path
|
|
175
|
+
def carried_annotation(previous, identifier)
|
|
176
|
+
previous.each_with_object({}) do |(entry_point, path), annotation|
|
|
177
|
+
controller, action = entry_point.to_s.split('#', 2)
|
|
178
|
+
annotation[action] = path if action && controller == identifier
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The controller part of a flow index entry point.
|
|
183
|
+
#
|
|
184
|
+
# @param entry_point [String]
|
|
185
|
+
# @return [String]
|
|
186
|
+
def controller_of(entry_point)
|
|
187
|
+
entry_point.to_s.split('#', 2).first
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The previous generation's flow index, or an empty hash when none was
|
|
191
|
+
# published. A missing index means there is nothing to carry forward
|
|
192
|
+
# (the caller established whether the family is genuinely absent); a
|
|
193
|
+
# corrupt one raises — publication must not advance over unreadable
|
|
194
|
+
# prior flow state.
|
|
195
|
+
#
|
|
196
|
+
# @return [Hash{String => String}]
|
|
197
|
+
# @raise [Woods::ExtractionError] when the index does not parse
|
|
198
|
+
def previous_flow_index
|
|
199
|
+
path = File.join(@flows_dir, 'flow_index.json')
|
|
200
|
+
return {} unless File.exist?(path)
|
|
201
|
+
|
|
202
|
+
JSON.parse(Woods::AtomicFile.read(path))
|
|
203
|
+
rescue JSON::ParserError => e
|
|
204
|
+
raise Woods::ExtractionError, "previous flow_index.json does not parse: #{e.message}"
|
|
205
|
+
end
|
|
206
|
+
|
|
74
207
|
# Assemble a flow for one entry point and write the JSON file.
|
|
75
208
|
#
|
|
209
|
+
# Written via {Woods::AtomicFile} (temp + rename) like every other index
|
|
210
|
+
# artifact. A plain +File.write+ interrupted mid-write left a torn
|
|
211
|
+
# partial for the MCP read side to trip over. Not durable by default: see
|
|
212
|
+
# {#durable_writes?}.
|
|
213
|
+
#
|
|
76
214
|
# @param assembler [FlowAssembler]
|
|
77
215
|
# @param entry_point [String]
|
|
78
216
|
# @param controller_id [String]
|
|
79
217
|
# @param action [String]
|
|
80
|
-
# @return [String
|
|
218
|
+
# @return [String] The output_dir-relative path of the written file
|
|
219
|
+
# (e.g. "flows/OrdersController_create.json")
|
|
220
|
+
# @raise [Woods::ExtractionError] when assembly or the write fails
|
|
81
221
|
def assemble_and_write(assembler, entry_point, controller_id, action)
|
|
82
222
|
flow = assembler.assemble(entry_point, max_depth: @max_depth)
|
|
83
223
|
|
|
84
224
|
filename = Woods::FilenameUtils.flow_filename(controller_id, action)
|
|
85
|
-
flow_path = File.join(@flows_dir, filename)
|
|
86
225
|
|
|
87
|
-
|
|
226
|
+
Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h), durable: durable_writes?)
|
|
88
227
|
|
|
89
|
-
|
|
228
|
+
# Relative — this value is persisted (flow_index.json and the unit's
|
|
229
|
+
# metadata[:flow_paths]), so it must not carry this machine's root.
|
|
230
|
+
File.join('flows', filename)
|
|
90
231
|
rescue StandardError => e
|
|
91
|
-
|
|
92
|
-
nil
|
|
232
|
+
raise Woods::ExtractionError, "flow precompute failed for #{entry_point}: #{e.message}"
|
|
93
233
|
end
|
|
94
234
|
|
|
95
|
-
# Write the flow index mapping entry points to file paths.
|
|
235
|
+
# Write the flow index mapping entry points to output_dir-relative file paths.
|
|
96
236
|
#
|
|
97
237
|
# @param flow_map [Hash{String => String}]
|
|
98
238
|
def write_flow_index(flow_map)
|
|
99
239
|
index_path = File.join(@flows_dir, 'flow_index.json')
|
|
100
|
-
|
|
240
|
+
Woods::AtomicFile.write(index_path, canonical_json(flow_map), durable: durable_writes?)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Whether a flow document pays for its own fsync as it is written.
|
|
244
|
+
#
|
|
245
|
+
# False by default. A flow document is a payload file: nothing reads it
|
|
246
|
+
# until +generation.json+ names the payload, and
|
|
247
|
+
# {Woods::AtomicFile.sync_directory_tree} flushes the whole payload before
|
|
248
|
+
# that pointer is written. `durable_payload_writes = true` buys the
|
|
249
|
+
# per-file fsync back for a host that wants it.
|
|
250
|
+
#
|
|
251
|
+
# @return [Boolean]
|
|
252
|
+
def durable_writes?
|
|
253
|
+
Woods.configuration&.durable_payload_writes ? true : false
|
|
101
254
|
end
|
|
102
255
|
|
|
103
256
|
# Emit deterministic pretty JSON — keys recursively sorted so two runs
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'pathname'
|
|
7
|
+
require 'time'
|
|
8
|
+
|
|
9
|
+
require_relative 'atomic_file'
|
|
10
|
+
require_relative 'coordination/pipeline_lock'
|
|
11
|
+
require_relative 'dependency_graph'
|
|
12
|
+
require_relative 'extracted_unit'
|
|
13
|
+
require_relative 'filename_utils'
|
|
14
|
+
require_relative 'generation'
|
|
15
|
+
require_relative 'graph_analyzer'
|
|
16
|
+
require_relative 'payload_store'
|
|
17
|
+
require_relative 'ruby_analyzer'
|
|
18
|
+
|
|
19
|
+
module Woods
|
|
20
|
+
# Publishes a static, source-only index of the Woods implementation.
|
|
21
|
+
class GemMapper
|
|
22
|
+
include FilenameUtils
|
|
23
|
+
|
|
24
|
+
SOURCE_GLOBS = %w[lib/woods.rb lib/woods/**/*.rb lib/tasks/**/*.rake exe/*].freeze
|
|
25
|
+
TYPE_DIRECTORIES = {
|
|
26
|
+
ruby_class: 'ruby_classes', ruby_module: 'ruby_modules',
|
|
27
|
+
ruby_method: 'ruby_methods', ruby_file: 'ruby_files'
|
|
28
|
+
}.freeze
|
|
29
|
+
PROVENANCE = {
|
|
30
|
+
mode: 'woods_static_ruby_source', runtime_fidelity: 'none', embeddings: 'absent',
|
|
31
|
+
included: SOURCE_GLOBS, excluded: %w[spec docs .git tmp vendor]
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(root:, output_dir:)
|
|
35
|
+
@root = Pathname.new(root).expand_path
|
|
36
|
+
@output_dir = Pathname.new(output_dir).expand_path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Publishes one complete source snapshot, or skips when it is unchanged.
|
|
40
|
+
def map!
|
|
41
|
+
with_lock do
|
|
42
|
+
snapshot = source_snapshot
|
|
43
|
+
return { status: :skipped, generation: current_generation.number } if current_checksum == snapshot[:checksum]
|
|
44
|
+
|
|
45
|
+
units, graph = GemMapSource.new(root: @root).build(snapshot[:sources])
|
|
46
|
+
payload = create_payload
|
|
47
|
+
GemMapPublisher.new(payload: payload, units: units, graph: graph, checksum: snapshot[:checksum]).write
|
|
48
|
+
ensure_snapshot_unchanged!(snapshot[:checksum])
|
|
49
|
+
marker = publish_generation
|
|
50
|
+
{ status: :published, generation: marker.number, units: units.size }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def with_lock(&block)
|
|
57
|
+
Coordination::PipelineLock.new(lock_dir: @output_dir.to_s, name: 'woods_self_map').with_lock(&block)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def source_files
|
|
61
|
+
SOURCE_GLOBS.flat_map { |glob| Dir.glob(@root.join(glob).to_s) }
|
|
62
|
+
.select { |path| File.file?(path) }.uniq.sort
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def source_snapshot
|
|
66
|
+
sources = source_files.to_h { |path| [path, read_source(path)] }
|
|
67
|
+
{ sources: sources, checksum: checksum_for(sources) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def checksum_for(sources)
|
|
71
|
+
digest = Digest::SHA256.new
|
|
72
|
+
sources.sort.each { |path, source| digest << relative_path(path) << "\0" << source << "\0" }
|
|
73
|
+
digest.hexdigest
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_source(path)
|
|
77
|
+
source = File.read(path, encoding: Encoding::UTF_8)
|
|
78
|
+
source.valid_encoding? ? source : source.scrub
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_snapshot_unchanged!(checksum)
|
|
82
|
+
return if source_snapshot[:checksum] == checksum
|
|
83
|
+
|
|
84
|
+
raise Woods::ExtractionError, 'Woods source changed during self-map; refusing to publish a mixed snapshot'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def current_generation
|
|
88
|
+
Generation.new(output_dir: @output_dir).current
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def next_generation
|
|
92
|
+
current_generation.number + 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def current_checksum
|
|
96
|
+
payload = Generation.new(output_dir: @output_dir).payload_dir(current_generation)
|
|
97
|
+
manifest = payload.join('manifest.json')
|
|
98
|
+
return nil unless manifest.file?
|
|
99
|
+
|
|
100
|
+
JSON.parse(AtomicFile.read(manifest)).dig('provenance', 'source_checksum')
|
|
101
|
+
rescue JSON::ParserError, SystemCallError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def relative_path(path)
|
|
106
|
+
Pathname.new(path).expand_path.relative_path_from(@root).to_s
|
|
107
|
+
rescue ArgumentError
|
|
108
|
+
raise Woods::ExtractionError, "Source path escapes gem root: #{path}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def create_payload
|
|
112
|
+
PayloadStore.new(@output_dir).create(next_generation)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def publish_generation
|
|
116
|
+
marker = Generation.new(output_dir: @output_dir).bump!(
|
|
117
|
+
reason: 'woods_static_source_map', payload: PayloadStore.name_for(next_generation)
|
|
118
|
+
)
|
|
119
|
+
PayloadStore.new(@output_dir).prune(keep: PayloadStore::DEFAULT_RETENTION, protect: marker.number)
|
|
120
|
+
marker
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Turns a pre-read Woods source snapshot into typed static analysis units.
|
|
125
|
+
class GemMapSource
|
|
126
|
+
def initialize(root:)
|
|
127
|
+
@root = root
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build(sources)
|
|
131
|
+
units = build_units(sources)
|
|
132
|
+
graph = DependencyGraph.new
|
|
133
|
+
units.each { |unit| graph.register(unit) }
|
|
134
|
+
[units, graph]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def build_units(sources)
|
|
140
|
+
analyzable = sources.select { |path, _| path.end_with?('.rb', '.rake') || executable?(path) }
|
|
141
|
+
units = RubyAnalyzer.analyze(sources: analyzable)
|
|
142
|
+
units.each { |unit| unit.file_path = relative_path(unit.file_path) }
|
|
143
|
+
units.concat(file_units(sources))
|
|
144
|
+
disambiguate!(units)
|
|
145
|
+
qualify_dependencies(units)
|
|
146
|
+
resolve_dependents(units)
|
|
147
|
+
units.sort_by { |unit| [unit.identifier, unit.type.to_s, unit.file_path] }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def executable?(path)
|
|
151
|
+
path.start_with?(@root.join('exe').to_s + File::SEPARATOR)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def file_units(sources)
|
|
155
|
+
sources.map do |path, source|
|
|
156
|
+
relative = relative_path(path)
|
|
157
|
+
unit = ExtractedUnit.new(type: :ruby_file, identifier: "file:#{relative}", file_path: relative)
|
|
158
|
+
unit.source_code = source
|
|
159
|
+
unit.metadata = { static_kind: 'ruby_file', executable: executable?(path) }
|
|
160
|
+
unit
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def disambiguate!(units)
|
|
165
|
+
units.group_by(&:identifier).each do |canonical, colliding|
|
|
166
|
+
colliding.sort_by! { |unit| [unit.file_path, unit.type.to_s] }
|
|
167
|
+
colliding.each_with_index do |unit, index|
|
|
168
|
+
unit.metadata[:canonical_identifier] = canonical
|
|
169
|
+
next if index.zero?
|
|
170
|
+
|
|
171
|
+
unit.identifier = "#{canonical} [#{unit.file_path}]"
|
|
172
|
+
unit.dependencies << { type: unit.type, target: canonical, via: :definition_of }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Resolve only known lexical definitions, retaining external/dynamic names as written.
|
|
178
|
+
def qualify_dependencies(units)
|
|
179
|
+
definitions = units.select { |unit| %i[ruby_class ruby_module].include?(unit.type) }.to_h do |unit|
|
|
180
|
+
[unit.metadata[:canonical_identifier] || unit.identifier, unit]
|
|
181
|
+
end
|
|
182
|
+
units.each do |unit|
|
|
183
|
+
unit.dependencies.each { |dependency| qualify_dependency(unit, dependency, definitions) }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def qualify_dependency(unit, dependency, definitions)
|
|
188
|
+
target = dependency[:target]
|
|
189
|
+
return unless target.is_a?(String)
|
|
190
|
+
|
|
191
|
+
scopes = Array(unit.metadata[:lexical_scopes])
|
|
192
|
+
scopes = scopes.drop(1) if dependency[:via] == :inheritance
|
|
193
|
+
candidates = if target.start_with?('::')
|
|
194
|
+
[target.delete_prefix('::')]
|
|
195
|
+
else
|
|
196
|
+
scopes.map { |scope| "#{scope}::#{target}" } + [target]
|
|
197
|
+
end
|
|
198
|
+
resolved = candidates.find { |candidate| definitions.key?(candidate) }
|
|
199
|
+
return unless resolved
|
|
200
|
+
|
|
201
|
+
dependency[:target] = resolved
|
|
202
|
+
dependency[:type] = definitions[resolved].type
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def resolve_dependents(units)
|
|
206
|
+
by_identifier = units.group_by(&:identifier)
|
|
207
|
+
units.each do |unit|
|
|
208
|
+
unit.dependencies.each do |dependency|
|
|
209
|
+
Array(by_identifier[dependency[:target]]).each do |target|
|
|
210
|
+
target.dependents << { type: unit.type, identifier: unit.identifier }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def relative_path(path)
|
|
217
|
+
Pathname.new(path).expand_path.relative_path_from(@root).to_s
|
|
218
|
+
rescue ArgumentError
|
|
219
|
+
raise Woods::ExtractionError, "Source path escapes gem root: #{path}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Writes a complete source-map payload before GemMapper atomically publishes it.
|
|
224
|
+
class GemMapPublisher
|
|
225
|
+
include FilenameUtils
|
|
226
|
+
|
|
227
|
+
def initialize(payload:, units:, graph:, checksum:)
|
|
228
|
+
@payload = payload
|
|
229
|
+
@units = units
|
|
230
|
+
@graph = graph
|
|
231
|
+
@checksum = checksum
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def write
|
|
235
|
+
GemMapper::TYPE_DIRECTORIES.each do |type, directory|
|
|
236
|
+
write_type(@payload.join(directory), @units.select { |unit| unit.type == type })
|
|
237
|
+
end
|
|
238
|
+
write_graph
|
|
239
|
+
write_manifest
|
|
240
|
+
write_summary
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
def write_type(directory, units)
|
|
246
|
+
FileUtils.mkdir_p(directory)
|
|
247
|
+
units.each do |unit|
|
|
248
|
+
AtomicFile.write(directory.join(collision_safe_filename(unit.identifier)), JSON.pretty_generate(unit.to_h))
|
|
249
|
+
end
|
|
250
|
+
index = units.map do |unit|
|
|
251
|
+
{ identifier: unit.identifier, file_path: unit.file_path, namespace: unit.namespace,
|
|
252
|
+
estimated_tokens: unit.estimated_tokens, chunk_count: unit.chunks.size }
|
|
253
|
+
end
|
|
254
|
+
AtomicFile.write(directory.join('_index.json'), JSON.pretty_generate(index))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def write_graph
|
|
258
|
+
data = @graph.to_h
|
|
259
|
+
data[:pagerank] = @graph.pagerank
|
|
260
|
+
AtomicFile.write(@payload.join('dependency_graph.json'), JSON.pretty_generate(data))
|
|
261
|
+
AtomicFile.write(@payload.join('graph_analysis.json'), JSON.pretty_generate(GraphAnalyzer.new(@graph).analyze))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def write_manifest
|
|
265
|
+
counts = GemMapper::TYPE_DIRECTORIES.keys.to_h do |type|
|
|
266
|
+
[GemMapper::TYPE_DIRECTORIES[type], @units.count { |unit| unit.type == type }]
|
|
267
|
+
end
|
|
268
|
+
manifest = {
|
|
269
|
+
extracted_at: Time.now.utc.iso8601, rails_version: nil, ruby_version: RUBY_VERSION,
|
|
270
|
+
counts: counts, total_units: @units.size, total_chunks: 0, git_sha: nil, git_branch: nil,
|
|
271
|
+
provenance: GemMapper::PROVENANCE.merge(source_checksum: @checksum),
|
|
272
|
+
graph_nodes: @graph.to_h.dig(:stats, :node_count), graph_edges: @graph.to_h.dig(:stats, :edge_count)
|
|
273
|
+
}
|
|
274
|
+
AtomicFile.write(@payload.join('manifest.json'), JSON.pretty_generate(manifest))
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def write_summary
|
|
278
|
+
stats = @graph.to_h.fetch(:stats)
|
|
279
|
+
AtomicFile.write(@payload.join('SUMMARY.md'), [
|
|
280
|
+
'# Woods static Ruby source map', '', "Units: #{@units.size}", "Graph nodes: #{stats[:node_count]}",
|
|
281
|
+
"Graph edges: #{stats[:edge_count]}", 'Static source analysis only; not a Rails runtime extraction.'
|
|
282
|
+
].join("\n"))
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|