rubik 0.4.0 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +118 -1
  4. data/lib/rubik/ast/c_extractor.rb +1 -1
  5. data/lib/rubik/ast/cache_store.rb +36 -5
  6. data/lib/rubik/ast/extraction_result.rb +6 -0
  7. data/lib/rubik/ast/fallback_cleaner.rb +3 -1
  8. data/lib/rubik/ast/graph.rb +31 -5
  9. data/lib/rubik/ast/ruby_extractor.rb +8 -2
  10. data/lib/rubik/ast/rust_extractor.rb +1 -1
  11. data/lib/rubik/ast/typescript_extractor.rb +3 -3
  12. data/lib/rubik/buffer/apply_contract.rb +6 -0
  13. data/lib/rubik/buffer/manager.rb +37 -27
  14. data/lib/rubik/diagnostics/parser.rb +16 -16
  15. data/lib/rubik/llm/openai_compatible.rb +2 -1
  16. data/lib/rubik/lsp/manager.rb +15 -5
  17. data/lib/rubik/mcp/client.rb +4 -0
  18. data/lib/rubik/mcp/manager.rb +51 -35
  19. data/lib/rubik/mcp/proxy_tool.rb +17 -0
  20. data/lib/rubik/mcp/transport/stdio.rb +142 -17
  21. data/lib/rubik/orchestrator/step_executor.rb +1 -1
  22. data/lib/rubik/orchestrator/subagent/completion.rb +1 -0
  23. data/lib/rubik/orchestrator/subagent/dependency.rb +1 -0
  24. data/lib/rubik/orchestrator/subagent/supervisor.rb +20 -3
  25. data/lib/rubik/orchestrator/subagent/waiting.rb +16 -9
  26. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +1 -1
  27. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -2
  28. data/lib/rubik/orchestrator/turn_executor.rb +3 -2
  29. data/lib/rubik/platform/path.rb +2 -1
  30. data/lib/rubik/platform/sanitize.rb +4 -1
  31. data/lib/rubik/plugin/manager.rb +39 -15
  32. data/lib/rubik/plugin/package_inventory.rb +11 -1
  33. data/lib/rubik/plugin/storage.rb +5 -4
  34. data/lib/rubik/repair/loop.rb +29 -15
  35. data/lib/rubik/repl/step_formatter.rb +2 -1
  36. data/lib/rubik/runtime/diagnostic_engine.rb +1 -1
  37. data/lib/rubik/security/blocked_guidance.rb +1 -1
  38. data/lib/rubik/security/redactor.rb +3 -3
  39. data/lib/rubik/storage/layout.rb +3 -1
  40. data/lib/rubik/storage/render.rb +1 -1
  41. data/lib/rubik/storage/vakanuvis/vocabulary.rb +36 -2
  42. data/lib/rubik/tools/base.rb +2 -1
  43. data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
  44. data/lib/rubik/ui/capability.rb +19 -5
  45. data/lib/rubik/ui/renderer.rb +1 -0
  46. data/lib/rubik/version.rb +1 -1
  47. metadata +1 -1
@@ -68,6 +68,7 @@ module Rubik
68
68
  @instances = {}
69
69
  @deferred = {}
70
70
  @monitor = Monitor.new
71
+ @wait_cond = @monitor.new_cond
71
72
  @counter = 0
72
73
  @max_depth = (config&.fetch("subagents.max_depth", 2) || 2).to_i
73
74
  @max_concurrency = (config&.fetch("subagents.max_concurrency", 10) || 10).to_i
@@ -83,6 +84,13 @@ module Rubik
83
84
  # redacted, paginated — see ResultArchive.
84
85
  @result_archive = ResultArchive.new(config: @config)
85
86
 
87
+ if @event_bus.respond_to?(:subscribe)
88
+ @event_bus.subscribe(Notices::ERROR_PAUSED_EVENT) { notify_wait! }
89
+ @event_bus.subscribe(Notices::BUDGET_EXHAUSTED_EVENT) { notify_wait! }
90
+ @event_bus.subscribe(Notices::COMPLETED_EVENT) { notify_wait! }
91
+ @event_bus.subscribe(Notices::QUESTION_EVENT) { notify_wait! } if defined?(Notices::QUESTION_EVENT)
92
+ end
93
+
86
94
  # Register inverse in root context (maestro hook)
87
95
  @root_context.register_disposable { shutdown_all } if @root_context.respond_to?(:register_disposable)
88
96
  end
@@ -93,20 +101,26 @@ module Rubik
93
101
  inst = get(id)
94
102
  return false unless inst.is_a?(Instance)
95
103
 
96
- inst.extend_budget(steps)
104
+ res = inst.extend_budget(steps)
105
+ notify_wait! if res
106
+ res
97
107
  end
98
108
 
