rubik 0.4.1 → 0.4.2

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.
@@ -91,6 +91,23 @@ module Rubik
91
91
  output: output_text
92
92
  )
93
93
  end
94
+ rescue ConnectionError, TimeoutError => e
95
+ tail = if @client.respond_to?(:stderr_tail)
96
+ @client.stderr_tail
97
+ elsif @client.respond_to?(:transport) && @client.transport.respond_to?(:stderr_tail)
98
+ @client.transport.stderr_tail
99
+ else
100
+ []
101
+ end
102
+ tail_lines = Array(tail).last(3)
103
+ stderr_detail = tail_lines.empty? ? "" : "; server stderr: #{tail_lines.join(' | ')}"
104
+ err_msg = "MCP server '#{@server_name}' is unreachable (#{e.class}: #{e.message}); its proxy tools are unavailable until reconnect or `!mcp` re-add#{stderr_detail}"
105
+ Rubik::Tools::Result.new(
106
+ tool_name: @name,
107
+ status: :error,
108
+ output: err_msg,
109
+ error: err_msg
110
+ )
94
111
  rescue StandardError => e
95
112
  Rubik::Tools::Result.new(
96
113
  tool_name: @name,
@@ -11,6 +11,11 @@ module Rubik
11
11
  module MCP
12
12
  module Transport
13
13
  class Stdio
14
+ # The child's stderr pipe is drained continuously (see
15
+ # #start_stderr_thread); this many recent lines stay queryable via
16
+ # #stderr_tail for diagnostics.
17
+ STDERR_TAIL_LINES = 20
18
+
14
19
  attr_reader :command, :args, :env, :root_dir, :running, :wait_thr
15
20
 
16
21
  def initialize(command, args: [], env: {}, root_dir: Dir.pwd, guard: nil)
@@ -26,14 +31,25 @@ module Rubik
26
31
  @stderr = nil
27
32
  @wait_thr = nil
28
33
  @reader_thr = nil
34
+ @stderr_thr = nil
35
+ @stderr_mutex = Mutex.new
36
+ @stderr_tail = []
29
37
  @mutex = Mutex.new
30
38
  @cv = ConditionVariable.new
31
39
  @responses = {}
32
40
  @closed = false
41
+ @generation = 0
42
+ @pending_requests = Set.new
33
43
  end
34
44
 
35
45
  def open
36
- return if @running
46
+ return if running?
47
+
48
+ # A previous incarnation whose child died on its own (the reader's
49
+ # ensure cleared @running) still holds pipe FDs and an unreaped
50
+ # wait thread; re-spawning over them leaks both. close is
51
+ # idempotent and a no-op when nothing lingers.
52
+ close if @wait_thr || @reader_thr || @stderr_thr
37
53
 
38
54
  cmd_parts = if @args.empty? && @command.is_a?(String)
39
55
  begin
@@ -78,21 +94,29 @@ module Rubik
78
94
  raise ConnectionError, "Failed to spawn MCP process '#{bin}': #{e.message}"
79
95
  end
80
96
 
81
- @running = true
82
- @closed = false
83
- @req_id = 0
84
- @responses.clear
97
+ @mutex.synchronize do
98
+ @running = true
99
+ @closed = false
100
+ @generation += 1
101
+ @req_id = 0
102
+ @responses.clear
103
+ @pending_requests.clear
104
+ end
105
+ @stderr_mutex.synchronize { @stderr_tail.clear }
85
106
 
107
+ start_stderr_thread
86
108
  start_reader_thread
87
109
  self
88
110
  end
89
111
  alias start open
90
112
 
91
113
  def send_request(method, params = {}, timeout: 10)
92
- raise ConnectionError, "MCP transport is not running" unless @running && @stdin
114
+ raise ConnectionError, "MCP transport is not running" unless running? && @stdin
93
115
 
94
116
  req_id = @mutex.synchronize do
95
117
  @req_id += 1
118
+ @pending_requests.add(@req_id)
119
+ @pending_requests.add(@req_id.to_s)
96
120
  @req_id
97
121
  end
98
122
 
@@ -103,8 +127,17 @@ module Rubik
103
127
  "params" => params || {}
104
128
  }
105
129
 
