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
|
@@ -51,6 +51,28 @@ module Woods
|
|
|
51
51
|
# the API docs marking it optional), so a working default matters.
|
|
52
52
|
DEFAULT_ICON_URL = 'https://raw.githubusercontent.com/lost-in-the/woods/main/assets/woods-mark-black.svg'
|
|
53
53
|
|
|
54
|
+
# HTTP methods safe to retry after *any* transient network failure —
|
|
55
|
+
# repeating an idempotent request cannot double-apply an operation.
|
|
56
|
+
# PUT belongs here because the documents endpoint is an upsert keyed
|
|
57
|
+
# on `uri`. POST (create_collection) is deliberately absent: see
|
|
58
|
+
# {#execute_http}.
|
|
59
|
+
IDEMPOTENT_METHODS = %i[get put delete head].freeze
|
|
60
|
+
|
|
61
|
+
# Network failures that provably occur before the server could have
|
|
62
|
+
# processed the request (the connection was never established), so a
|
|
63
|
+
# retry is safe even for non-idempotent verbs.
|
|
64
|
+
PRE_REQUEST_ERRORS = [Net::OpenTimeout, Errno::ECONNREFUSED].freeze
|
|
65
|
+
|
|
66
|
+
# Response codes potentially retried. 429 is safe for every verb — it
|
|
67
|
+
# means the server throttled the request before processing it. 503 is
|
|
68
|
+
# NOT unconditionally safe: that status proves the *origin* refused to
|
|
69
|
+
# process the request, but it can also be synthesized by an
|
|
70
|
+
# intermediary sitting in front of an origin that already committed
|
|
71
|
+
# the write, so the response alone doesn't prove non-commitment.
|
|
72
|
+
# {#request}'s `idempotent:` flag gates the 503 retry accordingly —
|
|
73
|
+
# see {#execute_http} for the equivalent network-level split.
|
|
74
|
+
RETRYABLE_STATUS_CODES = %w[429 503].freeze
|
|
75
|
+
|
|
54
76
|
# @param api_token [String] Unblocked API token (Personal or Team)
|
|
55
77
|
# @param rate_limiter [RateLimiter] Rate limiter instance
|
|
56
78
|
# @raise [ArgumentError] if api_token is nil or empty
|
|
@@ -91,23 +113,14 @@ module Woods
|
|
|
91
113
|
# Woods mark.
|
|
92
114
|
# @return [Hash] { "id" => "collection-uuid", "name" => "...", ... }
|
|
93
115
|
def create_collection(name:, description:, icon_url: nil)
|
|
116
|
+
# Collections have no upsert key, so there's no idempotency key to
|
|
117
|
+
# offer the API — a 503 that turns out to be an intermediary's
|
|
118
|
+
# response for an already-committed create must not be retried.
|
|
94
119
|
request(:post, 'collections', {
|
|
95
120
|
name: name,
|
|
96
121
|
description: description,
|
|
97
122
|
iconUrl: icon_url || DEFAULT_ICON_URL
|
|
98
|
-
})
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# List all collections.
|
|
102
|
-
#
|
|
103
|
-
# @return [Array<Hash>] Collection objects
|
|
104
|
-
def list_collections
|
|
105
|
-
result = request(:get, 'collections')
|
|
106
|
-
# The live API returns a bare JSON array; the envelope fallbacks are
|
|
107
|
-
# defensive (calling ['items'] on an Array raises TypeError).
|
|
108
|
-
return result if result.is_a?(Array)
|
|
109
|
-
|
|
110
|
-
result['items'] || result['data'] || [result].flatten.compact
|
|
123
|
+
}, idempotent: false)
|
|
111
124
|
end
|
|
112
125
|
|
|
113
126
|
# Delete a document by ID.
|
|
@@ -166,7 +179,18 @@ module Woods
|
|
|
166
179
|
|
|
167
180
|
private
|
|
168
181
|
|
|
169
|
-
|
|
182
|
+
# @param method [Symbol] HTTP method
|
|
183
|
+
# @param path [String] API path (appended to BASE_URL)
|
|
184
|
+
# @param body [Hash, nil] Request body
|
|
185
|
+
# @param idempotent [Boolean] Whether repeating this request cannot
|
|
186
|
+
# double-apply the operation. 429 always retries regardless; a 503
|
|
187
|
+
# only retries when `idempotent` is true, since a 503 can be an
|
|
188
|
+
# intermediary's response for an origin that already committed a
|
|
189
|
+
# non-idempotent write (see {RETRYABLE_STATUS_CODES}).
|
|
190
|
+
# @raise [Woods::Error] on non-success responses (after retries for 429,
|
|
191
|
+
# and for 503 when idempotent), or immediately for a non-idempotent
|
|
192
|
+
# request's 503
|
|
193
|
+
def request(method, path, body = nil, idempotent: true)
|
|
170
194
|
retries = 0
|
|
171
195
|
|
|
172
196
|
loop do
|
|
@@ -174,7 +198,9 @@ module Woods
|
|
|
174
198
|
|
|
175
199
|
return parse_response(response) if response.is_a?(Net::HTTPSuccess)
|
|
176
200
|
|
|
177
|
-
if response.code == '
|
|
201
|
+
raise_ambiguous_response_error(method, response) if response.code == '503' && !idempotent
|
|
202
|
+
|
|
203
|
+
if RETRYABLE_STATUS_CODES.include?(response.code) && retries < MAX_RETRIES
|
|
178
204
|
retries += 1
|
|
179
205
|
# Retry-After may be an HTTP-date, which .to_f would collapse to
|
|
180
206
|
# 0.0 — honoring it as-is would hammer a throttling server.
|
|
@@ -187,6 +213,18 @@ module Woods
|
|
|
187
213
|
end
|
|
188
214
|
end
|
|
189
215
|
|
|
216
|
+
# Perform the raw HTTP request with verb-aware network error retry.
|
|
217
|
+
#
|
|
218
|
+
# Idempotent verbs ({IDEMPOTENT_METHODS}) retry every transient
|
|
219
|
+
# failure. POST retries only failures that provably happened before
|
|
220
|
+
# the server could have processed the request ({PRE_REQUEST_ERRORS});
|
|
221
|
+
# a mid-exchange failure (Net::ReadTimeout, ECONNRESET) raises
|
|
222
|
+
# instead, because the request may have been fully delivered and
|
|
223
|
+
# committed server-side before the connection died. That matters here
|
|
224
|
+
# because the one live POST is create_collection: collections have no
|
|
225
|
+
# upsert key, so retrying a phantom-committed create mints a duplicate
|
|
226
|
+
# collection that no later sync reconciles (documents upsert by `uri`
|
|
227
|
+
# and are safe; collections are not).
|
|
190
228
|
def execute_http(method, path, body)
|
|
191
229
|
attempts = 0
|
|
192
230
|
begin
|
|
@@ -199,14 +237,77 @@ module Woods
|
|
|
199
237
|
req = build_request(method, uri, body)
|
|
200
238
|
http.request(req)
|
|
201
239
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED => e
|
|
240
|
+
raise_ambiguous_network_error(method, e) unless safe_to_retry?(method, e)
|
|
241
|
+
|
|
202
242
|
attempts += 1
|
|
203
|
-
|
|
243
|
+
if attempts >= MAX_RETRIES
|
|
244
|
+
raise Woods::Error, "Network error after #{attempts} attempts: #{redact_token(e.message)}"
|
|
245
|
+
end
|
|
204
246
|
|
|
205
247
|
sleep(2**attempts)
|
|
206
248
|
retry
|
|
207
249
|
end
|
|
208
250
|
end
|
|
209
251
|
|
|
252
|
+
# Whether a failed request may be retried without risking a
|
|
253
|
+
# double-apply: either the verb is idempotent, or the failure class
|
|
254
|
+
# proves the request never reached the server.
|
|
255
|
+
def safe_to_retry?(method, error)
|
|
256
|
+
IDEMPOTENT_METHODS.include?(method) || PRE_REQUEST_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Raise for a non-idempotent request that failed mid-exchange. The
|
|
260
|
+
# request may have been committed server-side before the failure, so
|
|
261
|
+
# the operator must verify (e.g. list collections) before re-running
|
|
262
|
+
# rather than have the client silently duplicate the write.
|
|
263
|
+
def raise_ambiguous_network_error(method, error)
|
|
264
|
+
raise Woods::Error,
|
|
265
|
+
"#{method.to_s.upcase} request interrupted mid-exchange (#{error.class}); " \
|
|
266
|
+
'the operation may or may not have been applied server-side. ' \
|
|
267
|
+
'Not retrying automatically to avoid duplicates; verify before re-running: ' \
|
|
268
|
+
"#{redact_token(error.message)}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Raise for a non-idempotent request that received a 503. The response
|
|
272
|
+
# doesn't prove the origin never processed the request — an
|
|
273
|
+
# intermediary can synthesize a 503 for a request the origin already
|
|
274
|
+
# committed — so this mirrors {#raise_ambiguous_network_error}: same
|
|
275
|
+
# error class, same "verify before re-running" guidance, because the
|
|
276
|
+
# risk (a silently duplicated create) is identical.
|
|
277
|
+
#
|
|
278
|
+
# @param method [Symbol] HTTP method
|
|
279
|
+
# @param response [Net::HTTPResponse] the 503 response
|
|
280
|
+
# @raise [Woods::Error] always
|
|
281
|
+
def raise_ambiguous_response_error(method, response)
|
|
282
|
+
parsed = begin
|
|
283
|
+
JSON.parse(response.body)
|
|
284
|
+
rescue JSON::ParserError, TypeError
|
|
285
|
+
{}
|
|
286
|
+
end
|
|
287
|
+
detail = parsed['message'] || parsed['detail'] || parsed['title'] || 'Service Unavailable'
|
|
288
|
+
raise Woods::Error,
|
|
289
|
+
"#{method.to_s.upcase} request received 503 (#{redact_token(detail)}); " \
|
|
290
|
+
'the operation may or may not have been applied server-side. ' \
|
|
291
|
+
'Not retrying automatically to avoid duplicates; verify before re-running.'
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Strip the bearer token out of anything derived from an underlying
|
|
295
|
+
# network error before it reaches a log or a backtrace.
|
|
296
|
+
#
|
|
297
|
+
# The stdlib can echo a reflected URL or a request dump into an
|
|
298
|
+
# exception message, and this client sends the token in an Authorization
|
|
299
|
+
# header on every request. The Notion client has done this since its
|
|
300
|
+
# audit; this one had not, so the same class of leak was still open here.
|
|
301
|
+
#
|
|
302
|
+
# @param message [String, nil]
|
|
303
|
+
# @return [String, nil]
|
|
304
|
+
def redact_token(message)
|
|
305
|
+
return message if message.nil? || message.empty?
|
|
306
|
+
return message if @api_token.nil? || @api_token.empty?
|
|
307
|
+
|
|
308
|
+
message.to_s.gsub(@api_token, '[REDACTED]')
|
|
309
|
+
end
|
|
310
|
+
|
|
210
311
|
def build_request(method, uri, body)
|
|
211
312
|
req = case method
|
|
212
313
|
when :put then Net::HTTP::Put.new(uri)
|
|
@@ -240,7 +341,8 @@ module Woods
|
|
|
240
341
|
# The Unblocked API returns RFC7807-style bodies ({ status, title, detail });
|
|
241
342
|
# older/other paths use message/error. Check all so failures stay legible.
|
|
242
343
|
message = parsed['message'] || parsed['error'] || parsed['detail'] || parsed['title'] || 'Unknown error'
|
|
243
|
-
raise ApiError.new("Unblocked API error #{response.code}: #{message}",
|
|
344
|
+
raise ApiError.new("Unblocked API error #{response.code}: #{redact_token(message)}",
|
|
345
|
+
status: response.code.to_i)
|
|
244
346
|
end
|
|
245
347
|
end
|
|
246
348
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'erb' # ERB::Util.url_encode
|
|
3
4
|
require 'woods/export/unit_facts'
|
|
4
5
|
|
|
5
6
|
module Woods
|
|
@@ -18,10 +19,21 @@ module Woods
|
|
|
18
19
|
#
|
|
19
20
|
class DocumentBuilder
|
|
20
21
|
# @param repo_url [String] GitHub repo base URL for citation URIs
|
|
21
|
-
|
|
22
|
+
# @param repo_url [String] Repository web URL, e.g. https://github.com/acme/app
|
|
23
|
+
# @param ref [String, nil] Branch/tag/SHA the index was extracted from.
|
|
24
|
+
# Defaults to +DEFAULT_REF+ when nil, empty, or "unknown" — which is
|
|
25
|
+
# what {Woods::GitProvenance} reports when it cannot resolve a ref.
|
|
26
|
+
def initialize(repo_url:, ref: nil)
|
|
22
27
|
@repo_url = repo_url.chomp('/')
|
|
28
|
+
@ref = normalize_ref(ref)
|
|
23
29
|
end
|
|
24
30
|
|
|
31
|
+
# Ref used when the index does not say which one it came from.
|
|
32
|
+
DEFAULT_REF = 'main'
|
|
33
|
+
|
|
34
|
+
# Refs that mean "no answer" rather than an actual ref name.
|
|
35
|
+
UNRESOLVED_REFS = ['', 'unknown'].freeze
|
|
36
|
+
|
|
25
37
|
# Build a document hash from a unit's extracted data.
|
|
26
38
|
#
|
|
27
39
|
# @param unit_data [Hash] Parsed unit JSON (from IndexReader)
|
|
@@ -48,11 +60,31 @@ module Woods
|
|
|
48
60
|
file_path = unit_data['file_path']
|
|
49
61
|
return @repo_url unless file_path
|
|
50
62
|
|
|
51
|
-
"#{@repo_url}/blob
|
|
63
|
+
"#{@repo_url}/blob/#{@ref}/#{encode_path(file_path)}"
|
|
52
64
|
end
|
|
53
65
|
|
|
54
66
|
private
|
|
55
67
|
|
|
68
|
+
# `main` was hardcoded, so every citation on a repo whose default branch
|
|
69
|
+
# is `master` (or a release branch, or a tag) pointed at a ref that may
|
|
70
|
+
# not contain the file at all.
|
|
71
|
+
def normalize_ref(ref)
|
|
72
|
+
candidate = ref.to_s.strip
|
|
73
|
+
return DEFAULT_REF if UNRESOLVED_REFS.include?(candidate.downcase)
|
|
74
|
+
|
|
75
|
+
candidate
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Percent-encode each path segment, leaving the separators alone.
|
|
79
|
+
#
|
|
80
|
+
# Paths were interpolated raw, so a space or `#` in a filename produced a
|
|
81
|
+
# URL that silently truncated or 404'd — `#` in particular turns the rest
|
|
82
|
+
# of the path into a fragment. Encoding per segment rather than whole
|
|
83
|
+
# keeps `/` meaningful.
|
|
84
|
+
def encode_path(file_path)
|
|
85
|
+
file_path.to_s.split('/').map { |segment| ERB::Util.url_encode(segment) }.join('/')
|
|
86
|
+
end
|
|
87
|
+
|
|
56
88
|
def build_body(unit_data)
|
|
57
89
|
type = unit_data['type']
|
|
58
90
|
body = case type
|
|
@@ -75,7 +75,10 @@ module Woods
|
|
|
75
75
|
|
|
76
76
|
@client = client || Client.new(api_token: api_token, rate_limiter: limiter)
|
|
77
77
|
@reader = reader || build_reader(index_dir)
|
|
78
|
-
|
|
78
|
+
# Cite the ref the index was actually extracted from. `main` was
|
|
79
|
+
# hardcoded, so citations on a `master`-default repo pointed at a
|
|
80
|
+
# branch that need not exist.
|
|
81
|
+
@builder = DocumentBuilder.new(repo_url: repo_url, ref: extracted_ref)
|
|
79
82
|
@manifest = manifest || build_manifest(index_dir)
|
|
80
83
|
@force_full = force_full
|
|
81
84
|
@force_purge = force_purge
|
|
@@ -93,35 +96,37 @@ module Woods
|
|
|
93
96
|
#
|
|
94
97
|
# @return [Hash] { synced:, skipped:, deleted:, errors: }
|
|
95
98
|
def sync_all
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
99
|
+
with_pinned_index do
|
|
100
|
+
@current_uris = Set.new
|
|
101
|
+
@budget_exhausted = false
|
|
102
|
+
build_uri_index
|
|
103
|
+
reconcile_from_remote if @manifest.empty?
|
|
104
|
+
|
|
105
|
+
synced = 0
|
|
106
|
+
skipped = 0
|
|
107
|
+
errors = []
|
|
108
|
+
|
|
109
|
+
FULL_SYNC_TYPES.each do |type|
|
|
110
|
+
break if @budget_exhausted
|
|
111
|
+
|
|
112
|
+
result = sync_type(type)
|
|
113
|
+
synced += result[:synced]
|
|
114
|
+
skipped += result[:skipped]
|
|
115
|
+
errors.concat(result[:errors])
|
|
116
|
+
end
|
|
107
117
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
skipped += result[:skipped]
|
|
111
|
-
errors.concat(result[:errors])
|
|
112
|
-
end
|
|
118
|
+
PARTIAL_SYNC_TYPES.each do |type, max_count|
|
|
119
|
+
break if @budget_exhausted
|
|
113
120
|
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
result = sync_type_partial(type, max_count)
|
|
122
|
+
synced += result[:synced]
|
|
123
|
+
skipped += result[:skipped]
|
|
124
|
+
errors.concat(result[:errors])
|
|
125
|
+
end
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
synced
|
|
119
|
-
skipped += result[:skipped]
|
|
120
|
-
errors.concat(result[:errors])
|
|
127
|
+
deleted = @budget_exhausted ? 0 : purge_stale(errors)
|
|
128
|
+
{ synced: synced, skipped: skipped, deleted: deleted, errors: cap_errors(errors) }
|
|
121
129
|
end
|
|
122
|
-
|
|
123
|
-
deleted = @budget_exhausted ? 0 : purge_stale(errors)
|
|
124
|
-
{ synced: synced, skipped: skipped, deleted: deleted, errors: cap_errors(errors) }
|
|
125
130
|
ensure
|
|
126
131
|
save_manifest
|
|
127
132
|
end
|
|
@@ -173,6 +178,25 @@ module Woods
|
|
|
173
178
|
|
|
174
179
|
private
|
|
175
180
|
|
|
181
|
+
# Run a multi-read export body against one index generation.
|
|
182
|
+
#
|
|
183
|
+
# Every public IndexReader accessor self-refreshes when the published
|
|
184
|
+
# generation moves, and the reader assigns pinning responsibility to
|
|
185
|
+
# direct callers. Unpinned, `build_uri_index`, the per-type listings and
|
|
186
|
+
# `purge_stale` can straddle two generations (EXP-5), so the purge set is
|
|
187
|
+
# computed against a mixture of them.
|
|
188
|
+
#
|
|
189
|
+
# Guarded by +respond_to?+: injected readers (specs, embedders) need not
|
|
190
|
+
# implement pinning.
|
|
191
|
+
#
|
|
192
|
+
# @yield the export body
|
|
193
|
+
# @return [Object] the block's value
|
|
194
|
+
def with_pinned_index(&block)
|
|
195
|
+
return yield unless @reader.respond_to?(:with_pinned_generation)
|
|
196
|
+
|
|
197
|
+
@reader.with_pinned_generation(&block)
|
|
198
|
+
end
|
|
199
|
+
|
|
176
200
|
def sync_units(units)
|
|
177
201
|
synced = 0
|
|
178
202
|
skipped = 0
|
|
@@ -422,7 +446,7 @@ module Woods
|
|
|
422
446
|
# message match remains as a fallback for injected clients that raise
|
|
423
447
|
# plain Woods::Error.
|
|
424
448
|
def note_budget_exhaustion(error)
|
|
425
|
-
return false unless error.is_a?(BudgetExhaustedError) || error.message.include?('
|
|
449
|
+
return false unless error.is_a?(BudgetExhaustedError) || error.message.include?('budget exhausted for this run')
|
|
426
450
|
|
|
427
451
|
@budget_exhausted = true
|
|
428
452
|
end
|
|
@@ -432,6 +456,18 @@ module Woods
|
|
|
432
456
|
Woods::MCP::IndexReader.new(index_dir)
|
|
433
457
|
end
|
|
434
458
|
|
|
459
|
+
# The git ref recorded in the index manifest, for citation URLs.
|
|
460
|
+
#
|
|
461
|
+
# Nil on any failure — a missing or unreadable manifest must not stop a
|
|
462
|
+
# sync, and DocumentBuilder falls back to its default ref.
|
|
463
|
+
#
|
|
464
|
+
# @return [String, nil]
|
|
465
|
+
def extracted_ref
|
|
466
|
+
@reader.manifest['git_branch']
|
|
467
|
+
rescue StandardError
|
|
468
|
+
nil
|
|
469
|
+
end
|
|
470
|
+
|
|
435
471
|
# Persist the manifest, downgrading failures to a warning: losing the
|
|
436
472
|
# manifest only costs a full re-check next run, which must not turn an
|
|
437
473
|
# otherwise-successful sync into a crash (this runs from an ensure, where
|
|
@@ -10,11 +10,23 @@ module Woods
|
|
|
10
10
|
# matching the message string.
|
|
11
11
|
class BudgetExhaustedError < Woods::Error; end
|
|
12
12
|
|
|
13
|
-
#
|
|
13
|
+
# Call-count budget for the Unblocked API, enforced **per run**.
|
|
14
14
|
#
|
|
15
|
-
# Unlike Notion's per-second throttling, Unblocked limits by daily call
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# Unlike Notion's per-second throttling, Unblocked limits by daily call
|
|
16
|
+
# count (1000/day). This limiter is a guard rail against a single run
|
|
17
|
+
# burning that allowance, not a reimplementation of it: the counter lives
|
|
18
|
+
# in the process and starts at zero every run.
|
|
19
|
+
#
|
|
20
|
+
# That is deliberate rather than an oversight. The real budget is enforced
|
|
21
|
+
# server-side against the token, and the token is typically shared across
|
|
22
|
+
# developer machines and CI runners with no common disk — so a persisted
|
|
23
|
+
# local counter would be wrong in both directions (blocking a fresh run
|
|
24
|
+
# that had allowance left, and permitting a run after the allowance was
|
|
25
|
+
# spent elsewhere) while looking authoritative. A per-run cap gives the
|
|
26
|
+
# useful half of the guarantee honestly.
|
|
27
|
+
#
|
|
28
|
+
# Messages therefore say "per run", and the operator is pointed at the
|
|
29
|
+
# server-side limit for the real answer.
|
|
18
30
|
#
|
|
19
31
|
# @example
|
|
20
32
|
# limiter = RateLimiter.new(daily_budget: 1000)
|
|
@@ -50,8 +62,10 @@ module Woods
|
|
|
50
62
|
@mutex.synchronize do
|
|
51
63
|
if @calls_today >= @daily_budget
|
|
52
64
|
raise BudgetExhaustedError,
|
|
53
|
-
"Unblocked API
|
|
54
|
-
'
|
|
65
|
+
"Unblocked API call budget exhausted for this run (#{@daily_budget} calls). " \
|
|
66
|
+
'This is a per-run cap, not a reading of your remaining daily allowance — ' \
|
|
67
|
+
'the 1000/day limit is enforced server-side against your token and resets ' \
|
|
68
|
+
'at midnight PST. Raise or lower the per-run cap with UNBLOCKED_DAILY_BUDGET.'
|
|
55
69
|
end
|
|
56
70
|
|
|
57
71
|
@calls_today += 1
|
|
@@ -61,7 +75,7 @@ module Woods
|
|
|
61
75
|
yield
|
|
62
76
|
end
|
|
63
77
|
|
|
64
|
-
# Number of API calls remaining in
|
|
78
|
+
# Number of API calls remaining in this run's budget.
|
|
65
79
|
#
|
|
66
80
|
# @return [Integer]
|
|
67
81
|
def remaining
|
|
@@ -93,8 +107,8 @@ module Woods
|
|
|
93
107
|
|
|
94
108
|
@warned = true
|
|
95
109
|
@warn_io&.puts(
|
|
96
|
-
"WARNING: Unblocked API usage at #{@calls_today}/#{@daily_budget} " \
|
|
97
|
-
"(#{remaining} calls remaining)"
|
|
110
|
+
"WARNING: Unblocked API usage at #{@calls_today}/#{@daily_budget} for this run " \
|
|
111
|
+
"(#{remaining} calls remaining in the per-run cap)"
|
|
98
112
|
)
|
|
99
113
|
end
|
|
100
114
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
|
|
6
|
+
require_relative '../atomic_file'
|
|
7
|
+
|
|
6
8
|
module Woods
|
|
7
9
|
module Unblocked
|
|
8
10
|
# Tracks what was last pushed to an Unblocked collection so a sync can
|
|
@@ -89,29 +91,33 @@ module Woods
|
|
|
89
91
|
# Persist the manifest atomically (temp file + rename) so an interrupted
|
|
90
92
|
# write never leaves a torn file in the CI cache.
|
|
91
93
|
def save
|
|
92
|
-
FileUtils.mkdir_p(File.dirname(@path))
|
|
93
94
|
payload = JSON.generate(
|
|
94
95
|
'version' => VERSION,
|
|
95
96
|
'collection_id' => @collection_id,
|
|
96
97
|
'documents' => @documents
|
|
97
98
|
)
|
|
98
|
-
|
|
99
|
-
File.write(tmp, payload)
|
|
100
|
-
File.rename(tmp, @path)
|
|
99
|
+
AtomicFile.write(@path, payload)
|
|
101
100
|
end
|
|
102
101
|
|
|
103
102
|
private
|
|
104
103
|
|
|
105
104
|
# Load the persisted documents, discarding data from a different
|
|
106
|
-
# collection, a different schema version, or an unparseable
|
|
107
|
-
# Every discard warns to stderr — the consequence (a full re-push)
|
|
108
|
-
# expensive enough that operators need to know why it happened.
|
|
105
|
+
# collection, a different schema version, or an unparseable/unreadable
|
|
106
|
+
# file. Every discard warns to stderr — the consequence (a full re-push)
|
|
107
|
+
# is expensive enough that operators need to know why it happened.
|
|
108
|
+
#
|
|
109
|
+
# AtomicFile.read, not File.read: a bare read tags the bytes with the
|
|
110
|
+
# process's default external encoding (US-ASCII under LANG=C), so a
|
|
111
|
+
# non-ASCII document URI raised EncodingError out of JSON.parse instead
|
|
112
|
+
# of degrading. Read failures (e.g. Errno::EACCES) take the same discard
|
|
113
|
+
# path — the manifest is a cache, and "everything is new" is always a
|
|
114
|
+
# correct answer.
|
|
109
115
|
#
|
|
110
116
|
# @return [Hash{String=>Hash}] uri => { 'hash' =>, 'document_id' => }
|
|
111
117
|
def load
|
|
112
118
|
return {} unless File.exist?(@path)
|
|
113
119
|
|
|
114
|
-
parsed = JSON.parse(
|
|
120
|
+
parsed = JSON.parse(AtomicFile.read(@path))
|
|
115
121
|
return discard('not a JSON object') unless parsed.is_a?(Hash)
|
|
116
122
|
return discard("schema version #{parsed['version'].inspect}, expected #{VERSION}") unless
|
|
117
123
|
parsed['version'] == VERSION
|
|
@@ -122,6 +128,8 @@ module Woods
|
|
|
122
128
|
documents.is_a?(Hash) ? documents : {}
|
|
123
129
|
rescue JSON::ParserError
|
|
124
130
|
discard('unparseable JSON')
|
|
131
|
+
rescue EncodingError, SystemCallError => e
|
|
132
|
+
discard("unreadable file: #{e.class}: #{e.message}")
|
|
125
133
|
end
|
|
126
134
|
|
|
127
135
|
# @param reason [String] Why the persisted manifest is unusable
|
data/lib/woods/update_check.rb
CHANGED
|
@@ -62,12 +62,21 @@ module Woods
|
|
|
62
62
|
# The +server.update+ sub-hash for +woods_status+. Keys are snake_case to
|
|
63
63
|
# match the rest of the status payload's JSON shape.
|
|
64
64
|
#
|
|
65
|
+
# +latest_version+ reports the newest version this process knows about —
|
|
66
|
+
# the published one when it is ahead of the install, otherwise the
|
|
67
|
+
# installed version itself. Reporting the raw published version here made
|
|
68
|
+
# the payload self-contradictory whenever the installed gem was newer (an
|
|
69
|
+
# unreleased build): +current_version: 2.0.0+ next to +latest_version:
|
|
70
|
+
# 1.6.0+ with +update_available: false+ reads as if the running version is
|
|
71
|
+
# invalid. +update_available+ semantics are unchanged: true only when the
|
|
72
|
+
# published version is strictly newer than the installed one.
|
|
73
|
+
#
|
|
65
74
|
# @return [Hash] +{ current_version:, latest_version:, update_available: }+
|
|
66
75
|
def status_hash(current: Woods::VERSION, **opts)
|
|
67
76
|
r = check(current: current, **opts)
|
|
68
77
|
{
|
|
69
78
|
current_version: r[:current],
|
|
70
|
-
latest_version: r[:latest],
|
|
79
|
+
latest_version: newest_known_version(r[:current], r[:latest]),
|
|
71
80
|
update_available: r[:update_available]
|
|
72
81
|
}
|
|
73
82
|
end
|
|
@@ -99,6 +108,20 @@ module Woods
|
|
|
99
108
|
{ current: current, latest: latest, update_available: latest ? newer?(latest, current) : false }
|
|
100
109
|
end
|
|
101
110
|
|
|
111
|
+
# The newest version between the installed gem and the last published one
|
|
112
|
+
# this process knows about. Used by {status_hash} for public reporting;
|
|
113
|
+
# the raw published value stays internal for comparison and caching.
|
|
114
|
+
#
|
|
115
|
+
# @param current [String] installed version
|
|
116
|
+
# @param published [String, nil] last known published version (nil when
|
|
117
|
+
# the probe failed or the check is disabled)
|
|
118
|
+
# @return [String]
|
|
119
|
+
def newest_known_version(current, published)
|
|
120
|
+
return current unless published && newer?(published, current)
|
|
121
|
+
|
|
122
|
+
published
|
|
123
|
+
end
|
|
124
|
+
|
|
102
125
|
def newer?(latest, current)
|
|
103
126
|
Gem::Version.new(latest) > Gem::Version.new(current)
|
|
104
127
|
rescue ArgumentError
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Util
|
|
7
|
+
# RFC 4122 / RFC 9562 name-based UUID version 5 (SHA-1) generation.
|
|
8
|
+
#
|
|
9
|
+
# Ruby's stdlib has no UUIDv5 primitive (`SecureRandom.uuid` is v4 —
|
|
10
|
+
# random, therefore useless for deriving a stable id from a name), so
|
|
11
|
+
# the digest is assembled here from {Digest::SHA1}.
|
|
12
|
+
#
|
|
13
|
+
# The output is a pure function of `(namespace, name)`: no clock, no
|
|
14
|
+
# randomness, no locale, no hash-ordering. Every input to the digest is
|
|
15
|
+
# a byte string derived deterministically, which is what makes the
|
|
16
|
+
# result identical across processes, machines, and Ruby versions —
|
|
17
|
+
# `Digest::SHA1` is a fixed standard, and the byte layout below is
|
|
18
|
+
# spelled out explicitly rather than relying on any Ruby-version
|
|
19
|
+
# behaviour.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# Woods::Util::UUID5.generate(Woods::Util::UUID5::NAMESPACE_DNS, 'python.org')
|
|
23
|
+
# # => "886313e1-3b8a-5372-9b90-0c9aee199e5d"
|
|
24
|
+
#
|
|
25
|
+
module UUID5
|
|
26
|
+
# The predefined DNS namespace from RFC 4122 Appendix C. Present so
|
|
27
|
+
# derived namespaces can be computed (and asserted) from a published
|
|
28
|
+
# constant rather than a magic literal.
|
|
29
|
+
NAMESPACE_DNS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
|
|
30
|
+
|
|
31
|
+
# Canonical 8-4-4-4-12 hex form, case-insensitive.
|
|
32
|
+
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
33
|
+
|
|
34
|
+
# Hex-digit group lengths of the canonical textual form.
|
|
35
|
+
GROUP_LENGTHS = [8, 4, 4, 4, 12].freeze
|
|
36
|
+
private_constant :GROUP_LENGTHS
|
|
37
|
+
|
|
38
|
+
# Generate the version 5 UUID for +name+ within +namespace+.
|
|
39
|
+
#
|
|
40
|
+
# @param namespace [String] Namespace UUID in canonical 8-4-4-4-12 form
|
|
41
|
+
# @param name [String] The name to hash within the namespace
|
|
42
|
+
# @return [String] Lowercase canonical UUIDv5
|
|
43
|
+
# @raise [ArgumentError] if +namespace+ is not a canonical UUID
|
|
44
|
+
def self.generate(namespace, name)
|
|
45
|
+
digest = Digest::SHA1.digest(pack_uuid(namespace) + name_bytes(name))
|
|
46
|
+
format_uuid(apply_version_and_variant(digest.bytes.first(16)))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Is +value+ a canonical textual UUID?
|
|
50
|
+
#
|
|
51
|
+
# @param value [Object]
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def self.uuid?(value)
|
|
54
|
+
value.is_a?(String) && UUID_PATTERN.match?(value)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Convert a canonical UUID string into its 16 raw big-endian bytes.
|
|
58
|
+
#
|
|
59
|
+
# @param uuid [String]
|
|
60
|
+
# @return [String] binary, 16 bytes
|
|
61
|
+
# @raise [ArgumentError] if +uuid+ is not canonical
|
|
62
|
+
def self.pack_uuid(uuid)
|
|
63
|
+
raise ArgumentError, "Namespace is not a canonical UUID: #{uuid.inspect}" unless uuid?(uuid)
|
|
64
|
+
|
|
65
|
+
[uuid.delete('-')].pack('H32')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The name's UTF-8 bytes.
|
|
69
|
+
#
|
|
70
|
+
# UUIDv5 hashes *bytes*, so the encoding a Ruby String happens to
|
|
71
|
+
# carry is load-bearing: the same characters tagged US-ASCII and
|
|
72
|
+
# UTF-8 must hash identically or a re-embed under a different
|
|
73
|
+
# `LANG` would produce a different point id and duplicate rather
|
|
74
|
+
# than replace. ASCII-only strings already share their byte
|
|
75
|
+
# sequence with UTF-8; anything else is transcoded explicitly.
|
|
76
|
+
#
|
|
77
|
+
# ASCII-8BIT is the exception: it is not a character encoding to
|
|
78
|
+
# transcode *from*, it is "this string is already bytes". `String#encode`
|
|
79
|
+
# treats it as latin-1-ish and raises `UndefinedConversionError` on any
|
|
80
|
+
# high byte, so a binary-tagged name is passed through instead — which is
|
|
81
|
+
# what "hash the bytes" meant all along (STO-13).
|
|
82
|
+
#
|
|
83
|
+
# @param name [String]
|
|
84
|
+
# @return [String] binary
|
|
85
|
+
def self.name_bytes(name)
|
|
86
|
+
str = name.to_s
|
|
87
|
+
return str.b if str.encoding == Encoding::ASCII_8BIT
|
|
88
|
+
|
|
89
|
+
str = str.encode(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8 || str.ascii_only?
|
|
90
|
+
str.b
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Stamp the version (5) and RFC 4122 variant bits into the digest.
|
|
94
|
+
#
|
|
95
|
+
# Byte 6 holds the version in its high nibble; byte 8 holds the
|
|
96
|
+
# variant in its two high bits. Both are masked before being set so
|
|
97
|
+
# the surrounding hash bits survive.
|
|
98
|
+
#
|
|
99
|
+
# @param bytes [Array<Integer>] 16 digest bytes
|
|
100
|
+
# @return [Array<Integer>] the same array, mutated
|
|
101
|
+
def self.apply_version_and_variant(bytes)
|
|
102
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50 # version 5
|
|
103
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80 # variant RFC 4122
|
|
104
|
+
bytes
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Render 16 bytes as the canonical lowercase 8-4-4-4-12 string.
|
|
108
|
+
#
|
|
109
|
+
# @param bytes [Array<Integer>]
|
|
110
|
+
# @return [String]
|
|
111
|
+
def self.format_uuid(bytes)
|
|
112
|
+
hex = bytes.pack('C16').unpack1('H*')
|
|
113
|
+
offset = 0
|
|
114
|
+
GROUP_LENGTHS.map do |len|
|
|
115
|
+
group = hex[offset, len]
|
|
116
|
+
offset += len
|
|
117
|
+
group
|
|
118
|
+
end.join('-')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private_class_method :pack_uuid, :name_bytes, :apply_version_and_variant, :format_uuid
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|