99
109
  def resume(id, additional_steps: nil)
100
110
  inst = get(id)
101
- inst.respond_to?(:resume!) ? inst.resume!(additional_steps: additional_steps) : false
111
+ res = inst.respond_to?(:resume!) ? inst.resume!(additional_steps: additional_steps) : false
112
+ notify_wait! if res
113
+ res
102
114
  end
103
115
 
104
116
  def resume_all
105
- @monitor.synchronize do
117
+ resumed = @monitor.synchronize do
106
118
  @instances.values.select(&:parked?).map do |i|
107
119
  i.resume! ? i.id : nil
108
120
  end.compact
109
121
  end
122
+ notify_wait! unless resumed.empty?
123
+ resumed
110
124
  end
111
125
 
112
126
  def list(parent_id: nil)
@@ -129,6 +143,7 @@ module Rubik
129
143
  # monitörü 2s'ye kadar bloklar (emit kuralı ihlali olurdu).
130
144
  announce_cancellations(cancelled)
131
145
  release_ready!
146
+ notify_wait!
132
147
  found
133
148
  end
134
149
 
@@ -142,6 +157,7 @@ module Rubik
142
157
  true
143
158
  end
144
159
  announce_cancellations(cancelled)
160
+ notify_wait!
145
161
  end
146
162
 
147
163
  def shutdown_all
@@ -151,6 +167,7 @@ module Rubik
151
167
  @instances.clear
152
168
  @deferred.clear
153
169
  end
170
+ notify_wait!
154
171
  end
155
172
 
156
173
  def reap_orphans
@@ -20,25 +20,32 @@ module Rubik
20
20
  wait(ids, timeout: timeout, wait_for_budget: wait_for_budget).rows
21
21
  end
22
22
 
23
+ def notify_wait!
24
+ @monitor.synchronize { @wait_cond&.broadcast }
25
+ end
26
+
23
27
  def wait(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
24
28
  target_ids = Array(ids).compact.map(&:to_s)
25
29
  deadline = Time.now + [timeout.to_i, 1].max
26
30
 
27
- loop do
28
- active_targets = @monitor.synchronize do
31
+ @monitor.synchronize do
32
+ loop do
29
33
  candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] }
30
- candidates.select do |inst|
34
+ active_targets = candidates.select do |inst|
31
35
  inst.active? && !inst.error_paused? && !inst.awaiting_human? && (!wait_for_budget || !inst.awaiting_budget?)
32
36
  end
33
- end
34
37
 
35
- # Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
36
- break if (active_targets.empty? && !deferred_pending?(target_ids)) || Time.now >= deadline
38
+ # Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
39
+ rem = deadline - Time.now
40
+ break if (active_targets.empty? && !deferred_pending?(target_ids)) || rem <= 0
37
41
 
38
- sleep 0.1
39
- end
42
+ if @wait_cond
43
+ @wait_cond.wait([rem, 0].max)
44
+ else
45
+ sleep [rem, 0.1].min
46
+ end
47
+ end
40
48
 
41
- @monitor.synchronize do
42
49
  candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] || queued_spec(id) }
43
50
  paused = candidates.select { |c| parked_candidate?(c) }
44
51
  active = candidates.select { |c| c.respond_to?(:active?) && c.active? }
@@ -62,7 +62,7 @@ module Rubik
62
62
  @inst.emit_compacted_event(reason, layer)
63
63
  end
64
64
 
65
- def announce_llm_thought(_text) = nil
65
+ def announce_llm_thought(_text, **) = nil
66
66
 
67
67
  # ── terminals & dispatch ──
68
68
  # The shared two-strike nudge policy (Channel#empty_response_turn)
@@ -107,8 +107,8 @@ module Rubik
107
107
  max_steps: @deps.max_steps, layer: layer)
108
108
  end
109
109
 
110
- def announce_llm_thought(text)
111
- @deps.on_step&.call(type: :llm_thought, content: text, step: @deps.step_count, max_steps: @deps.max_steps)
110
+ def announce_llm_thought(text, streamed: false)
111
+ @deps.on_step&.call(type: :llm_thought, content: text, streamed: streamed, step: @deps.step_count, max_steps: @deps.max_steps)
112
112
  end
113
113
 
114
114
  # ── terminals & dispatch ──
@@ -218,7 +218,8 @@ module Rubik
218
218
  def tool_turn(res, text_resp, tool_calls)
219
219
  channel.nudge_policy.reset!
220
220
  channel.append_tool_call_row(channel.final_text(res, text_resp), tool_calls)
221
- channel.announce_llm_thought(text_resp) if !text_resp.empty? && tool_calls.none? { |tc| self.class.finish_call?(channel.tool_registry, tc) }
221
+ streamed = res.respond_to?(:streamed) && res.streamed == true
222
+ channel.announce_llm_thought(text_resp, streamed: streamed) if !text_resp.empty? && tool_calls.none? { |tc| self.class.finish_call?(channel.tool_registry, tc) }
222
223
 