106
- write_message(msg)
107
- wait_for_response(req_id, timeout: timeout)
130
+ begin
131
+ write_message(msg)
132
+ wait_for_response(req_id, timeout: timeout)
133
+ ensure
134
+ @mutex.synchronize do
135
+ @pending_requests.delete(req_id)
136
+ @pending_requests.delete(req_id.to_s)
137
+ @responses.delete(req_id)
138
+ @responses.delete(req_id.to_s)
139
+ end
140
+ end
108
141
  end
109
142
 
110
143
  def send_notification(method, params = {})
@@ -120,10 +153,14 @@ module Rubik
120
153
  end
121
154
 
122
155
  def close
123
- return if @closed
156
+ child_died = false
157
+ @mutex.synchronize do
158
+ return if @closed
124
159
 
125
- @closed = true
126
- @running = false
160
+ child_died = !@running
161
+ @closed = true
162
+ @running = false
163
+ end
127
164
 
128
165
  begin
129
166
  @reader_thr&.kill
@@ -131,13 +168,32 @@ module Rubik
131
168
  nil
132
169
  end
133
170
 
171
+ begin
172
+ @stderr_thr&.kill
173
+ rescue StandardError
174
+ nil
175
+ end
176
+
134
177
  if @wait_thr&.alive?
135
- Rubik::Platform.kill_tree(@wait_thr.pid)
178
+ pid_to_kill = @wait_thr.pid
179
+ Rubik::Platform.kill_tree(pid_to_kill)
136
180
  begin
137
181
  @wait_thr.join(0.5)
138
182
  rescue StandardError
139
183
  nil
140
184
  end
185
+
186
+ if @wait_thr.alive?
187
+ abandoned_waiter = @wait_thr
188
+ Thread.new do
189
+ begin
190
+ abandoned_waiter.join(2.5)
191
+ rescue StandardError
192
+ nil
193
+ end
194
+ Rubik::Log.warn(:mcp, "abandoned child process pid #{pid_to_kill} did not exit within reaper window") if abandoned_waiter.alive?
195
+ end
196
+ end
141
197
  end
142
198
  @wait_thr = nil
143
199
 
@@ -155,8 +211,22 @@ module Rubik
155
211
  @reader_thr = nil
156
212
  end
157
213
 
214
+ begin
215
+ @stderr_thr&.join(0.5)
216
+ rescue StandardError
217
+ nil
218
+ ensure
219
+ @stderr_thr = nil
220
+ end
221
+
222
+ if child_died
223
+ hint = stderr_tail.last(5).join(" | ")[0, 512]
224
+ Rubik::Log.warn(:mcp, "MCP child process (pid #{pid}) had already exited; recent stderr: #{hint}") unless hint.empty?
225
+ end
226
+
158
227
  @mutex.synchronize do
159
228
  @responses.clear
229
+ @pending_requests.clear
160
230
  @cv.broadcast
161
231
  end
162
232
  end
@@ -164,7 +234,17 @@ module Rubik
164
234
  alias dispose close
165
235
 
166
236
  def running?
167
- @running && (@wait_thr.nil? || @wait_thr.alive?)
237
+ @mutex.synchronize do
238
+ @running && (@wait_thr.nil? || @wait_thr.alive?)
239
+ end
240
+ end
241
+
242
+ # Recent stderr output from the child (last STDERR_TAIL_LINES lines).
243
+ # The drain keeps the OS pipe buffer empty so a chatty server can
244
+ # never block on stderr write; the tail is what makes that output
245
+ # diagnosable instead of silently discarded.
246
+ def stderr_tail
247
+ @stderr_mutex.synchronize { @stderr_tail.dup }
168
248
  end
169
249
 
170
250
  def pid
@@ -198,7 +278,12 @@ module Rubik
198
278
  remaining = timeout - elapsed
199
279
  if remaining <= 0
200
280
  @responses.delete(req_id)
201
- raise TimeoutError, "Timed out waiting for MCP response to request #{req_id} after #{timeout}s"
281
+ # The stderr tail is the one place a wedged server explains
282
+ # itself (crash traces, logging storms); attach it so the
283
+ # timeout is diagnosable instead of opaque.
284
+ hint = stderr_tail.last(3).join(" | ")[0, 512]
285
+ suffix = hint.empty? ? "" : "; server stderr: #{hint}"
286
+ raise TimeoutError, "Timed out waiting for MCP response to request #{req_id} after #{timeout}s#{suffix}"
202
287
  end
203
288
 
204
289
  @cv.wait(@mutex, remaining)
