woods 1.6.1 → 2.0.0.beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
# Resolves a changed file path to the extraction work it implies.
|
|
7
|
+
#
|
|
8
|
+
# The incremental path used to route changes purely through
|
|
9
|
+
# {DependencyGraph#affected_by}, which resolves a path via the graph's
|
|
10
|
+
# file map — a map populated only from *already-registered* units. A file
|
|
11
|
+
# that did not exist at the last extraction therefore routed nowhere and
|
|
12
|
+
# was silently ignored (#164, gap 1). This class supplies the missing
|
|
13
|
+
# direction: path → extractor, for files the index has never seen.
|
|
14
|
+
#
|
|
15
|
+
# Two rule sets:
|
|
16
|
+
#
|
|
17
|
+
# * {.file_rules} — file-based extractors, whose per-file method can be
|
|
18
|
+
# pointed straight at the new path.
|
|
19
|
+
# * {.whole_app_rules} — extractors with no per-file entry point (routes,
|
|
20
|
+
# middleware, engines, scheduled jobs, state machines, factories,
|
|
21
|
+
# events, framework sources). Their trigger paths map to a wholesale
|
|
22
|
+
# re-run of that extractor, which is cheap in an already-booted process.
|
|
23
|
+
#
|
|
24
|
+
# Class-based extractors (models, controllers, mailers, components,
|
|
25
|
+
# channels) are deliberately *absent* here. They are reconciled against
|
|
26
|
+
# their own runtime discovery sets instead (see
|
|
27
|
+
# {Extractor#reconcile_class_based_types}), which is exact by construction
|
|
28
|
+
# and needs no path-to-constant guessing.
|
|
29
|
+
#
|
|
30
|
+
# Rules are built lazily and memoized: they reference the directory
|
|
31
|
+
# constants owned by each extractor, so a directory added to (say)
|
|
32
|
+
# +ServiceExtractor::SERVICE_DIRECTORIES+ flows into dispatch without a
|
|
33
|
+
# second edit here.
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# Woods::PathDispatcher.new.file_rules_for('app/services/checkout.rb')
|
|
37
|
+
# # => [#<Rule extractor_key=:services method_name=:extract_service_file ...>]
|
|
38
|
+
#
|
|
39
|
+
class PathDispatcher # rubocop:disable Metrics/ClassLength
|
|
40
|
+
# A single path → extraction mapping.
|
|
41
|
+
#
|
|
42
|
+
# @!attribute extractor_key
|
|
43
|
+
# @return [Symbol] key into {Extractor::EXTRACTORS}
|
|
44
|
+
# @!attribute method_name
|
|
45
|
+
# @return [Symbol] per-file extraction method (file rules only)
|
|
46
|
+
# @!attribute dirs
|
|
47
|
+
# @return [Array<String>] Rails.root-relative directory prefixes
|
|
48
|
+
# @!attribute extensions
|
|
49
|
+
# @return [Array<String>, nil] required filename suffixes (nil = any)
|
|
50
|
+
# @!attribute exclude
|
|
51
|
+
# @return [Array<String>, nil] path substrings that disqualify a match
|
|
52
|
+
# @!attribute require_segment
|
|
53
|
+
# @return [String, nil] path substring that must be present
|
|
54
|
+
# @!attribute recursive
|
|
55
|
+
# @return [Boolean] false to match only files directly inside +dirs+
|
|
56
|
+
# @!attribute exact_paths
|
|
57
|
+
# @return [Array<String>, nil] exact relative paths that match
|
|
58
|
+
# @!attribute basenames
|
|
59
|
+
# @return [Array<String>, nil] file basenames that match anywhere under Rails.root (honors +exclude+)
|
|
60
|
+
Rule = Struct.new(
|
|
61
|
+
:extractor_key, :method_name, :dirs, :extensions, :exclude,
|
|
62
|
+
:require_segment, :recursive, :exact_paths, :basenames,
|
|
63
|
+
keyword_init: true
|
|
64
|
+
) do
|
|
65
|
+
# @param relative_path [String] Rails.root-relative path
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def matches?(relative_path)
|
|
68
|
+
return true if exact_paths&.include?(relative_path)
|
|
69
|
+
return basename_match?(relative_path) if basenames
|
|
70
|
+
|
|
71
|
+
filters_pass?(relative_path) && under_a_directory?(relative_path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def basename_match?(relative_path)
|
|
77
|
+
basenames.include?(File.basename(relative_path)) &&
|
|
78
|
+
exclude.to_a.none? { |segment| relative_path.include?(segment) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def filters_pass?(relative_path)
|
|
82
|
+
(extensions.nil? || extensions.any? { |ext| relative_path.end_with?(ext) }) &&
|
|
83
|
+
exclude.to_a.none? { |segment| relative_path.include?(segment) } &&
|
|
84
|
+
(require_segment.nil? || relative_path.include?(require_segment))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def under_a_directory?(relative_path)
|
|
88
|
+
dirs.to_a.any? do |dir|
|
|
89
|
+
next false unless relative_path.start_with?("#{dir}/")
|
|
90
|
+
|
|
91
|
+
recursive == false ? File.dirname(relative_path) == dir : true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
# Rules for extractors with a per-file entry point.
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<Rule>]
|
|
100
|
+
def file_rules
|
|
101
|
+
@file_rules ||= build_file_rules.freeze
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Rules for extractors that must be re-run wholesale.
|
|
105
|
+
#
|
|
106
|
+
# @return [Array<Rule>]
|
|
107
|
+
def whole_app_rules
|
|
108
|
+
@whole_app_rules ||= build_whole_app_rules.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Drop memoized rules. Used by specs that stub extractor constants.
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
114
|
+
def reset!
|
|
115
|
+
@file_rules = nil
|
|
116
|
+
@whole_app_rules = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def build_file_rules
|
|
122
|
+
plain_ruby_rules + specialized_rules + caching_rules
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Extractors that glob `**/*.rb` under directories they own.
|
|
126
|
+
def plain_ruby_rules
|
|
127
|
+
ex = Woods::Extractors
|
|
128
|
+
|
|
129
|
+
[
|
|
130
|
+
[:services, :extract_service_file, ex::ServiceExtractor::SERVICE_DIRECTORIES],
|
|
131
|
+
[:jobs, :extract_job_file, ex::JobExtractor::JOB_DIRECTORIES],
|
|
132
|
+
[:serializers, :extract_serializer_file, ex::SerializerExtractor::SERIALIZER_DIRECTORIES],
|
|
133
|
+
[:managers, :extract_manager_file, ex::ManagerExtractor::MANAGER_DIRECTORIES],
|
|
134
|
+
[:policies, :extract_policy_file, ex::PolicyExtractor::POLICY_DIRECTORIES],
|
|
135
|
+
[:validators, :extract_validator_file, ex::ValidatorExtractor::VALIDATOR_DIRECTORIES],
|
|
136
|
+
[:pundit_policies, :extract_pundit_file, ex::PunditExtractor::PUNDIT_DIRECTORIES],
|
|
137
|
+
[:decorators, :extract_decorator_file, ex::DecoratorExtractor::DECORATOR_DIRECTORIES],
|
|
138
|
+
[:configurations, :extract_configuration_file, ex::ConfigurationExtractor::CONFIG_DIRECTORIES]
|
|
139
|
+
].map { |key, method_name, dirs| file_rule(key, method_name, dirs) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Extractors with a narrower or wider surface than "*.rb under my dirs".
|
|
143
|
+
def specialized_rules
|
|
144
|
+
ex = Woods::Extractors
|
|
145
|
+
|
|
146
|
+
[
|
|
147
|
+
# ConcernExtractor globs app/**/concerns, not just the two canonical
|
|
148
|
+
# directories — match any .rb under app/ inside a concerns/ segment.
|
|
149
|
+
file_rule(:concerns, :extract_concern_file, %w[app], require_segment: '/concerns/'),
|
|
150
|
+
# GraphQL types sit outside FILE_BASED (they share one extractor
|
|
151
|
+
# method across four unit types via GRAPHQL_TYPES), which is exactly
|
|
152
|
+
# why the FILE_BASED-driven coverage guard never noticed they had no
|
|
153
|
+
# rule: a *new* type/mutation/resolver routed nowhere and never
|
|
154
|
+
# entered the index — the same failure #164 gap 1 exists to close.
|
|
155
|
+
file_rule(:graphql, :extract_graphql_file,
|
|
156
|
+
[ex::GraphQLExtractor::GRAPHQL_DIRECTORY], extensions: %w[.rb]),
|
|
157
|
+
file_rule(:i18n, :extract_i18n_file, ex::I18nExtractor::I18N_DIRECTORIES, extensions: %w[.yml]),
|
|
158
|
+
file_rule(:rake_tasks, :extract_rake_file, ex::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake]),
|
|
159
|
+
file_rule(:view_templates, :extract_view_template_file,
|
|
160
|
+
ex::ViewTemplateExtractor::VIEW_DIRECTORIES, extensions: view_template_extensions),
|
|
161
|
+
file_rule(:migrations, :extract_migration_file, %w[db/migrate], recursive: false),
|
|
162
|
+
# POROs are app/models classes that are *not* ActiveRecord models;
|
|
163
|
+
# the extractor makes that call itself given ar_names.
|
|
164
|
+
file_rule(:poros, :extract_poro_file, %w[app/models], exclude: %w[/concerns/]),
|
|
165
|
+
file_rule(:libs, :extract_lib_file, %w[lib], exclude: ex::LibExtractor::EXCLUDED_SEGMENTS),
|
|
166
|
+
file_rule(:test_mappings, :extract_test_file, %w[spec], extensions: %w[_spec.rb]),
|
|
167
|
+
file_rule(:test_mappings, :extract_test_file, %w[test], extensions: %w[_test.rb])
|
|
168
|
+
]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# CachingExtractor scans three separate globs with different extensions.
|
|
172
|
+
def caching_rules
|
|
173
|
+
Woods::Extractors::CachingExtractor::SCAN_PATTERNS.map do |_file_type, pattern|
|
|
174
|
+
dir, glob = pattern.split('/**/', 2)
|
|
175
|
+
file_rule(:caching, :extract_caching_file, [dir], extensions: [glob.delete_prefix('*')])
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def view_template_extensions
|
|
180
|
+
Woods::Extractors::ViewTemplateExtractor::ENGINES.flat_map { |k| k.new.extensions }.uniq
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def build_whole_app_rules
|
|
184
|
+
[
|
|
185
|
+
whole_app_rule(:routes, %w[config/routes], exact_paths: %w[config/routes.rb]),
|
|
186
|
+
whole_app_rule(:engines, %w[config/routes], exact_paths: %w[config/routes.rb Gemfile.lock]),
|
|
187
|
+
whole_app_rule(:middleware, %w[config/initializers config/environments],
|
|
188
|
+
exact_paths: %w[config/application.rb Gemfile.lock]),
|
|
189
|
+
whole_app_rule(:scheduled_jobs, [],
|
|
190
|
+
exact_paths: Woods::Extractors::ScheduledJobExtractor::SCHEDULE_FILES.keys),
|
|
191
|
+
whole_app_rule(:state_machines, Woods::Extractors::StateMachineExtractor::MODEL_DIRECTORIES,
|
|
192
|
+
extensions: %w[.rb]),
|
|
193
|
+
whole_app_rule(:factories, Woods::Extractors::FactoryExtractor::FACTORY_DIRECTORIES,
|
|
194
|
+
extensions: %w[.rb]),
|
|
195
|
+
whole_app_rule(:database_views, %w[db/views], extensions: %w[.sql]),
|
|
196
|
+
# EventExtractor is a two-pass scan over all of app/ — any Ruby
|
|
197
|
+
# change can add or remove a publish/subscribe site.
|
|
198
|
+
whole_app_rule(:events, Woods::Extractors::EventExtractor::APP_DIRECTORIES, extensions: %w[.rb]),
|
|
199
|
+
# Framework/gem sources are a function of the installed dependency
|
|
200
|
+
# set, so the lockfile is their one honest trigger (#169). The
|
|
201
|
+
# `include_framework_sources` gate lives in the extractor
|
|
202
|
+
# (Extractor#skip_by_configuration?), not here: rules are memoized
|
|
203
|
+
# per-process while configuration can change, and `relevant?` is
|
|
204
|
+
# correct either way because Gemfile.lock already triggers
|
|
205
|
+
# :engines and :middleware.
|
|
206
|
+
whole_app_rule(:rails_source, [], exact_paths: %w[Gemfile.lock]),
|
|
207
|
+
# Packwerk boundaries: a package.yml anywhere re-runs the package
|
|
208
|
+
# extractor wholesale (#280). Vendored and generated trees are the
|
|
209
|
+
# same ones packwerk excludes by default.
|
|
210
|
+
whole_app_rule(:packages, [],
|
|
211
|
+
basenames: [Woods::Extractors::PackageExtractor::PACKAGE_FILE,
|
|
212
|
+
Woods::Extractors::PackageExtractor::PACKWERK_CONFIG],
|
|
213
|
+
exclude: %w[node_modules/ vendor/ tmp/ bin/ script/])
|
|
214
|
+
]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# File-based extractors glob `**/*.rb` unless they say otherwise, so
|
|
218
|
+
# that is the default extension filter here too.
|
|
219
|
+
def file_rule(key, method_name, dirs, **opts)
|
|
220
|
+
opts[:extensions] ||= %w[.rb]
|
|
221
|
+
Rule.new(extractor_key: key, method_name: method_name, dirs: Array(dirs), **opts)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def whole_app_rule(key, dirs, **opts)
|
|
225
|
+
Rule.new(extractor_key: key, method_name: nil, dirs: Array(dirs), **opts)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# File-based rules matching a path.
|
|
230
|
+
#
|
|
231
|
+
# @param relative_path [String] Rails.root-relative path
|
|
232
|
+
# @return [Array<Rule>]
|
|
233
|
+
def file_rules_for(relative_path)
|
|
234
|
+
self.class.file_rules.select { |rule| rule.matches?(relative_path) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Extractor keys whose whole-app re-run is triggered by a path.
|
|
238
|
+
#
|
|
239
|
+
# @param relative_path [String] Rails.root-relative path
|
|
240
|
+
# @return [Array<Symbol>]
|
|
241
|
+
def whole_app_keys_for(relative_path)
|
|
242
|
+
self.class.whole_app_rules
|
|
243
|
+
.select { |rule| rule.matches?(relative_path) }
|
|
244
|
+
.map(&:extractor_key).uniq
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Does this path imply any extraction work at all?
|
|
248
|
+
#
|
|
249
|
+
# Used to filter a raw git diff down to paths worth handing to
|
|
250
|
+
# {Extractor#extract_changed}. It is deliberately derived from the rules
|
|
251
|
+
# rather than from a second hand-maintained pattern list — the two would
|
|
252
|
+
# drift, and a path the filter drops is a path that never reaches the
|
|
253
|
+
# index no matter how good the dispatch behind it is.
|
|
254
|
+
#
|
|
255
|
+
# Paths under `app/` are relevant even without a rule match: class-based
|
|
256
|
+
# types are discovered from runtime descendants, not from the path.
|
|
257
|
+
#
|
|
258
|
+
# @param relative_path [String] Rails.root-relative path
|
|
259
|
+
# @return [Boolean]
|
|
260
|
+
def relevant?(relative_path)
|
|
261
|
+
return true if relative_path.start_with?('app/') && relative_path.end_with?('.rb')
|
|
262
|
+
|
|
263
|
+
file_rules_for(relative_path).any? || whole_app_keys_for(relative_path).any?
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Extractor keys whose whole-app re-run is triggered by any path in a set.
|
|
267
|
+
#
|
|
268
|
+
# @param relative_paths [Enumerable<String>]
|
|
269
|
+
# @return [Set<Symbol>]
|
|
270
|
+
def whole_app_keys_for_all(relative_paths)
|
|
271
|
+
relative_paths.each_with_object(Set.new) do |path, keys|
|
|
272
|
+
whole_app_keys_for(path).each { |key| keys.add(key) }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'find'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
# Writer-side owner of the per-generation payload directories.
|
|
9
|
+
#
|
|
10
|
+
# An index published flat is a directory of independently-written files, so a
|
|
11
|
+
# reader refreshing mid-publish can load a unit from generation N+1 beside a
|
|
12
|
+
# graph from N. Publishing each generation's payload into its own immutable
|
|
13
|
+
# directory and naming that directory from +generation.json+ makes the single
|
|
14
|
+
# atomic write of that one file the commit point for the whole payload.
|
|
15
|
+
#
|
|
16
|
+
# The reader half resolves the pointer ({Woods::MCP::IndexReader#payload_dir});
|
|
17
|
+
# this is the half that builds what it points at.
|
|
18
|
+
#
|
|
19
|
+
# @example Publishing a generation
|
|
20
|
+
# payloads = Woods::PayloadStore.new(output_dir)
|
|
21
|
+
# dir = payloads.create(next_generation)
|
|
22
|
+
# payloads.clone(payloads.path_for(current_generation), dir) # incremental
|
|
23
|
+
# # ... write the run's changes into `dir` ...
|
|
24
|
+
# generation.bump!(reason: 'incremental', payload: Woods::PayloadStore.name_for(next_generation))
|
|
25
|
+
# payloads.prune(keep: 3, protect: next_generation)
|
|
26
|
+
#
|
|
27
|
+
class PayloadStore
|
|
28
|
+
DIRNAME = 'payloads'
|
|
29
|
+
|
|
30
|
+
# How many payload generations to retain, overridable per host via
|
|
31
|
+
# WOODS_PAYLOAD_RETENTION (read by Extractor#payload_retention). Three is
|
|
32
|
+
# the current generation plus two a pinned reader might still be inside.
|
|
33
|
+
DEFAULT_RETENTION = 3
|
|
34
|
+
|
|
35
|
+
# @param output_dir [String, Pathname] index output directory
|
|
36
|
+
def initialize(output_dir)
|
|
37
|
+
@root = Pathname.new(output_dir.to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Pathname] the directory holding every generation's payload
|
|
41
|
+
def root
|
|
42
|
+
@root.join(DIRNAME)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The pointer a {Woods::Generation} carries for a generation's payload.
|
|
46
|
+
#
|
|
47
|
+
# Relative to the output directory by contract: the reader resolves it
|
|
48
|
+
# against its own index root, which is not the absolute path the writer
|
|
49
|
+
# used whenever the index is read through a volume mount.
|
|
50
|
+
#
|
|
51
|
+
# @param number [Integer] generation number
|
|
52
|
+
# @return [String]
|
|
53
|
+
def self.name_for(number)
|
|
54
|
+
"#{DIRNAME}/gen-#{number}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param number [Integer] generation number
|
|
58
|
+
# @return [Pathname] where that generation's payload lives (may not exist)
|
|
59
|
+
def path_for(number)
|
|
60
|
+
@root.join(self.class.name_for(number))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Create the payload directory for a generation and return it.
|
|
64
|
+
#
|
|
65
|
+
# An existing directory is **emptied** rather than reused. It can only be
|
|
66
|
+
# there because an earlier run built a payload and then failed before
|
|
67
|
+
# bumping the generation — nothing points at it, and merging this run's
|
|
68
|
+
# files into that run's leftovers publishes a mixture of the two.
|
|
69
|
+
#
|
|
70
|
+
# @param number [Integer] generation number this payload will be published as
|
|
71
|
+
# @return [Pathname]
|
|
72
|
+
def create(number)
|
|
73
|
+
dir = path_for(number)
|
|
74
|
+
FileUtils.rm_rf(dir.to_s)
|
|
75
|
+
FileUtils.mkdir_p(dir.to_s)
|
|
76
|
+
dir
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Populate +target+ with the contents of +source+ using hardlinks.
|
|
80
|
+
#
|
|
81
|
+
# This is what makes publishing a whole payload per generation affordable
|
|
82
|
+
# on an incremental run: an unchanged file costs a directory entry rather
|
|
83
|
+
# than a copy.
|
|
84
|
+
#
|
|
85
|
+
# It is safe **only because every writer against a payload goes through
|
|
86
|
+
# {Woods::AtomicFile}**, which renames a fresh tempfile over the path. A
|
|
87
|
+
# rename replaces the directory entry and leaves the old inode alone, so
|
|
88
|
+
# the previous generation keeps its bytes. A bare +File.write+ to a cloned
|
|
89
|
+
# path would edit the inode both generations share and corrupt the one a
|
|
90
|
+
# reader is still pinned to.
|
|
91
|
+
#
|
|
92
|
+
# A missing source is a no-op — the first payload has nothing to clone.
|
|
93
|
+
#
|
|
94
|
+
# @param source [Pathname, String] the previous generation's payload
|
|
95
|
+
# @param target [Pathname, String] a directory returned by {#create}
|
|
96
|
+
# @return [void]
|
|
97
|
+
def clone(source, target)
|
|
98
|
+
from = Pathname.new(source.to_s)
|
|
99
|
+
return unless from.directory?
|
|
100
|
+
|
|
101
|
+
to = Pathname.new(target.to_s)
|
|
102
|
+
# The one directory `find` never offers: it yields the root as `.`, and
|
|
103
|
+
# a caller seeding a subtree (Extractor#seed_payload_from_flat_root)
|
|
104
|
+
# passes a target that does not exist yet.
|
|
105
|
+
FileUtils.mkdir_p(to.to_s)
|
|
106
|
+
from.find do |entry|
|
|
107
|
+
relative = entry.relative_path_from(from)
|
|
108
|
+
next if relative.to_s == '.'
|
|
109
|
+
|
|
110
|
+
replicate(entry, to.join(relative))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Remove superseded payload directories, newest-first.
|
|
115
|
+
#
|
|
116
|
+
# +protect+ is never removed however the count works out: it is the
|
|
117
|
+
# generation +generation.json+ names, and removing it would leave every
|
|
118
|
+
# reader resolving a stale pointer back to the flat root.
|
|
119
|
+
#
|
|
120
|
+
# A directory numbered ABOVE +protect+ cannot be a generation still
|
|
121
|
+
# ahead of us in a legitimate history — the counter only advances by
|
|
122
|
+
# bumping past the currently published number — so it can only be a
|
|
123
|
+
# leftover from before `generation.json` was lost and the counter
|
|
124
|
+
# restarted lower. `sort.last(keep)` would otherwise read those stale
|
|
125
|
+
# high numbers as "newest" and retain them forever while pruning the
|
|
126
|
+
# genuinely-previous generation, so they are always removed regardless
|
|
127
|
+
# of +keep+; ordinary retention then applies only to +protect+ and
|
|
128
|
+
# below.
|
|
129
|
+
#
|
|
130
|
+
# Directories that do not parse as a generation payload are left alone —
|
|
131
|
+
# retention only owns what it created.
|
|
132
|
+
#
|
|
133
|
+
# @param keep [Integer] how many payload generations to retain
|
|
134
|
+
# @param protect [Integer] the currently published generation number
|
|
135
|
+
# A reader pins a payload by holding a shared advisory lock on its
|
|
136
|
+
# +manifest.json+. Retention takes the same file's exclusive lock without
|
|
137
|
+
# waiting and skips the generation when another process is still reading
|
|
138
|
+
# it. The operating system releases the shared lock if a reader crashes,
|
|
139
|
+
# so there are no PID leases to expire and no stale marker to guess at.
|
|
140
|
+
# The next publish retries every skipped generation normally.
|
|
141
|
+
#
|
|
142
|
+
# The manifest is the lock target rather than a reader-created sidecar so
|
|
143
|
+
# the Index MCP remains a read-only consumer and payloads written before
|
|
144
|
+
# this protocol remain protectable. Each published generation has its own
|
|
145
|
+
# manifest inode: writers replace a cloned manifest through AtomicFile
|
|
146
|
+
# before publishing, preserving generation-local lock identity.
|
|
147
|
+
#
|
|
148
|
+
# @return [Array<Integer>] the generation numbers removed
|
|
149
|
+
def prune(keep:, protect:)
|
|
150
|
+
return [] unless root.directory?
|
|
151
|
+
|
|
152
|
+
eligible, superseded = generation_dirs.partition { |number, _| number <= protect }
|
|
153
|
+
retained = eligible.map(&:first).sort.last(keep)
|
|
154
|
+
prunable = eligible.reject { |number, _| number == protect || retained.include?(number) }
|
|
155
|
+
|
|
156
|
+
remove_generation_dirs(superseded + prunable)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Hardlink where the filesystem allows it, copy where it does not — a
|
|
160
|
+
# payload spanning a device boundary (a bind mount inside the output
|
|
161
|
+
# directory) must still publish, just without the saving.
|
|
162
|
+
#
|
|
163
|
+
# Public so a caller seeding a payload from something other than
|
|
164
|
+
# {#clone} — {Woods::Extractor#seed_payload_from_flat_root}, which walks
|
|
165
|
+
# an allowlist of flat-root entries rather than a whole directory tree —
|
|
166
|
+
# gets the same cross-device fallback. A bare +FileUtils.ln+ there raised
|
|
167
|
+
# on every run on a filesystem that disallows hardlinks (e.g. some
|
|
168
|
+
# overlay/bind-mount setups), degrading every run to a flat publish and
|
|
169
|
+
# never once giving the fallback a chance to run.
|
|
170
|
+
#
|
|
171
|
+
# @param source [Pathname, String] file to link or copy
|
|
172
|
+
# @param destination [Pathname, String] where it should land
|
|
173
|
+
# @return [void]
|
|
174
|
+
def link_or_copy(source, destination)
|
|
175
|
+
FileUtils.ln(source.to_s, destination.to_s)
|
|
176
|
+
rescue Errno::EXDEV, Errno::EPERM, Errno::EMLINK, NotImplementedError
|
|
177
|
+
FileUtils.cp(source.to_s, destination.to_s)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# @param pairs [Array<Array(Integer, Pathname)>]
|
|
183
|
+
# @return [Array<Integer>] the generation numbers removed
|
|
184
|
+
def remove_generation_dirs(pairs)
|
|
185
|
+
pairs.filter_map { |number, dir| remove_generation_dir(number, dir) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @return [Integer, nil] the removed generation, or nil while pinned
|
|
189
|
+
def remove_generation_dir(number, dir)
|
|
190
|
+
manifest = dir.join('manifest.json')
|
|
191
|
+
return remove_unlocked_generation(number, dir) unless manifest.file?
|
|
192
|
+
|
|
193
|
+
File.open(manifest.to_s, File::RDONLY) do |file|
|
|
194
|
+
return nil unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
195
|
+
|
|
196
|
+
remove_unlocked_generation(number, dir)
|
|
197
|
+
end
|
|
198
|
+
rescue Errno::ENOENT
|
|
199
|
+
# An incomplete/orphaned payload has no readable contract a reader can
|
|
200
|
+
# pin. Retention still owns and may remove that directory.
|
|
201
|
+
remove_unlocked_generation(number, dir)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @return [Integer]
|
|
205
|
+
def remove_unlocked_generation(number, dir)
|
|
206
|
+
FileUtils.rm_rf(dir.to_s)
|
|
207
|
+
number
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @return [Array<Array(Integer, Pathname)>]
|
|
211
|
+
def generation_dirs
|
|
212
|
+
root.children.filter_map do |child|
|
|
213
|
+
match = child.directory? ? child.basename.to_s.match(/\Agen-(\d+)\z/) : nil
|
|
214
|
+
[match[1].to_i, child] if match
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# `Pathname#find` is a pre-order walk: a directory is always visited
|
|
219
|
+
# before anything inside it. So the directory branch has already created
|
|
220
|
+
# every parent a file could need, and the `mkdir_p` the file branch used
|
|
221
|
+
# to run was one stat-heavy syscall chain per unit re-proving what the
|
|
222
|
+
# previous entry established. On a payload of 8000+ files that was the
|
|
223
|
+
# bulk of the incremental seed.
|
|
224
|
+
#
|
|
225
|
+
# @param entry [Pathname] source entry, as `find` yielded it
|
|
226
|
+
# @param destination [Pathname] where it belongs in the target
|
|
227
|
+
# @return [void]
|
|
228
|
+
def replicate(entry, destination)
|
|
229
|
+
if entry.directory?
|
|
230
|
+
FileUtils.mkdir_p(destination.to_s)
|
|
231
|
+
else
|
|
232
|
+
link_or_copy(entry, destination)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
class PublishedIndex
|
|
5
|
+
# Turns raw `dependency_graph.json` data into the flat, uniform edge shape
|
|
6
|
+
# {PublishedIndex#edges} returns: `{ from:, to:, via:, through:,
|
|
7
|
+
# through_db:, disable_joins: }`.
|
|
8
|
+
#
|
|
9
|
+
# Pulled out of {PublishedIndex} itself because this walk is pure data
|
|
10
|
+
# shaping with no dependency on the pinned generation or its retention
|
|
11
|
+
# lock; keeping it separate is what lets the surrounding class stay under
|
|
12
|
+
# `Metrics/ClassLength` without an exclude entry.
|
|
13
|
+
module EdgeShaper
|
|
14
|
+
# Every forward edge in the graph: primary nodes and variants alike. An
|
|
15
|
+
# identifier shared by more than one type contributes one edge per
|
|
16
|
+
# owning type, two edges are never folded into one just because they
|
|
17
|
+
# look alike once reduced to this shape.
|
|
18
|
+
#
|
|
19
|
+
# @param graph [Hash] raw dependency graph data
|
|
20
|
+
# ({Woods::MCP::IndexReader#raw_graph_data})
|
|
21
|
+
# @return [Array<Hash>] `{ from:, to:, via:, through:, through_db:, disable_joins: }`
|
|
22
|
+
def self.call(graph)
|
|
23
|
+
primary = (graph['edges'] || {}).flat_map { |from, list| Array(list).map { |raw| edge_hash(from, raw) } }
|
|
24
|
+
primary + variant_edges(graph)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Edges owned by a type recorded only in the graph's `variants` section:
|
|
28
|
+
# an identifier that names units of more than one type keeps its other
|
|
29
|
+
# types' out-edges there, since the primary `edges` map holds only one
|
|
30
|
+
# type's edges per identifier.
|
|
31
|
+
#
|
|
32
|
+
# @param graph [Hash] raw dependency graph data
|
|
33
|
+
# @return [Array<Hash>]
|
|
34
|
+
def self.variant_edges(graph)
|
|
35
|
+
variant_records(graph).flat_map do |record|
|
|
36
|
+
Array(record['edges']).map { |raw| edge_hash(record['identifier'], raw) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param graph [Hash] raw dependency graph data
|
|
41
|
+
# @return [Array<Hash>] entries from `variants` that name an identifier
|
|
42
|
+
def self.variant_records(graph)
|
|
43
|
+
Array(graph['variants']).select { |record| record.is_a?(Hash) && record['identifier'] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param from [String]
|
|
47
|
+
# @param raw [String, Hash] a bare target (pre-via graphs) or an edge hash
|
|
48
|
+
# @return [Hash]
|
|
49
|
+
def self.edge_hash(from, raw)
|
|
50
|
+
if raw.is_a?(Hash)
|
|
51
|
+
{ from: from, to: raw['target'], via: raw['via'], through: raw['through'],
|
|
52
|
+
through_db: raw['through_db'], disable_joins: raw['disable_joins'] == true }
|
|
53
|
+
else
|
|
54
|
+
{ from: from, to: raw.to_s, via: nil, through: nil, through_db: nil, disable_joins: false }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_class_method :variant_edges, :variant_records, :edge_hash
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
class PublishedIndex
|
|
5
|
+
# Resolves `generation.json` and enumerates published `payloads/gen-N`
|
|
6
|
+
# directories.
|
|
7
|
+
#
|
|
8
|
+
# Split out of {PublishedIndex} because this is pure pointer and
|
|
9
|
+
# directory inspection: it never touches an open reader or its retention
|
|
10
|
+
# lock, and keeping it separate is what lets {PublishedIndex} itself stay
|
|
11
|
+
# under `Metrics/ClassLength` without an exclude entry.
|
|
12
|
+
module GenerationCatalog
|
|
13
|
+
# The published generation pointer, distinguishing "no pointer file"
|
|
14
|
+
# (a flat index, where {Woods::Generation::UNPUBLISHED} is the honest
|
|
15
|
+
# answer) from "a pointer file that will not parse" (a corrupt install,
|
|
16
|
+
# which {Woods::Generation#current} silently maps to that same
|
|
17
|
+
# UNPUBLISHED sentinel). Only this method's caller has already checked
|
|
18
|
+
# which one it is, so only here can the two be told apart.
|
|
19
|
+
#
|
|
20
|
+
# @param root [Pathname]
|
|
21
|
+
# @return [Woods::Generation::Marker]
|
|
22
|
+
# @raise [PublishedIndex::CorruptPointerError] when the file exists but will not parse
|
|
23
|
+
def self.pointer(root)
|
|
24
|
+
generation = Woods::Generation.new(output_dir: root)
|
|
25
|
+
return Woods::Generation::UNPUBLISHED unless File.exist?(generation.path)
|
|
26
|
+
|
|
27
|
+
marker = generation.current
|
|
28
|
+
return marker unless marker.equal?(Woods::Generation::UNPUBLISHED)
|
|
29
|
+
|
|
30
|
+
raise PublishedIndex::CorruptPointerError, "Unreadable generation pointer: #{generation.path}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Published generation numbers, ascending. See
|
|
34
|
+
# {PublishedIndex.available_generations} for what "published" means.
|
|
35
|
+
#
|
|
36
|
+
# @param root [Pathname]
|
|
37
|
+
# @return [Array<Integer>]
|
|
38
|
+
# @raise [PublishedIndex::CorruptPointerError] when `generation.json` exists but will not parse
|
|
39
|
+
def self.available(root)
|
|
40
|
+
marker = pointer(root)
|
|
41
|
+
return [] if marker.number.zero?
|
|
42
|
+
|
|
43
|
+
payloads = Woods::PayloadStore.new(root)
|
|
44
|
+
return [] unless payloads.root.directory?
|
|
45
|
+
|
|
46
|
+
payloads.root.children.filter_map { |child| published_number(child, marker.number) }.sort
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A directory's generation number, when it qualifies as published:
|
|
50
|
+
# named `gen-<N>` for N at or below +pointer+, holding a
|
|
51
|
+
# `manifest.json`.
|
|
52
|
+
#
|
|
53
|
+
# @param child [Pathname]
|
|
54
|
+
# @param pointer [Integer] the currently published generation number
|
|
55
|
+
# @return [Integer, nil]
|
|
56
|
+
def self.published_number(child, pointer)
|
|
57
|
+
return nil unless child.directory?
|
|
58
|
+
|
|
59
|
+
match = child.basename.to_s.match(/\Agen-(\d+)\z/)
|
|
60
|
+
return nil unless match
|
|
61
|
+
|
|
62
|
+
number = match[1].to_i
|
|
63
|
+
return nil if number > pointer
|
|
64
|
+
return nil unless child.join('manifest.json').file?
|
|
65
|
+
|
|
66
|
+
number
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method :published_number
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|