woods 1.6.0 → 2.0.0.beta1
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 +1908 -0
- data/CONTRIBUTING.md +202 -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 +620 -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 +415 -0
- data/docs/INTERNALS.md +415 -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 +197 -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 +40 -1
- 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 +2509 -189
- 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 +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -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 +408 -34
- 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 +223 -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.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 +53 -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,53 @@ 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
|
+
# @return [Hash{String => Hash{String => String}}] per-controller
|
|
93
|
+
# annotation (action => relative flow path) to write into the units'
|
|
94
|
+
# metadata; an empty hash means "no flows", which clears any
|
|
95
|
+
# annotation a previous run had written
|
|
96
|
+
def recompute_delta(touched_units:, removed_identifiers: [])
|
|
97
|
+
FileUtils.mkdir_p(@flows_dir)
|
|
98
|
+
|
|
99
|
+
assembler = FlowAssembler.new(graph: @graph, extracted_dir: @output_dir)
|
|
100
|
+
delta = {}
|
|
101
|
+
annotations = {}
|
|
102
|
+
|
|
103
|
+
touched_units.each do |unit|
|
|
104
|
+
# A per-action skip would publish an index missing that entry, so
|
|
105
|
+
# assembly raises. The incremental path must not advance publication
|
|
106
|
+
# over a broken assembly; the caller aborts before the generation
|
|
107
|
+
# bump.
|
|
108
|
+
entries, unit_flow_paths = assemble_controller_unit(assembler, unit)
|
|
109
|
+
annotations[unit.identifier] = unit_flow_paths
|
|
110
|
+
delta.merge!(entries)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
replaced = touched_units.map(&:identifier) + Array(removed_identifiers)
|
|
114
|
+
carried = previous_flow_index.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
|
|
115
|
+
|
|
116
|
+
write_flow_index(carried.merge(delta))
|
|
117
|
+
|
|
118
|
+
annotations
|
|
119
|
+
end
|
|
120
|
+
|
|
65
121
|
private
|
|
66
122
|
|
|
67
123
|
# Filter units to only controllers.
|
|
@@ -71,33 +127,93 @@ module Woods
|
|
|
71
127
|
@units.select { |u| u.type.to_s == 'controller' }
|
|
72
128
|
end
|
|
73
129
|
|
|
130
|
+
# Assemble and write every flow for one controller unit.
|
|
131
|
+
#
|
|
132
|
+
# Both entry points fail closed: a per-action failure aborts the run
|
|
133
|
+
# rather than publishing a flow index missing that entry (EXTB-14 — the
|
|
134
|
+
# old `fail_closed:` switch documented a log-and-skip contract for the
|
|
135
|
+
# full path that no caller ever asked for).
|
|
136
|
+
#
|
|
137
|
+
# @param assembler [FlowAssembler]
|
|
138
|
+
# @param unit [ExtractedUnit]
|
|
139
|
+
# @return [Array(Hash{String => String}, Hash{String => String})] the
|
|
140
|
+
# entry_point => relative-path entries for the flow index, and the
|
|
141
|
+
# action => relative-path annotation for the unit's metadata
|
|
142
|
+
# @raise [Woods::ExtractionError] when an action fails to assemble
|
|
143
|
+
def assemble_controller_unit(assembler, unit)
|
|
144
|
+
entries = {}
|
|
145
|
+
unit_flow_paths = {}
|
|
146
|
+
|
|
147
|
+
actions = unit.metadata[:actions] || unit.metadata['actions'] || []
|
|
148
|
+
actions.each do |action|
|
|
149
|
+
entry_point = "#{unit.identifier}##{action}"
|
|
150
|
+
flow_path = assemble_and_write(assembler, entry_point, unit.identifier, action)
|
|
151
|
+
|
|
152
|
+
entries[entry_point] = flow_path
|
|
153
|
+
unit_flow_paths[action] = flow_path
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
[entries, unit_flow_paths]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The controller part of a flow index entry point.
|
|
160
|
+
#
|
|
161
|
+
# @param entry_point [String]
|
|
162
|
+
# @return [String]
|
|
163
|
+
def controller_of(entry_point)
|
|
164
|
+
entry_point.to_s.split('#', 2).first
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The previous generation's flow index, or an empty hash when none was
|
|
168
|
+
# published. A missing index means there is nothing to carry forward
|
|
169
|
+
# (the caller established whether the family is genuinely absent); a
|
|
170
|
+
# corrupt one raises — publication must not advance over unreadable
|
|
171
|
+
# prior flow state.
|
|
172
|
+
#
|
|
173
|
+
# @return [Hash{String => String}]
|
|
174
|
+
# @raise [Woods::ExtractionError] when the index does not parse
|
|
175
|
+
def previous_flow_index
|
|
176
|
+
path = File.join(@flows_dir, 'flow_index.json')
|
|
177
|
+
return {} unless File.exist?(path)
|
|
178
|
+
|
|
179
|
+
JSON.parse(Woods::AtomicFile.read(path))
|
|
180
|
+
rescue JSON::ParserError => e
|
|
181
|
+
raise Woods::ExtractionError, "previous flow_index.json does not parse: #{e.message}"
|
|
182
|
+
end
|
|
183
|
+
|
|
74
184
|
# Assemble a flow for one entry point and write the JSON file.
|
|
75
185
|
#
|
|
186
|
+
# Written via {Woods::AtomicFile} (temp + fsync + rename) like every
|
|
187
|
+
# other index artifact — a plain +File.write+ interrupted mid-write left
|
|
188
|
+
# a torn partial for the MCP read side to trip over.
|
|
189
|
+
#
|
|
76
190
|
# @param assembler [FlowAssembler]
|
|
77
191
|
# @param entry_point [String]
|
|
78
192
|
# @param controller_id [String]
|
|
79
193
|
# @param action [String]
|
|
80
|
-
# @return [String
|
|
194
|
+
# @return [String] The output_dir-relative path of the written file
|
|
195
|
+
# (e.g. "flows/OrdersController_create.json")
|
|
196
|
+
# @raise [Woods::ExtractionError] when assembly or the write fails
|
|
81
197
|
def assemble_and_write(assembler, entry_point, controller_id, action)
|
|
82
198
|
flow = assembler.assemble(entry_point, max_depth: @max_depth)
|
|
83
199
|
|
|
84
200
|
filename = Woods::FilenameUtils.flow_filename(controller_id, action)
|
|
85
|
-
flow_path = File.join(@flows_dir, filename)
|
|
86
201
|
|
|
87
|
-
|
|
202
|
+
Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h))
|
|
88
203
|
|
|
89
|
-
|
|
204
|
+
# Relative — this value is persisted (flow_index.json and the unit's
|
|
205
|
+
# metadata[:flow_paths]), so it must not carry this machine's root.
|
|
206
|
+
File.join('flows', filename)
|
|
90
207
|
rescue StandardError => e
|
|
91
|
-
|
|
92
|
-
nil
|
|
208
|
+
raise Woods::ExtractionError, "flow precompute failed for #{entry_point}: #{e.message}"
|
|
93
209
|
end
|
|
94
210
|
|
|
95
|
-
# Write the flow index mapping entry points to file paths.
|
|
211
|
+
# Write the flow index mapping entry points to output_dir-relative file paths.
|
|
96
212
|
#
|
|
97
213
|
# @param flow_map [Hash{String => String}]
|
|
98
214
|
def write_flow_index(flow_map)
|
|
99
215
|
index_path = File.join(@flows_dir, 'flow_index.json')
|
|
100
|
-
|
|
216
|
+
Woods::AtomicFile.write(index_path, canonical_json(flow_map))
|
|
101
217
|
end
|
|
102
218
|
|
|
103
219
|
# 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
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'digest'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
require_relative 'atomic_file'
|
|
9
|
+
|
|
10
|
+
module Woods
|
|
11
|
+
# A monotonic marker for "which version of the index is on disk".
|
|
12
|
+
#
|
|
13
|
+
# The index is a directory of files, so a reader has no cheap way to ask
|
|
14
|
+
# "has this changed since I cached it?" — it would have to stat everything.
|
|
15
|
+
# This is that cheap way: one small file, bumped as the *last* step of any
|
|
16
|
+
# write, holding a counter that only ever increases plus a token that
|
|
17
|
+
# changes whenever the counter does.
|
|
18
|
+
#
|
|
19
|
+
# Two properties matter, and both come from #164's central rule — never
|
|
20
|
+
# advance a cursor over work that didn't land:
|
|
21
|
+
#
|
|
22
|
+
# * **Bumped last.** Callers write the index, then bump. A reader that sees
|
|
23
|
+
# generation N therefore knows the files for N are already there.
|
|
24
|
+
# * **Not bumped on failure.** A run that raised, or that changed nothing,
|
|
25
|
+
# leaves the generation alone. Staleness is then honest: the reader keeps
|
|
26
|
+
# serving N and knows it.
|
|
27
|
+
#
|
|
28
|
+
# The write itself is atomic ({AtomicFile}), so a reader never sees a torn
|
|
29
|
+
# generation file. It can also carry a **payload pointer** — the name of an
|
|
30
|
+
# immutable per-generation directory the payload was published into. When
|
|
31
|
+
# present, the single atomic write of this file is the one commit point for
|
|
32
|
+
# the whole payload: a reader resolves every artifact of a read through the
|
|
33
|
+
# directory this generation names, so it sees one generation whole rather
|
|
34
|
+
# than a mix of a manifest from N+1 next to a unit from N. The pointer is
|
|
35
|
+
# optional — an index written flat (payload files directly under the output
|
|
36
|
+
# directory) carries none, and readers fall back to the output directory.
|
|
37
|
+
#
|
|
38
|
+
# @example Publishing after a successful write
|
|
39
|
+
# Woods::Generation.new(output_dir: "tmp/woods").bump!(reason: "incremental")
|
|
40
|
+
# # => #<Generation::Marker number=42 token="9f2c…" reason="incremental">
|
|
41
|
+
#
|
|
42
|
+
class Generation
|
|
43
|
+
FILENAME = 'generation.json'
|
|
44
|
+
|
|
45
|
+
# An immutable snapshot of the generation file.
|
|
46
|
+
#
|
|
47
|
+
# @!attribute number
|
|
48
|
+
# @return [Integer] monotonically increasing, starting at 1
|
|
49
|
+
# @!attribute token
|
|
50
|
+
# @return [String] opaque; changes on every bump
|
|
51
|
+
# @!attribute updated_at
|
|
52
|
+
# @return [String, nil] ISO8601 stamp of the bump
|
|
53
|
+
# @!attribute reason
|
|
54
|
+
# @return [String, nil] what produced this generation
|
|
55
|
+
# @!attribute payload
|
|
56
|
+
# @return [String, nil] name of the immutable directory this generation's
|
|
57
|
+
# payload lives in, relative to the output directory. nil for an index
|
|
58
|
+
# written flat (payload files directly under the output directory).
|
|
59
|
+
Marker = Struct.new(:number, :token, :updated_at, :reason, :payload, keyword_init: true) do
|
|
60
|
+
# @return [Hash] string-keyed, ready to serialize. The +payload+ key is
|
|
61
|
+
# emitted only when set, so a flat index's generation file is byte-for-
|
|
62
|
+
# byte what it always was.
|
|
63
|
+
def to_h
|
|
64
|
+
base = { 'number' => number, 'token' => token, 'updated_at' => updated_at, 'reason' => reason }
|
|
65
|
+
base['payload'] = payload if payload
|
|
66
|
+
base
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Marker returned when no generation file exists yet — an index written
|
|
71
|
+
# before generations existed, or a directory nothing has published to.
|
|
72
|
+
UNPUBLISHED = Marker.new(number: 0, token: nil, updated_at: nil, reason: nil).freeze
|
|
73
|
+
|
|
74
|
+
# @param output_dir [String, Pathname] index directory
|
|
75
|
+
# @param clock [#call] returns the ISO8601 stamp for a bump; injectable
|
|
76
|
+
# so specs don't depend on wall-clock time
|
|
77
|
+
def initialize(output_dir:, clock: -> { Time.now.utc.iso8601 })
|
|
78
|
+
@path = File.join(output_dir.to_s, FILENAME)
|
|
79
|
+
@clock = clock
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String] absolute path to the generation file
|
|
83
|
+
attr_reader :path
|
|
84
|
+
|
|
85
|
+
# The generation currently published.
|
|
86
|
+
#
|
|
87
|
+
# A missing or unreadable file reads as {UNPUBLISHED} rather than raising:
|
|
88
|
+
# a reader asking "what generation is this?" of a directory that has none
|
|
89
|
+
# wants an answer, not an exception.
|
|
90
|
+
#
|
|
91
|
+
# @return [Marker]
|
|
92
|
+
def current
|
|
93
|
+
return UNPUBLISHED unless File.exist?(@path)
|
|
94
|
+
|
|
95
|
+
data = JSON.parse(AtomicFile.read(@path))
|
|
96
|
+
Marker.new(
|
|
97
|
+
number: data['number'].to_i,
|
|
98
|
+
token: data['token'],
|
|
99
|
+
updated_at: data['updated_at'],
|
|
100
|
+
reason: data['reason'],
|
|
101
|
+
payload: data['payload']
|
|
102
|
+
)
|
|
103
|
+
rescue JSON::ParserError, SystemCallError
|
|
104
|
+
UNPUBLISHED
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Advance to the next generation.
|
|
108
|
+
#
|
|
109
|
+
# Call this *after* the index files are written, never before, and never
|
|
110
|
+
# after a run that failed or wrote nothing.
|
|
111
|
+
#
|
|
112
|
+
# @param reason [String, nil] what produced this generation, for humans
|
|
113
|
+
# reading `woods_status`
|
|
114
|
+
# @param payload [String, nil] name of the immutable directory this
|
|
115
|
+
# generation's payload was published into, relative to the output
|
|
116
|
+
# directory. Omit for a flat index; when given, this bump becomes the
|
|
117
|
+
# single commit point for that whole payload (see the class docs).
|
|
118
|
+
# @return [Marker] the newly published generation
|
|
119
|
+
def bump!(reason: nil, payload: nil)
|
|
120
|
+
marker = Marker.new(
|
|
121
|
+
number: current.number + 1,
|
|
122
|
+
token: SecureRandom.hex(8),
|
|
123
|
+
updated_at: @clock.call,
|
|
124
|
+
reason: reason,
|
|
125
|
+
payload: payload
|
|
126
|
+
)
|
|
127
|
+
AtomicFile.write(@path, JSON.generate(marker.to_h))
|
|
128
|
+
marker
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The directory a generation's payload artifacts live in.
|
|
132
|
+
#
|
|
133
|
+
# Returns the index root when there is no pointer (an index written flat,
|
|
134
|
+
# which is every index written before payloads existed), when the named
|
|
135
|
+
# directory is gone (a stale pointer — a payload retention pruned), or
|
|
136
|
+
# when the name would escape the index root. A torn or tampered pointer
|
|
137
|
+
# must degrade to the flat layout, never read outside the index; the name
|
|
138
|
+
# is written by Woods and is relative by contract.
|
|
139
|
+
#
|
|
140
|
+
# Every reader of a payload artifact resolves through this, so the pointer
|
|
141
|
+
# is followed identically wherever it is read.
|
|
142
|
+
#
|
|
143
|
+
# @param marker [Marker] the generation to resolve; defaults to the
|
|
144
|
+
# published one. Pass an already-loaded marker to avoid re-reading, and
|
|
145
|
+
# to resolve a generation a caller has pinned rather than the current one.
|
|
146
|
+
# @return [Pathname]
|
|
147
|
+
def payload_dir(marker = current)
|
|
148
|
+
name = marker.payload
|
|
149
|
+
return root if name.nil? || name.empty?
|
|
150
|
+
|
|
151
|
+
candidate = root.join(name)
|
|
152
|
+
return root unless candidate.directory?
|
|
153
|
+
return root unless within_root?(candidate)
|
|
154
|
+
|
|
155
|
+
candidate
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Pathname] the index directory this generation lives in
|
|
159
|
+
def root
|
|
160
|
+
@root ||= Pathname.new(File.dirname(@path))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Does +candidate+ resolve inside the index root?
|
|
166
|
+
#
|
|
167
|
+
# Compared through +realpath+, mirroring
|
|
168
|
+
# {Woods::IndexArtifact#validate_dump_dir!} (B-134). +expand_path+ is
|
|
169
|
+
# textual, so a symlink planted *inside* +payloads/+ and pointing outside
|
|
170
|
+
# the index passed the check while +Pathname#directory?+ happily followed
|
|
171
|
+
# it — aiming every payload reader at an arbitrary directory (CORE-6). An
|
|
172
|
+
# index reached through a symlinked root still resolves, because both
|
|
173
|
+
# sides go through realpath.
|
|
174
|
+
#
|
|
175
|
+
# @param candidate [Pathname] an existing directory
|
|
176
|
+
# @return [Boolean] false when it cannot be resolved at all
|
|
177
|
+
def within_root?(candidate)
|
|
178
|
+
candidate_real = candidate.realpath.to_s
|
|
179
|
+
root_real = root.realpath.to_s
|
|
180
|
+
candidate_real.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
181
|
+
rescue SystemCallError
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|