@@ -217,8 +302,9 @@ module Rubik
217
302
  end
218
303
 
219
304
  def start_reader_thread
305
+ gen = @mutex.synchronize { @generation }
220
306
  @reader_thr = Thread.new do
221
- while @running && @stdout && !@stdout.closed?
307
+ while @mutex.synchronize { @running } && @stdout && !@stdout.closed?
222
308
  line = begin
223
309
  @stdout.gets
224
310
  rescue IOError, Errno::EBADF
@@ -238,9 +324,46 @@ module Rubik
238
324
  end
239
325
  ensure
240
326
  @mutex.synchronize do
241
- @running = false
242
- @cv.broadcast
327
+ if @generation == gen
328
+ @running = false
329
+ @cv.broadcast
330
+ end
331
+ end
332
+ end
333
+ end
334
+
335
+ # Drain the child's stderr on its own thread. Without a reader, a
336
+ # server that logs more than the OS pipe buffer holds (~64KB on
337
+ # Linux, far less on Windows) blocks inside its write() and stops
338
+ # answering requests — every call then times out with no hint that
339
+ # the child is wedged on its own logging. EOF on stderr must NOT
340
+ # stop the transport: a child may close stderr and keep serving.
341
+ def start_stderr_thread
342
+ @stderr_thr = Thread.new do
343
+ while @running && @stderr && !@stderr.closed?
344
+ line = begin
345
+ @stderr.gets
346
+ rescue IOError, Errno::EBADF
347
+ nil
348
+ end
349
+ break unless line
350
+
351
+ line = line.chomp
352
+ # Cap retained size per line: a newline-less flood must not
353
+ # turn the diagnostic tail into a memory hog.
354
+ line = line[0, 4096] if line.length > 4096
355
+
356
+ @stderr_mutex.synchronize do
357
+ @stderr_tail << line
358
+ @stderr_tail.shift while @stderr_tail.length > STDERR_TAIL_LINES
359
+ end
243
360
  end
361
+ rescue IOError, Errno::EBADF
362
+ nil
363
+ rescue StandardError => e
364
+ # A silently dead drainer would recreate the exact wedge this
365
+ # thread exists to prevent — make its death greppable.
366
+ Rubik::Log.warn(:mcp, "stderr drain thread exited unexpectedly (#{e.class}: #{e.message}); the MCP child may block on a full stderr pipe")
244
367
  end
245
368
  end
246
369
 
@@ -248,6 +371,8 @@ module Rubik
248
371
  return unless msg.is_a?(Hash) && msg.key?("id")
249
372
 
250
373
  @mutex.synchronize do
374
+ return unless @pending_requests.include?(msg["id"])
375
+
251
376
  @responses[msg["id"]] = msg
252
377
  @cv.broadcast
253
378
  end
@@ -100,30 +100,44 @@ module Rubik
100
100
  def load_dropin_plugins(runtime_manager:, storage:)
101
101
  plugins = storage.discover_plugins
102
102
  plugins.each do |plugin_meta|
103
- integrity = storage.verify_integrity(plugin_meta[:path], scope: plugin_meta[:scope])
103
+ content = File.exist?(plugin_meta[:path]) ? File.binread(plugin_meta[:path]) : nil
104
+ integrity = storage.verify_integrity(plugin_meta[:path], scope: plugin_meta[:scope], content: content)
104
105
  if integrity == :rejected
105
106
  @logger.warn("Skipping drop-in plugin '#{plugin_meta[:name]}' due to integrity check failure.")
106
107
  next
107
108
  end
108
109
 
109
- load_plugin_file(plugin_meta, runtime_manager: runtime_manager)
110
+ load_plugin_file(plugin_meta, runtime_manager: runtime_manager, content: content)
110
111
  end
111
112
  end
112
113
 
113
- def load_plugin_file(plugin_meta, runtime_manager:)
114
+ def load_plugin_file(plugin_meta, runtime_manager:, content: nil)
114
115
  name = plugin_meta[:name]
115
116
  component_name = "plugin_#{name}"
116
117
 
117
- unload_plugin(name, runtime_manager: runtime_manager) if runtime_manager.fibers.key?(component_name)
118
+ # If plugin is already running, prepare the replacement first (issue #309).
119
+ # Unloading before prepare would destroy the live plugin if the replacement has a syntax/prepare error.
120
+ already_running = runtime_manager.fibers.key?(component_name)
121
+ previous_def = Rubik::Plugin::Registry.get(name) if already_running
122
+ previous_fiber = runtime_manager.fibers[component_name] if already_running
123
+ was_active = previous_fiber&.state == :active
118
124
 
