rubik 0.3.4 → 0.4.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +105 -2
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/graph.rb +20 -1
  7. data/lib/rubik/ast/graph_extensions.rb +89 -1
  8. data/lib/rubik/ast/python_extractor.rb +5 -5
  9. data/lib/rubik/ast/rust_extractor.rb +9 -2
  10. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  11. data/lib/rubik/buffer/manager.rb +78 -66
  12. data/lib/rubik/config/defaults.rb +1 -0
  13. data/lib/rubik/context/git_lister.rb +3 -1
  14. data/lib/rubik/diagnostics/parser.rb +53 -1
  15. data/lib/rubik/llm/anthropic.rb +5 -2
  16. data/lib/rubik/llm/openai_compatible.rb +83 -68
  17. data/lib/rubik/llm/registry.rb +81 -74
  18. data/lib/rubik/lsp/client.rb +320 -96
  19. data/lib/rubik/lsp/manager.rb +83 -12
  20. data/lib/rubik/lsp/server_command.rb +102 -0
  21. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  22. data/lib/rubik/orchestrator/step_executor.rb +138 -154
  23. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  24. data/lib/rubik/prompt/token_estimator.rb +16 -12
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +83 -68
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/buffer.rb +122 -88
  29. data/lib/rubik/repl/commands/mcp.rb +120 -92
  30. data/lib/rubik/repl/commands/session.rb +90 -59
  31. data/lib/rubik/repl/commands/workspace.rb +110 -94
  32. data/lib/rubik/repl/shell.rb +10 -1
  33. data/lib/rubik/runtime/discovery.rb +12 -6
  34. data/lib/rubik/security/classifier/classifier.rb +3 -1
  35. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  36. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  37. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  38. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  39. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  40. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  41. data/lib/rubik/tools/truncator.rb +21 -8
  42. data/lib/rubik/version.rb +1 -1
  43. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 220e106421fc0a5692890a2c3f3e7ba2296661c5ce60a96f62a3f8d5fcaa4c0a
4
- data.tar.gz: 1a287e603bb553b11ebc1e0187f4a0d00e8e6b17a68c005311bc144635a1696a
3
+ metadata.gz: 38e5cb4f6771198e0b32d67b8ec5fabd72ae886fbb7b1fcdccffddd4c9e1cedb
4
+ data.tar.gz: 820079f88cde99e3e82caa3214b1f6d11e9bcbe4ee773f879a50370b208e1287
5
5
  SHA512:
6
- metadata.gz: c02dbe25a9cc8a3af6a4c24187fc9115ad8ae5077c49e4842e2f03151ade01bedc4cc5e4709056a388a3934151b4a361422c30b1de92158e3d4c6d6d87c56743
7
- data.tar.gz: db9835fd0913e3fad74ab68bdbd25137df95544579b2beed718237d09699e378fb2cbfde7da9341785f407d3aea9a43b98aedec34df1305278b93ddc3434adb6
6
+ metadata.gz: a92d6f084b0694076803d7240559c0a0bb68c52f06543d4ab0ec5a21af071d419e47e1c42db95b3fd324e43ffbaffbdd7c41834032f7a279b09a42be5df23289
7
+ data.tar.gz: f0de254f34eac1cc3a373b6e3cd07f69192f90fda8e73269fd23ff12b27c2a66aaa737db114e30286404e47de5391f8938b23c9ef642e44330502f19e730ab53
data/ATTRIBUTION.md CHANGED
@@ -121,7 +121,7 @@ SOFTWARE.
121
121
  | **`strings-ansi`** | Piotr Murach | MIT | ANSI-aware string truncation and alignment for REPL formatters. |
122
122
  | **`reline`** | Hiroshi SHIBATA & Ruby Team | Ruby License / BSD-2-Clause | Line-editing, history navigation, autocompletion hooks, and headless terminal adaptation. |
123
123
  | **`prism`** | Kevin Newton & Ruby Core Team | MIT | High-performance Ruby parsing and AST extraction ([`Rubik::AST::RubyExtractor`](lib/rubik/ast/ruby_extractor.rb)). |
