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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +105 -2
- data/lib/rubik/adapter/process.rb +11 -8
- data/lib/rubik/ast/go_extractor.rb +9 -2
- data/lib/rubik/ast/graph.rb +20 -1
- data/lib/rubik/ast/graph_extensions.rb +89 -1
- data/lib/rubik/ast/python_extractor.rb +5 -5
- data/lib/rubik/ast/rust_extractor.rb +9 -2
- data/lib/rubik/ast/typescript_extractor.rb +13 -7
- data/lib/rubik/buffer/manager.rb +78 -66
- data/lib/rubik/config/defaults.rb +1 -0
- data/lib/rubik/context/git_lister.rb +3 -1
- data/lib/rubik/diagnostics/parser.rb +53 -1
- data/lib/rubik/llm/anthropic.rb +5 -2
- data/lib/rubik/llm/openai_compatible.rb +83 -68
- data/lib/rubik/llm/registry.rb +81 -74
- data/lib/rubik/lsp/client.rb +320 -96
- data/lib/rubik/lsp/manager.rb +83 -12
- data/lib/rubik/lsp/server_command.rb +102 -0
- data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
- data/lib/rubik/orchestrator/step_executor.rb +138 -154
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
- data/lib/rubik/prompt/token_estimator.rb +16 -12
- data/lib/rubik/repl/commands/agent_context.rb +126 -104
- data/lib/rubik/repl/commands/agent_execution.rb +83 -68
- data/lib/rubik/repl/commands/agent_models.rb +112 -87
- data/lib/rubik/repl/commands/buffer.rb +122 -88
- data/lib/rubik/repl/commands/mcp.rb +120 -92
- data/lib/rubik/repl/commands/session.rb +90 -59
- data/lib/rubik/repl/commands/workspace.rb +110 -94
- data/lib/rubik/repl/shell.rb +10 -1
- data/lib/rubik/runtime/discovery.rb +12 -6
- data/lib/rubik/security/classifier/classifier.rb +3 -1
- data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
- data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
- data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
- data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
- data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
- data/lib/rubik/tools/builtins/lsp.rb +36 -8
- data/lib/rubik/tools/truncator.rb +21 -8
- data/lib/rubik/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38e5cb4f6771198e0b32d67b8ec5fabd72ae886fbb7b1fcdccffddd4c9e1cedb
|
|
4
|
+
data.tar.gz: 820079f88cde99e3e82caa3214b1f6d11e9bcbe4ee773f879a50370b208e1287
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
##
|
|
9
|
+
## [0.4.0] - 2026-10-27
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
calls.
|
|
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)
|
data/lib/rubik/ast/graph.rb
CHANGED
|
@@ -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 =
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
96
|
-
calls.
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
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)
|
data/lib/rubik/buffer/manager.rb
CHANGED
|
@@ -317,82 +317,20 @@ module Rubik
|
|
|
317
317
|
@monitor.synchronize do
|
|
318
318
|
applied_paths = []
|
|
319
319
|
conflicts = []
|
|
320
|
-
keys_to_apply =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|