119
- definition = prepare_plugin_definition(plugin_meta)
125
+ definition = prepare_plugin_definition(plugin_meta, content: content)
120
126
  return false unless definition
121
127
 
128
+ unload_plugin(name, runtime_manager: runtime_manager) if already_running
129
+ Rubik::Plugin::Registry.register(definition)
130
+
122
131
  fiber = register_plugin_component(name, definition, plugin_meta, runtime_manager: runtime_manager)
123
- return false unless fiber
132
+ if fiber.nil? || fiber.state == :failed || (was_active && fiber.state == :pending)
133
+ if already_running && previous_def
134
+ @logger.error("Failed to activate replacement for plugin '#{name}' (state: #{fiber&.state}); rolling back to previous version")
135
+ runtime_manager.unregister_component(component_name)
136
+ restore_previous_plugin(name, previous_def, plugin_meta, runtime_manager: runtime_manager)
137
+ end
138
+ return false
139
+ end
124
140
 
125
- # Honest load result (issue #144): a failed activation (factory raised)
126
- # is reported as failure, not as a successful load.
127
141
  fiber.state != :failed
128
142
  end
129
143
 
@@ -159,14 +173,19 @@ module Rubik
159
173
  return false
160
174
  end
161
175
 
162
- integrity = storage.verify_integrity(target[:path], scope: target[:scope])
176
+ content = File.exist?(target[:path]) ? File.binread(target[:path]) : nil
177
+ integrity = storage.verify_integrity(target[:path], scope: target[:scope], content: content)
163
178
  if integrity == :rejected
164
179
  @logger.error("Reload of drop-in plugin '#{name}' refused: integrity check failed; previous version kept")
165
180
  return false
166
181
  end
167
182
 
183
+ component_name = "plugin_#{name}"
168
184
  previous = Rubik::Plugin::Registry.get(name)
169
- definition = prepare_plugin_definition(target)
185
+ previous_fiber = runtime_manager.fibers[component_name]
186
+ was_active = previous_fiber&.state == :active
187
+
188
+ definition = prepare_plugin_definition(target, content: content)
170
189
  unless definition
171
190
  @logger.error("Reload of drop-in plugin '#{name}' failed during preparation; previous version kept")
172
191
  return false
@@ -175,10 +194,11 @@ module Rubik
175
194
  unload_plugin(name, runtime_manager: runtime_manager)
176
195
  Rubik::Plugin::Registry.register(definition)
177
196
  fiber = register_plugin_component(name, definition, target, runtime_manager: runtime_manager)
178
- return true if fiber && fiber.state != :failed
197
+ return true if fiber && fiber.state != :failed && (!was_active || fiber.state == :active)
179
198
 
180
- @logger.error("Reload of drop-in plugin '#{name}' failed during activation; rolling back")
181
- runtime_manager.unregister_component("plugin_#{name}")
199
+ state_reason = fiber ? fiber.state.to_s : "nil fiber"
200
+ @logger.error("Reload of drop-in plugin '#{name}' failed during activation (ended #{state_reason}); rolling back")
201
+ runtime_manager.unregister_component(component_name)
182
202
  restore_previous_plugin(name, previous, target, runtime_manager: runtime_manager)
183
203
  false
184
204
  end
@@ -190,7 +210,7 @@ module Rubik
190
210
  # replacement can never cost the old version. Returns the new
191
211
  # Definition on success; nil on failure, with the previous registry
192
212
  # entry restored and the load-path claim released.
193
- def prepare_plugin_definition(plugin_meta)
213
+ def prepare_plugin_definition(plugin_meta, content: nil)
194
214
  name = plugin_meta[:name]
195
215
  path = plugin_meta[:path]
196
216
  load_path = plugin_meta[:load_path]
@@ -199,7 +219,11 @@ module Rubik
199
219
  claim_load_path(load_path)
200
220
 
201
221
  begin
202
- load path
222
+ if content
223
+ TOPLEVEL_BINDING.eval(content, path)
224
+ else
225
+ load path
226
+ end
203
227
  rescue StandardError, ScriptError => e
204
228
  @logger.error("Failed to parse drop-in plugin '#{name}': #{e.message}")