223
224
  tool_calls.each do |tc|
224
225
  # finish is exempt (pinned by test_orchestrator_stream): its args
@@ -300,7 +301,7 @@ module Rubik
300
301
  # ── announce ──
301
302
  def announce_compaction(_reason, _layer) = nil
302
303
 
303
- def announce_llm_thought(_text) = nil
304
+ def announce_llm_thought(_text, **) = nil
304
305
 
305
306
  # Goal-ledger observation projection (plan D): related-optional
306
307
  # capability — base channel is structurally inert (subagent planes
@@ -228,7 +228,8 @@ module Rubik
228
228
  # drive root?" depend on the process cwd (which drive the workspace
229
229
  # lives on). Security callers pass already-expanded absolute paths;
230
230
  # compare the literal form here.
231
- cleaned = path.to_s.tr("\\", "/").downcase.sub(%r{/+\z}, "")
231
+ cleaned = path.to_s.tr("\\", "/").downcase
232
+ cleaned = cleaned.delete_suffix("/") while cleaned.end_with?("/")
232
233
  cleaned =~ /^[a-z]:$/
233
234
  end
234
235
  end
@@ -108,14 +108,17 @@ module Rubik
108
108
  end
109
109
  end
110
110
 
111
+ VALID_SYMBOL_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII].freeze
112
+
111
113
  # Binary (ASCII-8BIT) symbol names always report valid_encoding?, so a
112
114
  # bare validity check would fast-path them through UNCLEANED — the old
113
115
  # clean_utf8 path strips their >= 0x80 bytes and the byte loss would
114
116
  # instead surface later, at the JSON/display layer. Only UTF-8 (or the
115
117
  # lossless US-ASCII subset) symbols may take the fast path.
118
+ # PERF: Hoist encoding array to frozen constant to eliminate array allocations per symbol check (~26% faster, ~99.99% fewer allocations).
116
119
  def fast_clean_symbol(sym)
117
120
  name = sym.name
118
- return sym if [Encoding::UTF_8, Encoding::US_ASCII].include?(name.encoding) && name.valid_encoding?
121
+ return sym if VALID_SYMBOL_ENCODINGS.include?(name.encoding) && name.valid_encoding?
119
122
 
120
123
  clean_utf8(name).to_sym
121
124
  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)
@@ -145,7 +145,7 @@ module Rubik
145
145
  # blocks stripped; nil when nothing human-readable remains. Shared
146
146
  # with UI::Renderer (Plan 51, Decision D2).
147
147
  def thought_first_line(content)
