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.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. 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
- def request(method, path, body = nil)
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 == '429' && retries < MAX_RETRIES
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
- raise Woods::Error, "Network error after #{attempts} retries: #{e.message}" if attempts > MAX_RETRIES
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}", status: response.code.to_i)
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
- def initialize(repo_url:)
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/main/#{file_path}"
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
- @builder = DocumentBuilder.new(repo_url: repo_url)
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
- @current_uris = Set.new
97
- @budget_exhausted = false
98
- build_uri_index
99
- reconcile_from_remote if @manifest.empty?
100
-
101
- synced = 0
102
- skipped = 0
103
- errors = []
104
-
105
- FULL_SYNC_TYPES.each do |type|
106
- break if @budget_exhausted
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
- result = sync_type(type)
109
- synced += result[:synced]
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
- PARTIAL_SYNC_TYPES.each do |type, max_count|
115
- break if @budget_exhausted
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
- result = sync_type_partial(type, max_count)
118
- synced += result[: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?('daily budget exhausted')
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
- # Daily budget-based rate limiter for the Unblocked API (1000 calls/day).
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 count.
16
- # Tracks usage against a configurable budget, warns when approaching the limit,
17
- # and raises when exhausted.
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 daily budget exhausted (#{@daily_budget} calls). " \
54
- 'Budget resets at midnight PST. Use UNBLOCKED_DAILY_BUDGET to adjust.'
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 the daily budget.
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
- tmp = "#{@path}.tmp"
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 file.
107
- # Every discard warns to stderr — the consequence (a full re-push) is
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(File.read(@path))
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
@@ -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