205
229
  rollback_prepared_definition(name, previous)
@@ -33,12 +33,22 @@ module Rubik
33
33
  e.message
34
34
  end
35
35
 
36
+ def secure_compare(first, second)
37
+ return false unless first.is_a?(String) && second.is_a?(String)
38
+ return false unless first.bytesize == second.bytesize
39
+
40
+ bytes = first.unpack("C*")
41
+ res = 0
42
+ second.each_byte { |byte| res |= byte ^ bytes.shift }
43
+ res.zero?
44
+ end
45
+
36
46
  # Audits additions, deletions, and modifications: any drift means the
37
47
  # package no longer matches the consented inventory.
38
48
  def drift(expected, current)
39
49
  added = current.keys - expected.keys
40
50
  removed = expected.keys - current.keys
41
- modified = (current.keys & expected.keys).reject { |f| current[f] == expected[f] }
51
+ modified = (current.keys & expected.keys).reject { |f| secure_compare(current[f], expected[f]) }
42
52
 
43
53
  parts = []
44
54
  parts << "added: #{added.sort.join(', ')}" unless added.empty?
@@ -6,6 +6,7 @@ require "time"
6
6
  # save_manifest atomik yazımı Platform.atomic_write kullanır; dosyanın tek başına
7
7
  # require edilmesi durumunda da çözümlenebilsin diye açık yüklenir.
8
8
  require_relative "../platform"
9
+ require_relative "../log"
9
10
  require_relative "package_inventory"
10
11
 
11
12
  module Rubik
@@ -31,7 +32,7 @@ module Rubik
31
32
  [project_plugins_dir, global_plugins_dir].each do |dir|
32
33
  FileUtils.mkdir_p(dir)
33
34
  rescue SystemCallError => e
34
- warn "[WARN] Failed to create plugin directory #{dir}: #{e.message}" if ENV["RUBIK_DEBUG"]
35
+ Rubik::Log.warn(:plugin, "Failed to create plugin directory #{dir}: #{e.class}: #{e.message}")
35
36
  end
36
37
  end
37
38
 
@@ -67,7 +68,7 @@ module Rubik
67
68
  save_manifest(dir, manifest)
68
69
  end
69
70
 
70
- def verify_integrity(path, scope: :project)
71
+ def verify_integrity(path, scope: :project, content: nil)
71
72
  return :trusted if ENV["RUBIK_TRUST_OVERLAYS"] == "1"
72
73
 
73
74
  dir = scope.to_sym == :global ? global_plugins_dir : project_plugins_dir
@@ -78,12 +79,12 @@ module Rubik
78
79
  basename = File.basename(path)
79
80
  manifest = load_manifest(dir)
80
81
 
81
- current_content = File.exist?(path) ? File.binread(path) : ""
82
+ current_content = content || (File.exist?(path) ? File.binread(path) : "")
82
83
  current_hash = PackageInventory.content_hash(current_content)
83
84
 
84
85
  if manifest.key?(basename)
85
86
  expected_hash = manifest[basename]["sha256"]
86
- if current_hash == expected_hash
87
+ if PackageInventory.secure_compare(current_hash, expected_hash)
87
88
  :verified
88
89
  else
89
90
  warn "[SECURITY] Integrity check failed for plugin '#{basename}': file modified since adding."
@@ -27,20 +27,34 @@ module Rubik
27
27
 
28
28
  @snapshot_store.create_snapshot(@buffer_manager)
29
29
 