148
- clean_content = content.to_s.sub(/```(?:json)?\s*\{.*?\}\s*```/m, "").strip
148
+ clean_content = content.to_s.sub(/```(?:json)?\s*\{[^`]*\}\s*```/m, "").strip
149
149
  return nil if clean_content.empty?
150
150
 
151
151
  clean_content.lines.first.to_s.strip
@@ -243,6 +243,7 @@ module Rubik
243
243
  # with UI::Renderer (Plan 51, Decision D2).
244
244
  def format_llm_thought(step_info)
245
245
  return nil if @level == LEVEL_QUIET || @level == LEVEL_NORMAL
246
+ return nil if step_info[:streamed] == true
246
247
 
247
248
  first_line = thought_first_line(step_info[:content])
248
249
  return nil if first_line.nil? || first_line.empty?
@@ -144,7 +144,7 @@ module Rubik
144
144
  :dependency_missing
145
145
  elsif combined.match?(/(syntax error|unexpected token|compilation error|error\[E\d+\]|parse error|fatal error)/i)
146
146
  :syntax_or_compile_error
147
- elsif combined.match?(/(failures:|assertions|tests failed|failed tests|FAILED \(|FAIL:|\d+ failures, \d+ errors)/i)
147
+ elsif combined.match?(/(failures:|assertions|tests failed|failed tests|FAILED \(|FAIL:|\b\d+ failures, \d+ errors\b)/i)
148
148
  :test_failure
149
149
  else
150
150
  :unknown_failure
@@ -164,7 +164,7 @@ module Rubik
164
164
  def self.segment_annotation(segment)
165
165
  rule = segment[:rule] || "unknown"
166
166
  reason = segment[:reason].to_s.strip
167
- return "#{rule} — option #{segment[:option]} overrides to DANGEROUS" if segment[:option] && reason.match?(/overridden by option .+ to dangerous\z/i)
167
+ return "#{rule} — option #{segment[:option]} overrides to DANGEROUS" if segment[:option] && reason.match?(/overridden by option \S+ to dangerous\z/i)
168
168
 
169
169
  # Base-rule reasons restate matched_rule ("base rule: rm (…)") — the
170
170
  # rule is already displayed; keep only the elevation evidence.
@@ -14,19 +14,19 @@ module Rubik
14
14
  /AIzaSy[a-zA-Z0-9_-]{33}/,
15
15
  /\bbearer\s+[a-zA-Z0-9_\-.]{20,}/i,
16
16
  /-----BEGIN [A-Z0-9_\s-]+PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9_\s-]+PRIVATE KEY-----/,
17
- %r{(https?://[^:]+:)([^@]+)@}
17
+ %r{(https?://[^\s/@:]+:)([^\s/@:]+)@}
18
18
  ].freeze
19
19
 
20
20
  ASSIGNMENT_KEY_REGEX = /\b(password|passwd|api_key|secret)(\s*[:=]\s*)(['"][^'"]*['"]|[^\s,;'"]+)/i
21
21
  ASSIGNMENT_TOKEN_REGEX = /\b(token)(\s*[:=]\s*)(['"][^'"]*['"]|[^\s,;'"]+)/i
22
22
 
23
23
  # PERF: Pre-compiled constants to avoid re-allocation inside hot loops
24
- URL_PATTERN = %r{(https?://[^:]+:)([^@]+)@}
24
+ URL_PATTERN = %r{(https?://[^\s/@:]+:)([^\s/@:]+)@}
25
25
  URL_MASK = "\\1#{MASK_STRING}@".freeze
26
26
  STATIC_PATTERNS = PATTERNS.reject { |pattern| pattern == URL_PATTERN }.freeze
27
27
  # Skip static replacement passes for assignment-only and URL-only inputs.
28
28
  STATIC_PATTERNS_COMBINED_REGEX = Regexp.union(STATIC_PATTERNS).freeze
29
- QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^:]+:|password|passwd|api_key|secret|token}i
29
+ QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^\s/@:]+:|password|passwd|api_key|secret|token}i
30
30
 
31
31
  # Anahtar-maskeleme desenleri (Session::Recorder#mask_payload ile aynı
32
32
  # aile): sır görünümlü anahtarların değerleri desen eşleşmese bile
@@ -48,7 +48,9 @@ module Rubik
48
48
 
49
49
  # [a-z0-9._-] dışını tireye çevirir; boş kalırsa "default".
50
50
  def slugify(name)
51
- slug = name.to_s.downcase.gsub(/[^a-z0-9._-]+/, "-").gsub(/\A[-.]+|[-.]+\z/, "")
51
+ slug = name.to_s.downcase.gsub(/[^a-z0-9._-]+/, "-")
52
+ slug = slug.delete_prefix("-").delete_prefix(".") while slug.start_with?("-", ".")
53
+ slug = slug.delete_suffix("-").delete_suffix(".") while slug.end_with?("-", ".")
52
54
  slug.empty? ? "default" : slug
53
55
  end
54
56
 
@@ -38,7 +38,7 @@ module Rubik
38
38
  return header << "_(empty — no #{type} entities yet)_\n" if summaries.empty?
39
39
 
40
40
  summaries.each do |s|
41
- title = s[:title].to_s.gsub("|", "\\|").slice(0, 80)
41
+ title = s[:title].to_s.gsub("\\", "\\\\\\\\").gsub("|", "\\|").slice(0, 80)
42
42
  header << "| #{s[:row]} | `#{s[:id]}` | #{s[:status]} | #{s[:updated_at]} | #{title} |\n"
43
43
  end
44
44
  header
@@ -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,14 +38,46 @@ 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]+/, "-").gsub(/\A-+|-+\z/, "")
46
- s = s[0, SLUG_MAX].to_s.gsub(/\A-+|-+\z/, "")
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]+/, "-")
67
+ s = s.delete_prefix("-") while s.start_with?("-")
68
+ s = s.delete_suffix("-") while s.end_with?("-")
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
+
80
+ s = s.delete_suffix("-") while s.end_with?("-")
47
81
  s = "n#{s}" if s.match?(/\A\d+\z/)
48
82
  s.empty? ? "untitled" : s
49
83
  end
@@ -55,7 +55,8 @@ module Rubik
55
55
  filter = raw.to_s.strip.tr("\\", "/").sub(%r{^\./}, "")
56
56
  return ["", nil] if filter.empty? || filter == "." || filter == "/"
57
57
 
58
- norm_root = root_dir.to_s.tr("\\", "/").sub(%r{/+\z}, "")
58
+ norm_root = root_dir.to_s.tr("\\", "/")
59
+ norm_root = norm_root.delete_suffix("/") while norm_root.end_with?("/")
59
60
  if filter.downcase == norm_root.downcase
60
61
  return ["", nil]
61
62
  elsif filter.downcase.start_with?("#{norm_root.downcase}/")