124
- | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,800+ unit, integration, and contract tests. |
124
+ | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,850+ unit, integration, and contract tests. |
125
125
  | **`rubocop`** | Bozhidar Batsov & contributors | MIT | Static code analysis, style enforcement, and architectural hygiene gates. |
126
126
  | **`benchmark`, `logger`, `pstore`, `reline`, `fiddle`** | Ruby core / rubygems teams | Ruby License / BSD-2-Clause | Ruby stdlib gems pinned explicitly in `rubik.gemspec` as they are unbundled from the default set in Ruby 3.4/3.5. |
127
127
 
data/CHANGELOG.md CHANGED
@@ -6,9 +6,112 @@ commits, 2026-08-23 → 2026-10-09, see `git log`).
6
6
 
7
7
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
- ## Unreleased
9
+ ## [0.4.0] - 2026-10-27
10
10
 
11
- Nothing yet.
11
+ A diagnostics-reach minor: the diagnostics engine gains native
12
+ parsers for Python tracebacks, TypeScript/JavaScript compiler
13
+ output, and C++ error streams, alongside an AST-graph correctness
14
+ restore, multibyte-safe truncation, and hot-path performance
15
+ streamlines.
16
+
17
+ ### Added
18
+
19
+ - **Diagnostics parser speaks Python, TypeScript/JavaScript, and
20
+ C++ (#286):** Python tracebacks (`File "...", line N` headers
21
+ paired with exception lines) parse into structured entries with
22
+ syntax-vs-type categorization; tsc-style `file(line,col): error
23
+ TSxxxx:` and `file:line:col: error` streams parse with error codes
24
+ and columns; C++ compilers route through the existing zig/c
25
+ parser. Unrecognized output still falls back to the generic
26
+ parser.
27
+
28
+ ### Fixed
29
+
30
+ - **AST plain-symbol lookups on file removal restored (#280, #282):**
31
+ PR #281's squash merge accidentally reverted the #280 work; the
32
+ reviewed restoration is back on main. Removing a file from the AST
33
+ graph again repairs only the plain symbol entries it owned, and
34
+ `SymbolMap` completes Hash parity (single-yield `#each`,
35
+ `#to_h` applied once, `#dig` raising `TypeError` on non-diggable
36
+ intermediates), with removal/parity edge cases covered by tests.
37
+ - **Truncator output is valid UTF-8 at every step (#284):** byteslice
38
+ cutting multibyte characters midway left invalid byte sequences in
39
+ intermediate results and undercounted `omitted_chars`; sliced
40
+ substrings are now scrubbed immediately in all strategies, so
41
+ line counts and character statistics stay accurate.
42
+
43
+ ### Performance
44
+
45
+ - **Python `extract_calls` is cheaper (#278):** the string scan and
46
+ filtering path is streamlined, cutting work in hot call-extraction.
47
+ - **TokenEstimator message field evaluation streamlined (#279):**
48
+ per-message field access in token estimation is evaluated more
49
+ cheaply.
50
+ - **Security classifier avoids per-call allocations (#283):** the
51
+ home-token list in `Engine#system_path?` is hoisted into a frozen
52
+ constant, removing an array allocation on every system-path check.
53
+
54
+ ### Changed
55
+
56
+ - **No-behavior-change decompositions (#281, #285):**
57
+ `StepExecutor#execute_registered_tool` is split into private
58
+ helpers, with the file kept under the 350-line hygiene cap, and
59
+ the LLM registry's `register_provider_spec` extracts
60
+ `build_provider_instance` and `register_models_catalog` helpers.
61
+
62
+ ## [0.3.5] - 2026-10-24
63
+
64
+ A vakanuvis-durability patch: the chronicler's digest pipeline stops
65
+ losing in-flight work and serving stale reads, LSP diagnostics and
66
+ document sync get hardened across the board, and AST call
67
+ extraction runs measurably cheaper.
68
+
69
+ ### Fixed
70
+
71
+ - **vakanuvis recall and digest are durable (#266, #267, #268,
72
+ #270, #271, #272):** in-place memory and wiki edits now register
73
+ as durable content updates for digest dirty tracking; superseded
74
+ records no longer surface as exact recall hits or pass smoke
75
+ checks; annal write failures and harness shutdown no longer
76
+ strand targeted digest jobs or events enqueued after the STOP
77
+ marker; the pending digest queue is bounded when the distiller is
78
+ unavailable; and the rogue-memory sweep preserves canonical
79
+ `mem.*` aliases, superseding raw duplicates in the canonical
80
+ line's favor.
81
+ - **LSP diagnostics and sync hardened (#252, closes #245–#251):**
82
+ diagnostic freshness is tracked per canonical document — stale
83
+ versioned publications are rejected and pending or unversioned
84
+ analysis is reported accurately; document state and LSP frame
85
+ writes are serialized, and transport failures propagate to wake
86
+ pending requests; text-document synchronization capabilities are
87
+ honored (unsupported dynamic registrations rejected, save
88
+ notifications sent when supported); server selection is separated
89
+ from document language IDs, with crash history retained across
90
+ short-lived restarts; and structured executable/argv server
91
+ configuration is supported through discovery, repair and client
92
+ startup without invoking a shell.
93
+ - **Anthropic API key resolution hardened (#261):** the reported
94
+ hardcoded-secret finding is confirmed a false positive (the
95
+ literal env-var name in error messages); `resolve_api_key` now
96
+ falls back to `ANTHROPIC_API_KEY` defensively when the configured
97
+ environment-variable name is blank or empty.
98
+
99
+ ### Performance
100
+
101
+ - **AST call extraction is cheaper (#277):** the Go, Rust and
102
+ TypeScript extractors use zero-capture lookahead regexes and
103
+ frozen keyword-exclusion Sets — 35–80% fewer allocations and
104
+ ~25–27% faster in hot call-extraction paths, verified
105
+ output-identical.
106
+
107
+ ### Changed
108
+
109
+ - **No-behavior-change decompositions (#254–#265):** oversized
110
+ methods across the OpenAI-compatible provider, goal-turn driving,
111
+ the MCP/workspace/session/model/context command registrations,
112
+ buffer registration and apply, subagent spawn and adapter process
113
+ management were split into helpers — same behavior, reviewable
114
+ units.
12
115
 
13
116
  ## [0.3.4] - 2026-10-23
14
117
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "timeout"
5
+ require_relative "../log"
5
6
 
6
7
  module Rubik
7
8
  module Adapter
@@ -19,17 +20,19 @@ module Rubik
19
20
  pid = wait_thr.pid
20
21
  begin
21
22
  stdin.close
22
- rescue
23
- nil
23
+ rescue StandardError => e
24
+ Rubik::Log.debug(:adapter, "stdin close failed during process execution: #{e.class}: #{e.message}")
24
25
  end
25
26
  t_out = Thread.new do
26
27
  stdout.read
27
- rescue
28
+ rescue StandardError => e
29
+ Rubik::Log.debug(:adapter, "stdout read failed during process execution: #{e.class}: #{e.message}")
28
30
  ""
29
31
  end
30
32
  t_err = Thread.new do
31
33
  stderr.read
32
- rescue
34
+ rescue StandardError => e
35
+ Rubik::Log.debug(:adapter, "stderr read failed during process execution: #{e.class}: #{e.message}")
33
36
  ""
34
37
  end
35
38
  begin
@@ -40,13 +43,13 @@ module Rubik
40
43
  ensure
41
44
  begin
42
45
  t_out.kill
43
- rescue
44
- nil
46
+ rescue StandardError => e
47
+ Rubik::Log.debug(:adapter, "t_out kill failed: #{e.class}: #{e.message}")
45
48
  end
46
49
  begin
47
50
  t_err.kill
48
- rescue
49
- nil
51
+ rescue StandardError => e
52
+ Rubik::Log.debug(:adapter, "t_err kill failed: #{e.class}: #{e.message}")
50
53
  end
51
54
  end
52
55
  end
@@ -8,6 +8,10 @@ module Rubik
8
8
  class GoExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ ["func", "if", "for", "return", "make", "append", "len", "cap", "panic", "recover"]
13
+ ).freeze
14
+
11
15
  def self.extract(file_path, content = nil)
12
16
  new(file_path, content).extract
13
17
  end
@@ -105,9 +109,12 @@ module Rubik
105
109
  )
106
110
  end
107
111
 
112
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
113
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~25% speedup, ~80% fewer allocations).
108
114
  def extract_calls(body, self_name)
109
- calls = body.scan(/\b([A-Za-z0-9_]+)\s*\(/).flatten.uniq
110
- calls.reject { |c| c == self_name || ["func", "if", "for", "return", "make", "append", "len", "cap", "panic", "recover"].include?(c) }
115
+ calls = body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/).uniq
116
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
117
+ calls
111
118
  end
112
119
 
113
120
  def find_closing_brace(start_idx)
@@ -216,10 +216,29 @@ module Rubik
216
216
  lang = CacheStore::LANGUAGE_MAP[ext] || "code"
217
217
 
218
218
  existing.each do |sym|
219
- @symbols.delete(sym.name) if @symbols[sym.name] == sym
220
219
  @qualified_symbols.delete("#{lang}:#{file_path}##{sym.name}")
221
220
  @qualified_symbols.delete("#{lang}:#{sym.file}##{sym.name}") if sym.file
222
221
  end
222
+
223
+ removed = {}.compare_by_identity
224
+ existing.each { |sym| removed[sym] = true }
225
+ existing.map(&:name).uniq.each do |name|
226
+ next unless removed.key?(@symbols[name])
227
+
228
+ replacement = _find_remaining_symbol_unlocked(name)
229
+ replacement ? @symbols[name] = replacement : @symbols.delete(name)
230
+ end
231
+ end
232
+
233
+ # The plain map keeps the most recently indexed definition of a name, so
234
+ # fall back to the most recently indexed file that still defines it.
235
+ def _find_remaining_symbol_unlocked(name)
236
+ @file_symbols.reverse_each do |_file_path, syms|
237
+ syms.reverse_each do |sym|
238
+ return sym if sym.name == name
239
+ end
240
+ end
241
+ nil
223
242
  end
224
243
 
225
244
  def _get_file_symbols_unlocked(file_path)
@@ -2,7 +2,9 @@
2
2
 
3
3
  module Rubik
4
4
  module AST
5
- class SymbolMap
5
+ class SymbolMap < Hash
6
+ FETCH_DEFAULT = Object.new.freeze
7
+
6
8
  def values
7
9
  qualified_values = @qualified_symbols.sort_by { |key, _symbol| key }.map(&:last)
8
10
  qualified_ids = {}.compare_by_identity
@@ -18,6 +20,92 @@ module Rubik
18
20
  def empty?
19
21
  super && @qualified_symbols.empty?
20
22
  end
23
+
24
+ def fetch(key, default = FETCH_DEFAULT)
25
+ if key?(key)
26
+ self[key]
27
+ elsif block_given?
28
+ yield(key)
29
+ elsif !default.equal?(FETCH_DEFAULT)
30
+ default
31
+ else
32
+ raise KeyError, "key not found: #{key.inspect}"
33
+ end
34
+ end
35
+
36
+ def include?(key)
37
+ key?(key)
38
+ end
39
+ alias member? include?
40
+
41
+ def keys
42
+ (super + @qualified_symbols.keys).uniq
43
+ end
44
+
45
+ def each(&)
46
+ return enum_for(:each) unless block_given?
47
+
48
+ combined = (Hash.instance_method(:to_a).bind_call(self) + @qualified_symbols.to_a).uniq(&:first)
49
+ combined.each(&)
50
+ self
51
+ end
52
+ alias each_pair each
53
+
54
+ def each_key(&)
55
+ return enum_for(:each_key) unless block_given?
56
+
57
+ keys.each(&)
58
+ self
59
+ end
60
+
61
+ def each_value(&)
62
+ return enum_for(:each_value) unless block_given?
63
+
64
+ values.each(&)
65
+ self
66
+ end
67
+
68
+ def assoc(key)
69
+ return nil unless key?(key)
70
+
71
+ [key, self[key]]
72
+ end
73
+
74
+ def to_h(&)
75
+ h = Hash.instance_method(:to_h).bind_call(self)
76
+ @qualified_symbols.each { |k, v| h[k] ||= v }
77
+ if block_given?
78
+ h.each_with_object({}) do |(k, v), acc|
79
+ nk, nv = yield(k, v)
80
+ acc[nk] = nv
81
+ end
82
+ else
83
+ h
84
+ end
85
+ end
86
+
87
+ def dig(key, *rest)
88
+ val = self[key]
89
+ return val if rest.empty? || val.nil?
90
+ return val.dig(*rest) if val.respond_to?(:dig)
91
+
92
+ raise TypeError, "#{val.class} does not have #dig method"
93
+ end
94
+
95
+ def slice(*keys_to_slice)
96
+ plain = {}
97
+ qual = {}
98
+ plain_key_check = Hash.instance_method(:key?).bind(self)
99
+ plain_fetch = Hash.instance_method(:[]).bind(self)
100
+ keys_to_slice.each do |k|
101
+ if plain_key_check.call(k)
102
+ plain[k] = plain_fetch.call(k)
103
+ elsif @qualified_symbols.key?(k)
104
+ qual[k] = @qualified_symbols[k]
105
+ end
106
+ end
107
+ SymbolMap.new(plain, qual)
108
+ end
21
109
  end
22
110
 
23
111
  class Graph
@@ -86,12 +86,12 @@ module Rubik
86
86
 
87
87
  private
88
88
 
89
+ # PERF: Uses zero-capture lookahead, scan with uniq, and delete_if filtering
90
+ # to eliminate Set instantiation and block allocations (~12% faster, ~26% fewer allocations).
89
91
  def extract_calls(body, self_name)
90
- calls = Set.new
91
- body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/) do |c|
92
- calls << c unless c == self_name || EXCLUDED_KEYWORDS.include?(c)
93
- end
94
- calls.to_a
92
+ calls = body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/).uniq
93
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
94
+ calls
95
95
  end
96
96
 
97
97
  def find_block_end(start_idx, base_indent)
@@ -8,6 +8,10 @@ module Rubik
8
8
  class RustExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ ["fn", "pub", "async", "if", "for", "while", "loop", "match", "return", "let", "mut"]
13
+ ).freeze
14
+
11
15
  def self.extract(file_path, content = nil)
12
16
  new(file_path, content).extract
13
17
  end
@@ -91,9 +95,12 @@ module Rubik
91
95
 
92
96
  private
93
97
 
98
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
99
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~27% speedup, ~80% fewer allocations).
94
100
  def extract_calls(body, self_name)
95
- calls = body.scan(/\b([A-Za-z0-9_]+)(?:!|\s*\()/).flatten.uniq
96
- calls.reject { |c| c == self_name || ["fn", "pub", "async", "if", "for", "while", "loop", "match", "return", "let", "mut"].include?(c) }
101
+ calls = body.scan(/\b[A-Za-z0-9_]+(?=!|\s*\()/).uniq
102
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
103
+ calls
97
104
  end
98
105
 
99
106
  def find_closing_brace(start_idx)
@@ -8,6 +8,14 @@ module Rubik
8
8
  class TypeScriptExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ [
13
+ "if", "else", "for", "while", "do", "switch", "case", "return", "catch", "function", "import",
14
+ "require", "typeof", "instanceof", "throw", "new", "try", "finally", "delete", "void", "yield",
15
+ "await", "super", "class", "interface", "type", "const", "let", "var"
16
+ ]
17
+ ).freeze
18
+
11
19
  def self.extract(file_path, content = nil)
12
20
  new(file_path, content).extract
13
21
  end
@@ -227,14 +235,12 @@ module Rubik
227
235
  [sym, next_i]
228
236
  end
229
237
 
238
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
239
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~27% speedup, ~35% fewer allocations).
230
240
  def extract_calls(body, self_name)
231
- calls = body.scan(/\b([A-Za-z0-9_$]+)\s*\(/).flatten.uniq
232
- keywords = [
233
- "if", "else", "for", "while", "do", "switch", "case", "return", "catch", "function", "import",
234
- "require", "typeof", "instanceof", "throw", "new", "try", "finally", "delete", "void", "yield",
235
- "await", "super", "class", "interface", "type", "const", "let", "var"
236
- ]
237
- calls.reject { |c| c == self_name || keywords.include?(c) }
241
+ calls = body.scan(/\b[A-Za-z0-9_$]+(?=\s*\()/).uniq
242
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
243
+ calls
238
244
  end
239
245
 
240
246
  def find_closing_brace(start_idx)
@@ -317,82 +317,20 @@ module Rubik
317
317
  @monitor.synchronize do
318
318
  applied_paths = []
319
319
  conflicts = []
320
- keys_to_apply = if target_paths
321
- Array(target_paths).map { |p| sanitize_path(p, for_write: true) }
322
- else
323
- @dirty_files.keys.dup
324
- end
325
-
320
+ keys_to_apply = determine_keys_to_apply(target_paths)
326
321
  effective_policy = (policy || external_modification_policy).to_s.downcase
327
322
 
328
323
  keys_to_apply.each_with_index do |rel_path, index|
329
324
  next unless @dirty_files.key?(rel_path)
325
+ next unless validate_and_clean_garbage(rel_path)
330
326
 
331
- unless PathValidator.valid?(rel_path)
332
- @dirty_files.delete(rel_path)
333
- @buffer.delete(rel_path)
334
- @original_hashes.delete(rel_path)
335
- @event_bus&.emit(:buffer_garbage_skipped, path: rel_path)
336
- next
337
- end
338
-
339
- baseline = @original_hashes[rel_path]
340
- current_disk = disk_hash(rel_path)
341
-
342
- conflict = if baseline
343
- current_disk != baseline
344
- else
345
- !current_disk.nil?
346
- end
347
-
348
- if conflict && effective_policy != "overwrite"
327
+ if external_conflict?(rel_path) && effective_policy != "overwrite"
349
328
  conflicts << rel_path
350
329
  @event_bus&.emit(:buffer_apply_conflict, paths: conflicts, path: rel_path)
351
330
  next
352
331
  end
353
332
 
354
- full_path = File.expand_path(rel_path, @root_dir)
355
-
356
- # TOCTOU close: re-verify containment immediately before the
357
- # atomic write — a symlink planted between write-time sanitize
358
- # and apply! must not redirect the write outside the workspace.
359
- # Fail loud: an untrustworthy path is an attack signal or a
360
- # mid-flight root misconfiguration, not a content problem; the
361
- # entry stays dirty for a retry after remediation.
362
- containment_kind, = containment_root(full_path)
363
- unless containment_kind
364
- @event_bus&.emit(:buffer_containment_blocked, path: rel_path)
365
- # Commit what this run has seen so "apply! rewrites
366
- # last_conflicts on every run" holds for readers (AutoApply's
367
- # report_conflicts) even on the failure path.
368
- @last_conflicts = conflicts
369
- raise apply_failure(
370
- "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
371
- "(possible symlink swap since buffering). Re-verify the workspace and retry.",
372
- kind: :containment_blocked, applied: applied_paths, failed: rel_path,
373
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
374
- )
375
- end
376
-
377
- content = @buffer[rel_path]
378
-
379
- begin
380
- Rubik::Platform.atomic_write(full_path, content)
381
- rescue StandardError => e
382
- @last_conflicts = conflicts
383
- raise apply_failure(
384
- "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
385
- "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
386
- "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
387
- kind: :write_failed, applied: applied_paths, failed: rel_path,
388
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
389
- ), cause: e
390
- end
391
-
392
- @original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
393
- @dirty_files.delete(rel_path)
394
- @buffer.delete(rel_path)
395
- applied_paths << rel_path
333
+ apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply, applied_paths: applied_paths, conflicts: conflicts)
396
334
  end
397
335
 
398
336
  @last_conflicts = conflicts
@@ -443,6 +381,80 @@ module Rubik
443
381
 
444
382
  private
445
383
 
384
+ def determine_keys_to_apply(target_paths)
385
+ if target_paths
386
+ Array(target_paths).map { |p| sanitize_path(p, for_write: true) }
387
+ else
388
+ @dirty_files.keys.dup
389
+ end
390
+ end
391
+
392
+ def validate_and_clean_garbage(rel_path)
393
+ return true if PathValidator.valid?(rel_path)
394
+
395
+ @dirty_files.delete(rel_path)
396
+ @buffer.delete(rel_path)
397
+ @original_hashes.delete(rel_path)
398
+ @event_bus&.emit(:buffer_garbage_skipped, path: rel_path)
399
+ false
400
+ end
401
+
402
+ def external_conflict?(rel_path)
403
+ baseline = @original_hashes[rel_path]
404
+ current_disk = disk_hash(rel_path)
405
+
406
+ if baseline
407
+ current_disk != baseline
408
+ else
409
+ !current_disk.nil?
410
+ end
411
+ end
412
+
413
+ def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:)
414
+ full_path = File.expand_path(rel_path, @root_dir)
415
+
416
+ # TOCTOU close: re-verify containment immediately before the
417
+ # atomic write — a symlink planted between write-time sanitize
418
+ # and apply! must not redirect the write outside the workspace.
419
+ # Fail loud: an untrustworthy path is an attack signal or a
420
+ # mid-flight root misconfiguration, not a content problem; the
421
+ # entry stays dirty for a retry after remediation.
422
+ containment_kind, = containment_root(full_path)
423
+ unless containment_kind
424
+ @event_bus&.emit(:buffer_containment_blocked, path: rel_path)
425
+ # Commit what this run has seen so "apply! rewrites
426
+ # last_conflicts on every run" holds for readers (AutoApply's
427
+ # report_conflicts) even on the failure path.
428
+ @last_conflicts = conflicts
429
+ raise apply_failure(
430
+ "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
431
+ "(possible symlink swap since buffering). Re-verify the workspace and retry.",
432
+ kind: :containment_blocked, applied: applied_paths, failed: rel_path,
433
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts
434
+ )
435
+ end
436
+
437
+ content = @buffer[rel_path]
438
+
439
+ begin
440
+ Rubik::Platform.atomic_write(full_path, content)
441
+ rescue StandardError => e
442
+ @last_conflicts = conflicts
443
+ raise apply_failure(
444
+ "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
445
+ "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
446
+ "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
447
+ kind: :write_failed, applied: applied_paths, failed: rel_path,
448
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts
449
+ ), cause: e
450
+ end
451
+
452
+ @original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
453
+ @dirty_files.delete(rel_path)
454
+ @buffer.delete(rel_path)
455
+ applied_paths << rel_path
456
+ end
457
+
446
458
  # Single source of boundary containment for both the sanitize_path
447
459
  # entry check and the apply! re-verification. Returns [kind, dir] with
448
460
  # kind :root / :external (dir is the LEXICAL matched root/external
@@ -171,6 +171,7 @@ module Rubik
171
171
  "distiller" => true,
172
172
  "max_steps" => 8,
173
173
  "flush_events" => 10,
174
+ "max_digest_jobs" => 100,
174
175
  "model" => nil,
175
176
  "retention" => "supersede",
176
177
  "root_turns" => true
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "../log"
4
5
 
5
6
  module Rubik
6
7
  module Context
@@ -27,7 +28,8 @@ module Rubik
27
28
  res.map! { |p| p.tr("\\", "/") } if out.include?("\\")
28
29
  res.uniq!
29
30
  res
30
- rescue StandardError
31
+ rescue StandardError => e
32
+ Rubik::Log.debug(:context, "git ls-files execution failed for #{root_dir}: #{e.class}: #{e.message}")
31
33
  nil
32
34
  end
33
35