30
- prompt = build_repair_prompt(current_diag, target_file, @attempts_made)
31
- res = @provider.complete([{ role: "user", content: prompt }])
32
- proposed_code = extract_code(res.text)
33
-
34
- @buffer_manager.write(target_file, proposed_code)
35
-
36
- # Verify syntax against in-memory buffer via tempfile
37
- check_res = check_buffer_syntax(target_file, lang)
38
-
39
- return { ok: true, attempts: @attempts_made, code: proposed_code } if check_res[:ok]
40
-
41
- current_diag = Rubik::Diagnostics::Parser.parse(check_res[:output], lang)
42
- @snapshot_store.undo(@buffer_manager)
43
-
30
+ begin
31
+ prompt = build_repair_prompt(current_diag, target_file, @attempts_made)
32
+ res = @provider.complete([{ role: "user", content: prompt }])
33
+ proposed_code = extract_code(res.text)
34
+
35
+ if proposed_code.empty?
36
+ @snapshot_store.undo(@buffer_manager)
37
+ next
38
+ end
39
+
40
+ @buffer_manager.write(target_file, proposed_code)
41
+
42
+ # Verify syntax against in-memory buffer via tempfile
43
+ check_res = check_buffer_syntax(target_file, lang)
44
+
45
+ return { ok: true, attempts: @attempts_made, code: proposed_code } if check_res[:ok]
46
+
47
+ if check_res[:unverified]
48
+ @snapshot_store.undo(@buffer_manager)
49
+ return { ok: false, attempts: @attempts_made, unverified: true, error: check_res[:error] }
50
+ end
51
+
52
+ current_diag = Rubik::Diagnostics::Parser.parse(check_res[:output], lang)
53
+ @snapshot_store.undo(@buffer_manager)
54
+ rescue StandardError => e
55
+ @snapshot_store.undo(@buffer_manager)
56
+ return { ok: false, attempts: @attempts_made, error: "#{e.class}: #{e.message}" }
57
+ end
44
58
  end
45
59
 
46
60
  { ok: false, attempts: @attempts_made, last_diagnostics: current_diag }
@@ -50,7 +64,7 @@ module Rubik
50
64
 
51
65
  def check_buffer_syntax(target_file, lang)
52
66
  adapter = @adapter_registry.get(lang.to_s)
53
- return { ok: true } unless adapter
67
+ return { ok: false, unverified: true, error: "No adapter registered for #{lang}" } unless adapter
54
68
 
55
69
  content = @buffer_manager.read(target_file)
56
70
  ext = File.extname(target_file)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Rubik
4
6
  module Storage
5
7
  module Vakanuvis
@@ -36,16 +38,45 @@ module Rubik
36
38
 
37
39
  module_function
38
40
 
41
+ def transliterate(text)
42
+ s = text.to_s.dup
43
+ s.gsub!(/[çÇ]/, "c")
44
+ s.gsub!(/[ğĞ]/, "g")
45
+ s.gsub!(/[ıİ]/, "i")
46
+ s.gsub!(/[öÖ]/, "o")
47
+ s.gsub!(/[şŞ]/, "s")
48
+ s.gsub!(/[üÜ]/, "u")
49
+ s.unicode_normalize(:nfd).gsub(/\p{M}/, "")
50
+ end
51
+
39
52
  # Deterministik slug: aynı metin ⇒ aynı slug ⇒ aynı ref (digest
40
53
  # makbuzlarının ve mem./wiki. ref'lerin yeniden oynatılabilirliği
41
54
  # buna dayanır). Yalnızca-rakam slug'a "n" öneki zorunlu: Store'un
42
55
  # çözümleyicisi "wiki.42" gibi iki parçalı tip.satır formlarını ref
43
56
  # alias'tan ÖNCE dener — çarpışma yanlış satırı ezmesi demekti.
57
+ # Unicode dönüştürme ve kısaltma/boşluk durumunda deterministik
58
+ # hash eki (issue #269) çakışmaları önler.
44
59
  def slug(text)
45
- s = text.to_s.downcase.gsub(/[^a-z0-9]+/, "-")
60
+ raw = text.to_s
61
+ return "untitled" if raw.strip.empty?
62
+
63
+ trans = transliterate(raw)
64
+ lossy = trans.match?(/[^\x00-\x7F]/)
65
+
66
+ s = trans.downcase.gsub(/[^a-z0-9]+/, "-")
46
67
  s = s.delete_prefix("-") while s.start_with?("-")
47
68
  s = s.delete_suffix("-") while s.end_with?("-")
48
- s = s[0, SLUG_MAX].to_s
69
+
70
+ hash = Digest::SHA256.hexdigest(raw)[0, 6]
71
+
72
+ return "untitled-#{hash}" if s.empty?
73
+
74
+ if s.length > SLUG_MAX || lossy
75
+ prefix = s[0, SLUG_MAX - 7].to_s
76
+ prefix = prefix.delete_suffix("-") while prefix.end_with?("-")
77
+ s = prefix.empty? ? "untitled-#{hash}" : "#{prefix}-#{hash}"
78
+ end
79
+
49
80
  s = s.delete_suffix("-") while s.end_with?("-")
50
81
  s = "n#{s}" if s.match?(/\A\d+\z/)
51
82
  s.empty? ? "untitled" : s