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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +118 -1
- data/lib/rubik/ast/c_extractor.rb +1 -1
- data/lib/rubik/ast/cache_store.rb +36 -5
- data/lib/rubik/ast/extraction_result.rb +6 -0
- data/lib/rubik/ast/fallback_cleaner.rb +3 -1
- data/lib/rubik/ast/graph.rb +31 -5
- data/lib/rubik/ast/ruby_extractor.rb +8 -2
- data/lib/rubik/ast/rust_extractor.rb +1 -1
- data/lib/rubik/ast/typescript_extractor.rb +3 -3
- data/lib/rubik/buffer/apply_contract.rb +6 -0
- data/lib/rubik/buffer/manager.rb +37 -27
- data/lib/rubik/diagnostics/parser.rb +16 -16
- data/lib/rubik/llm/openai_compatible.rb +2 -1
- data/lib/rubik/lsp/manager.rb +15 -5
- data/lib/rubik/mcp/client.rb +4 -0
- data/lib/rubik/mcp/manager.rb +51 -35
- data/lib/rubik/mcp/proxy_tool.rb +17 -0
- data/lib/rubik/mcp/transport/stdio.rb +142 -17
- data/lib/rubik/orchestrator/step_executor.rb +1 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +1 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +1 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +20 -3
- data/lib/rubik/orchestrator/subagent/waiting.rb +16 -9
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +1 -1
- data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -2
- data/lib/rubik/orchestrator/turn_executor.rb +3 -2
- data/lib/rubik/platform/path.rb +2 -1
- data/lib/rubik/platform/sanitize.rb +4 -1
- data/lib/rubik/plugin/manager.rb +39 -15
- data/lib/rubik/plugin/package_inventory.rb +11 -1
- data/lib/rubik/plugin/storage.rb +5 -4
- data/lib/rubik/repair/loop.rb +29 -15
- data/lib/rubik/repl/step_formatter.rb +2 -1
- data/lib/rubik/runtime/diagnostic_engine.rb +1 -1
- data/lib/rubik/security/blocked_guidance.rb +1 -1
- data/lib/rubik/security/redactor.rb +3 -3
- data/lib/rubik/storage/layout.rb +3 -1
- data/lib/rubik/storage/render.rb +1 -1
- data/lib/rubik/storage/vakanuvis/vocabulary.rb +36 -2
- data/lib/rubik/tools/base.rb +2 -1
- data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
- data/lib/rubik/ui/capability.rb +19 -5
- data/lib/rubik/ui/renderer.rb +1 -0
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8907beb9d8441f6f497af9bd1b65a998c5dfa1d2857552943809ace7270e3759
|
|
4
|
+
data.tar.gz: a4b938aa55ff08037d63fc00d545a5039c06319c2ecee309e85d369d5d752621
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a034a02d012190d0f6fe02c1b7977628d60617fb0bca562c3b4799dd6512e5cb77809540d57ebe4eede099a0c8e724a679579cc75f4db85f9a8e59bd535d59ea
|
|
7
|
+
data.tar.gz: bc8162afcace2ab99fca16b749a5291abeac662e2255f5d798277931a7295a447bdc981bb5d9bc897afa80a15db8fe836771536c3441c110208546442b280622
|
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,900+ 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,6 +6,121 @@ 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
|
+
## [0.4.2] - 2026-10-29
|
|
10
|
+
|
|
11
|
+
A reliability patch: plugin file loads become transactional with
|
|
12
|
+
timing-safe integrity checks, MCP transport and handshake lifecycles
|
|
13
|
+
shed their remaining deadlocks and wedged-boot races, buffer apply
|
|
14
|
+
failures report faithfully, the repair loop can no longer return
|
|
15
|
+
unverified code as success, and vakanüvis slugs stop colliding across
|
|
16
|
+
scripts and long topics.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **MCP transport lifecycle deadlocks closed (#308):** server stderr is
|
|
21
|
+
drained on a dedicated thread with a bounded diagnostic tail (also
|
|
22
|
+
attached to `TimeoutError` messages), a dead transport incarnation is
|
|
23
|
+
reaped on re-open instead of leaking its file descriptors and waiter,
|
|
24
|
+
and a fast async boot can no longer park a dead thread handle that
|
|
25
|
+
permanently wedged the sync-boot gate after `stop_all`.
|
|
26
|
+
- **AST persistent cache no longer pins stale symbols (#308):** a failed
|
|
27
|
+
re-index is excluded from the cache snapshot so its protected stale
|
|
28
|
+
symbols are never validated under the current file's fresh digest;
|
|
29
|
+
per-entry extraction status is persisted and validated on load
|
|
30
|
+
(cache schema version 2, backward compatible with existing caches).
|
|
31
|
+
- **Plugin file loads are transactional (#334):** `load_plugin_file`
|
|
32
|
+
prepares the replacement before touching the running plugin so a
|
|
33
|
+
failed re-add no longer bricks the live plugin; reloading a previously
|
|
34
|
+
`:active` plugin that lands in `:pending` rolls back to the last
|
|
35
|
+
working version; the manifest TOCTOU window is closed by evaluating
|
|
36
|
+
the in-memory verified content via `TOPLEVEL_BINDING.eval`; manifest
|
|
37
|
+
SHA-256 checks use timing-safe `secure_compare`; and directory
|
|
38
|
+
creation failures are always reported with `Rubik::Log.warn`.
|
|
39
|
+
- **MCP handshake and boot races closed (#336):** the manager's
|
|
40
|
+
handshake lock is split so a slow connect no longer serializes
|
|
41
|
+
unrelated state reads; async boots carry epoch-tagged guards that
|
|
42
|
+
discard a superseded connection instead of adopting it; the stdio
|
|
43
|
+
transport gains a synchronized reaper and a bounded `stderr_tail`
|
|
44
|
+
diagnostic; and rollback paths log the client they stopped.
|
|
45
|
+
- **Repair loop no longer reports unverified code as success (#335):**
|
|
46
|
+
a missing diagnostics adapter, an empty LLM proposal, or an exception
|
|
47
|
+
mid-repair now undoes the buffer snapshot and returns `ok: false`
|
|
48
|
+
(flagging `unverified:` when syntax could not be checked) instead of
|
|
49
|
+
writing broken code; LSP rollback only restores clients that are
|
|
50
|
+
alive and running.
|
|
51
|
+
- **Buffer apply failures report faithfully (#333):** `apply!` commits
|
|
52
|
+
`last_applied` and `last_conflicts` in an `ensure` block so files
|
|
53
|
+
already written to disk before a failure are remembered and
|
|
54
|
+
`clean_buffer_message` no longer hides them; garbage-skipped edits
|
|
55
|
+
are tracked in `last_skipped`/`ApplyFailedError.skipped` and surfaced
|
|
56
|
+
in `ApplyContract.failure_lines` and tool outputs; and conflict event
|
|
57
|
+
payloads are frozen copies, closing the live-array aliasing mutation.
|
|
58
|
+
- **Vakanüvis slugs no longer collide across scripts and topics
|
|
59
|
+
(#332):** slug generation Unicode-folds and transliterates Turkish
|
|
60
|
+
characters (ç, ğ, ı, ö, ş, ü) and appends a deterministic 6-character
|
|
61
|
+
SHA-256 suffix when normalization is lossy, when the topic exceeds the
|
|
62
|
+
40-char limit, or when punctuation-only input yields the untitled
|
|
63
|
+
fallback — distinct topics can no longer resolve to the same storage
|
|
64
|
+
key.
|
|
65
|
+
- **UI capability detection unwraps capture streams (#331):**
|
|
66
|
+
`Capability.resolve_mode`, `color?`, and `spinner?` unwrap `TeeIO`
|
|
67
|
+
targets so a live terminal wrapped in a capture stream renders styled
|
|
68
|
+
dialogs and notices again; non-empty truthy `CLICOLOR_FORCE` values
|
|
69
|
+
(e.g. `true`, `yes`) now force ANSI color while `NO_COLOR` keeps
|
|
70
|
+
precedence.
|
|
71
|
+
|
|
72
|
+
### Performance
|
|
73
|
+
|
|
74
|
+
- **Prism `CallNode` filter lists hoisted to frozen Sets (#307):** the
|
|
75
|
+
Ruby extractor's hot walk no longer allocates two temporary Arrays per
|
|
76
|
+
call node (~140k fewer heap objects per 10k file extractions).
|
|
77
|
+
|
|
78
|
+
## [0.4.1] - 2026-10-29
|
|
79
|
+
|
|
80
|
+
A hardening patch: CodeQL-driven fixes remove ReDoS-prone regular
|
|
81
|
+
expressions and tighten output escaping, LSP client loss retracts its
|
|
82
|
+
coeffects symmetrically, and subagent waits wake reactively instead of
|
|
83
|
+
polling.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- **CodeQL scanning alerts resolved — ReDoS, escaping, and workflow
|
|
88
|
+
permissions:** polynomial-time regular expressions in AST extractors,
|
|
89
|
+
the diagnostics parser, storage slug generation, and path helpers are
|
|
90
|
+
replaced with deterministic string operations or bounded alternation;
|
|
91
|
+
`Render.listing` escapes backslashes before pipes in Markdown tables;
|
|
92
|
+
and the CI workflow's default `GITHUB_TOKEN` is floored to
|
|
93
|
+
`contents: read`.
|
|
94
|
+
- **Streamed thought duplication in REPL suppressed (#287):** when an LLM turn
|
|
95
|
+
with tool calls streams preliminary text live to the terminal, `TurnExecutor`
|
|
96
|
+
and UI renderers no longer reprint the identical thought line prefixed with
|
|
97
|
+
`💭` at verbose/debug log levels.
|
|
98
|
+
- **LSP client loss retracts the `lsp:<lang>` coeffect symmetrically
|
|
99
|
+
(#306):** a new `unregister_client_unlocked` runs on every client-removal
|
|
100
|
+
path — `stop_client`, `stop_all`, crash recovery, and dead-client
|
|
101
|
+
detection — so the context `unprovide` pairs with the original `provide`
|
|
102
|
+
and `registered_disposable_langs` tracking is cleared. Crash paths
|
|
103
|
+
previously leaked the coeffect and its dispose hook, leaving stale
|
|
104
|
+
services behind after a server died.
|
|
105
|
+
|
|
106
|
+
### Performance
|
|
107
|
+
|
|
108
|
+
- **Subagent waiters wake reactively via ConditionVariable (#299):** the
|
|
109
|
+
wait loop parks on a monitor-owned condition variable instead of polling
|
|
110
|
+
every 100 ms; every settle, pause, unpark, and launch transition
|
|
111
|
+
(completion, `release_ready!`, kill, resume, budget/error/question
|
|
112
|
+
events) broadcasts the wakeup. The sleep fallback keeps standalone
|
|
113
|
+
`WaitSupervisor` includers working.
|
|
114
|
+
- **LSP diagnostics aggregation merges outside the monitor (#294):**
|
|
115
|
+
`all_diagnostics` and `all_diagnostic_snapshots` collect client hashes
|
|
116
|
+
under the monitor and bulk-merge outside it, dropping the per-client
|
|
117
|
+
merge from the locked section; the result is always a fresh hash.
|
|
118
|
+
- **`compact.map` chains fused into `filter_map` (#290, #291, #297,
|
|
119
|
+
#302):** single-pass collection replaces the intermediate arrays in
|
|
120
|
+
`WaitSupervisor#wait`, subagent dependency normalization,
|
|
121
|
+
`dangerous_system_roots`, and `ConversationId.normalize_list`, with the
|
|
122
|
+
nil-skip semantics of the originals preserved exactly.
|
|
123
|
+
|
|
9
124
|
## [0.4.0] - 2026-10-27
|
|
10
125
|
|
|
11
126
|
A diagnostics-reach minor: the diagnostics engine gains native
|
|
@@ -846,5 +961,7 @@ installation section.
|
|
|
846
961
|
- Context: serialized index mutations, snapshot-outside-lock reconcile,
|
|
847
962
|
streaming-first truncation.
|
|
848
963
|
|
|
849
|
-
[Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.
|
|
964
|
+
[Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.4.2...HEAD
|
|
965
|
+
[0.4.2]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...v0.4.2
|
|
966
|
+
[0.4.1]: https://github.com/ozgurulukir/rubik/compare/v0.4.0...v0.4.1
|
|
850
967
|
[0.1.0]: https://github.com/ozgurulukir/rubik/releases/tag/v0.1.0
|
|
@@ -51,7 +51,7 @@ module Rubik
|
|
|
51
51
|
signature: line.split("{").first.to_s.strip,
|
|
52
52
|
body_loader: body_loader_for(line_num, end_line)
|
|
53
53
|
)
|
|
54
|
-
elsif (match = stripped.match(/^([A-Za-z0-9_
|
|
54
|
+
elsif (match = stripped.match(/^([A-Za-z0-9_*]+(?:\s+[A-Za-z0-9_*]+)*)\s+([A-Za-z0-9_]+)\s*\((.*?)\)\s*\{/))
|
|
55
55
|
fn_name = match[2]
|
|
56
56
|
unless ["if", "for", "while", "switch"].include?(fn_name)
|
|
57
57
|
end_line = find_closing_brace(i)
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "digest"
|
|
6
6
|
require_relative "symbol"
|
|
7
|
+
require_relative "extraction_result"
|
|
7
8
|
require_relative "../platform"
|
|
8
9
|
|
|
9
10
|
module Rubik
|
|
@@ -44,9 +45,11 @@ module Rubik
|
|
|
44
45
|
File.join(@home_dir, ".rubik", "cache", project_hash, "ast_index.json")
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
def save(file_symbols_hash)
|
|
48
|
+
def save(file_symbols_hash, statuses: {})
|
|
48
49
|
return false unless file_symbols_hash.is_a?(Hash)
|
|
49
50
|
|
|
51
|
+
statuses = {} unless statuses.is_a?(Hash)
|
|
52
|
+
|
|
50
53
|
data = {
|
|
51
54
|
"version" => SCHEMA_VERSION,
|
|
52
55
|
"last_scan" => Time.now.to_i,
|
|
@@ -84,12 +87,17 @@ module Rubik
|
|
|
84
87
|
|
|
85
88
|
# Bodies are intentionally NOT persisted: they dominate the cache
|
|
86
89
|
# size and are re-extracted lazily from source via Symbol#body.
|
|
90
|
+
# `status` carries the issue-#147 extraction status so the
|
|
91
|
+
# loaded graph can keep telling "parsed clean" from "partial" —
|
|
92
|
+
# entries predating the field read as :ok, the only status they
|
|
93
|
+
# could ever have been saved under.
|
|
87
94
|
data["files"][rel_path] = {
|
|
88
95
|
"language" => lang,
|
|
89
96
|
"mtime" => stat.mtime.to_i,
|
|
90
97
|
"mtime_f" => stat.mtime.to_f,
|
|
91
98
|
"size" => stat.size,
|
|
92
99
|
"digest" => digest,
|
|
100
|
+
"status" => (statuses[file_path] || :ok).to_s,
|
|
93
101
|
"symbols" => syms.map { |sym| sym.to_h(include_body: false) }
|
|
94
102
|
}
|
|
95
103
|
end
|
|
@@ -103,14 +111,22 @@ module Rubik
|
|
|
103
111
|
end
|
|
104
112
|
|
|
105
113
|
def load
|
|
114
|
+
load_with_statuses[0]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Same entries as #load, paired with the persisted per-file extraction
|
|
118
|
+
# status keyed by the same expanded paths (issue #147 across the cache
|
|
119
|
+
# boundary: a cache-loaded file must not report "never attempted").
|
|
120
|
+
def load_with_statuses
|
|
106
121
|
target_path = cache_path
|
|
107
|
-
return {} unless File.exist?(target_path)
|
|
122
|
+
return [{}, {}] unless File.exist?(target_path)
|
|
108
123
|
|
|
109
124
|
content = File.read(target_path)
|
|
110
125
|
data = JSON.parse(content)
|
|
111
|
-
return {} unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
|
|
126
|
+
return [{}, {}] unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
|
|
112
127
|
|
|
113
128
|
result = {}
|
|
129
|
+
statuses = {}
|
|
114
130
|
data["files"].each do |rel_path, cached_info|
|
|
115
131
|
full_path = File.expand_path(rel_path, @root_dir)
|
|
116
132
|
next unless valid_entry?(full_path, cached_info)
|
|
@@ -132,14 +148,29 @@ module Rubik
|
|
|
132
148
|
)
|
|
133
149
|
end
|
|
134
150
|
result[full_path] = symbols
|
|
151
|
+
raw_status = cached_info["status"]
|
|
152
|
+
# Entries predating the field had exactly one possible status;
|
|
153
|
+
# anything else unreadable is corrupt data, not a status — warn
|
|
154
|
+
# and fall back instead of materializing an arbitrary symbol that
|
|
155
|
+
# the symbols tool would report and save_to_cache would honor.
|
|
156
|
+
statuses[full_path] = if raw_status.nil?
|
|
157
|
+
:ok
|
|
158
|
+
else
|
|
159
|
+
status = raw_status.is_a?(String) ? raw_status.to_sym : nil
|
|
160
|
+
unless ExtractionResult::STATUSES.include?(status)
|
|
161
|
+
Rubik::Log.warn(:ast, "unknown cached extraction status #{raw_status.inspect} for #{rel_path}; treating as ok")
|
|
162
|
+
status = :ok
|
|
163
|
+
end
|
|
164
|
+
status
|
|
165
|
+
end
|
|
135
166
|
end
|
|
136
167
|
|
|
137
|
-
result
|
|
168
|
+
[result, statuses]
|
|
138
169
|
rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES, TypeError, NoMethodError
|
|
139
170
|
# Corrupt-but-valid JSON shapes raise TypeError/NoMethodError inside
|
|
140
171
|
# the entry loop; EACCES covers an unreadable cache file. All mean
|
|
141
172
|
# "cache unusable" → rebuild from scratch.
|
|
142
|
-
{}
|
|
173
|
+
[{}, {}]
|
|
143
174
|
end
|
|
144
175
|
|
|
145
176
|
def valid_entry?(file_path, cached_info)
|
|
@@ -33,5 +33,11 @@ module Rubik
|
|
|
33
33
|
[:ok, :partial].include?(status)
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
|
+
|
|
37
|
+
class ExtractionResult
|
|
38
|
+
# The closed status vocabulary, shared with persistence seams: the AST
|
|
39
|
+
# cache persists it per entry and validates against it on load.
|
|
40
|
+
STATUSES = [:ok, :partial, :failed, :unavailable].freeze
|
|
41
|
+
end
|
|
36
42
|
end
|
|
37
43
|
end
|
|
@@ -134,7 +134,9 @@ module Rubik
|
|
|
134
134
|
return true if k.negative?
|
|
135
135
|
return true if REGEX_PRECEDERS.include?(raw[k])
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
word_start = k
|
|
138
|
+
word_start -= 1 while word_start >= 0 && raw[word_start].match?(/\w/)
|
|
139
|
+
REGEX_KEYWORDS.include?(raw[(word_start + 1)..k])
|
|
138
140
|
end
|
|
139
141
|
|
|
140
142
|
def blank_symbol_keywords(line)
|
data/lib/rubik/ast/graph.rb
CHANGED
|
@@ -89,7 +89,7 @@ module Rubik
|
|
|
89
89
|
def load_from_cache(cache_store)
|
|
90
90
|
return 0 unless cache_store
|
|
91
91
|
|
|
92
|
-
loaded_files = cache_store.
|
|
92
|
+
loaded_files, statuses = cache_store.load_with_statuses
|
|
93
93
|
return 0 if loaded_files.nil? || loaded_files.empty?
|
|
94
94
|
|
|
95
95
|
@mutex.synchronize do
|
|
@@ -99,6 +99,7 @@ module Rubik
|
|
|
99
99
|
lang = CacheStore::LANGUAGE_MAP[ext] || "code"
|
|
100
100
|
|
|
101
101
|
@file_symbols[file_path] = syms.dup
|
|
102
|
+
@extraction_status[file_path] = statuses[file_path] || :ok
|
|
102
103
|
syms.each do |sym|
|
|
103
104
|
@symbols[sym.name] = sym
|
|
104
105
|
q_key = "#{lang}:#{file_path}##{sym.name}"
|
|
@@ -113,12 +114,32 @@ module Rubik
|
|
|
113
114
|
def save_to_cache(cache_store)
|
|
114
115
|
return false unless cache_store
|
|
115
116
|
|
|
116
|
-
snapshot =
|
|
117
|
-
|
|
117
|
+
snapshot = nil
|
|
118
|
+
statuses = nil
|
|
119
|
+
skipped_failed = 0
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
snapshot = @file_symbols.each_with_object({}) do |(file_path, symbols), copy|
|
|
122
|
+
# Failed-extraction honesty across the cache boundary: a :failed
|
|
123
|
+
# file's in-memory symbols are protected stale data or
|
|
124
|
+
# untrustworthy best-effort fallback. Persisting them would let
|
|
125
|
+
# CacheStore re-stat the CURRENT file and pin them under a fresh
|
|
126
|
+
# digest, making the staleness permanent — skip them and let the
|
|
127
|
+
# next boot re-extract.
|
|
128
|
+
if _get_extraction_status_unlocked(file_path) == :failed
|
|
129
|
+
skipped_failed += 1
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
118
133
|
copy[file_path] = symbols.dup
|
|
119
134
|
end
|
|
135
|
+
statuses = {}
|
|
136
|
+
snapshot.each_key do |file_path|
|
|
137
|
+
statuses[file_path] = _get_extraction_status_unlocked(file_path) || :ok
|
|
138
|
+
end
|
|
120
139
|
end
|
|
121
|
-
|
|
140
|
+
skipped = "cache snapshot excluded #{skipped_failed} file(s) with failed extractions; they re-extract on next boot"
|
|
141
|
+
Rubik::Log.warn(:ast, skipped) if skipped_failed.positive?
|
|
142
|
+
cache_store.save(snapshot, statuses: statuses)
|
|
122
143
|
end
|
|
123
144
|
|
|
124
145
|
def smart_context_for(symbol_name)
|
|
@@ -179,7 +200,6 @@ module Rubik
|
|
|
179
200
|
Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); keeping previously indexed symbols")
|
|
180
201
|
return existing
|
|
181
202
|
end
|
|
182
|
-
Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); no symbols indexed")
|
|
183
203
|
end
|
|
184
204
|
|
|
185
205
|
_remove_file_unlocked(file_path)
|
|
@@ -188,6 +208,12 @@ module Rubik
|
|
|
188
208
|
lang = CacheStore::LANGUAGE_MAP[ext] || "code"
|
|
189
209
|
|
|
190
210
|
extracted = result.symbols
|
|
211
|
+
if result.status == :failed
|
|
212
|
+
# Truthful accounting: the fallback symbols ARE indexed when
|
|
213
|
+
# nothing pre-existing needed protection — even when there are
|
|
214
|
+
# none, the log must not claim otherwise.
|
|
215
|
+
Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); indexing #{extracted.length} best-effort fallback symbols")
|
|
216
|
+
end
|
|
191
217
|
extracted.each do |sym|
|
|
192
218
|
@symbols[sym.name] = sym
|
|
193
219
|
q_key = "#{lang}:#{file_path}##{sym.name}"
|
|
@@ -8,6 +8,10 @@ module Rubik
|
|
|
8
8
|
module AST
|
|
9
9
|
class RubyExtractor
|
|
10
10
|
EXCLUDED_CALLS = Set.new(["def", "if", "unless", "case", "return", "raise", "puts", "p", "print", "require"]).freeze
|
|
11
|
+
# PERF: Hoist require and excluded call lists to frozen Sets to avoid per-node Array allocations in hot AST walks.
|
|
12
|
+
REQUIRE_CALLS = Set.new(["require", "require_relative"]).freeze
|
|
13
|
+
EXCLUDED_PRISM_CALLS = Set.new(["require", "require_relative", "raise", "puts", "p", "print"]).freeze
|
|
14
|
+
|
|
11
15
|
begin
|
|
12
16
|
require "prism"
|
|
13
17
|
PRISM_AVAILABLE = true
|
|
@@ -119,7 +123,8 @@ module Rubik
|
|
|
119
123
|
|
|
120
124
|
when Prism::CallNode
|
|
121
125
|
name = node.name.to_s
|
|
122
|
-
|
|
126
|
+
# PERF: Fast Set lookup avoids Array allocation on every CallNode
|
|
127
|
+
if REQUIRE_CALLS.include?(name)
|
|
123
128
|
line_num = node.location.start_line
|
|
124
129
|
args_node = node.arguments&.arguments
|
|
125
130
|
arg = args_node&.first&.slice.to_s
|
|
@@ -153,7 +158,8 @@ module Rubik
|
|
|
153
158
|
operator = curr.call_operator || "."
|
|
154
159
|
cname = "#{receiver}#{operator}#{cname}"
|
|
155
160
|
end
|
|
156
|
-
|
|
161
|
+
# PERF: Fast Set lookup avoids Array allocation per call site in method body
|
|
162
|
+
calls << cname unless EXCLUDED_PRISM_CALLS.include?(cname)
|
|
157
163
|
end
|
|
158
164
|
queue.concat(curr.compact_child_nodes) if curr.respond_to?(:compact_child_nodes)
|
|
159
165
|
end
|
|
@@ -62,7 +62,7 @@ module Rubik
|
|
|
62
62
|
elsif (match = stripped.match(/^impl(?:\s*<.*?>)?\s+([A-Za-z0-9_]+(?:\s+for\s+[A-Za-z0-9_]+)?)/))
|
|
63
63
|
current_impl = match[1].split(" for ").last.to_s.strip
|
|
64
64
|
impl_end_line = find_closing_brace(i)
|
|
65
|
-
elsif (match = stripped.match(/^(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+([A-Za-z0-9_]+)
|
|
65
|
+
elsif (match = stripped.match(/^(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+([A-Za-z0-9_]+)(?:<[^>]*>)?\s*\((.*?)\)/))
|
|
66
66
|
fn_name = match[1]
|
|
67
67
|
full_name = current_impl ? "#{current_impl}::#{fn_name}" : fn_name
|
|
68
68
|
end_line = stripped.end_with?(";") ? line_num : find_closing_brace(i)
|
|
@@ -166,7 +166,7 @@ module Rubik
|
|
|
166
166
|
end
|
|
167
167
|
|
|
168
168
|
def parse_function(line, stripped, line_num, idx)
|
|
169
|
-
if (match = stripped.match(/^(?:export\s+(?:default\s+)?)?(?:async\s+)?function
|
|
169
|
+
if (match = stripped.match(/^(?:export\s+(?:default\s+)?)?(?:async\s+)?function(?:\s*\*\s*|\s*)(?:([A-Za-z0-9_$]+)\s*)?\(/))
|
|
170
170
|
fn_name = match[1] || "default"
|
|
171
171
|
end_line = stripped.include?("{") ? find_closing_brace(idx) : find_statement_end(idx)
|
|
172
172
|
body_for_calls = extract_lines(line_num, end_line)
|
|
@@ -185,7 +185,7 @@ module Rubik
|
|
|
185
185
|
next_i = end_line > line_num ? end_line - 1 : idx
|
|
186
186
|
[sym, next_i]
|
|
187
187
|
elsif (match = stripped.match(
|
|
188
|
-
/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)(?:\s*:\s*[^=]
|
|
188
|
+
/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)(?:\s*:\s*[^=]*?[^=\s])?\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z0-9_$]+)\s*=>/
|
|
189
189
|
)) ||
|
|
190
190
|
(match = stripped.match(/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?function\b/))
|
|
191
191
|
fn_name = match[1]
|
|
@@ -210,7 +210,7 @@ module Rubik
|
|
|
210
210
|
|
|
211
211
|
def parse_method(line, stripped, line_num, idx, current_class)
|
|
212
212
|
return unless (match = stripped.match(
|
|
213
|
-
/^(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z0-9_$]+)\s*\([^)]*\)(?:\s*:\s*[^{]
|
|
213
|
+
/^(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z0-9_$]+)\s*\([^)]*\)(?:\s*:\s*[^{]*?[^{\s])?\s*\{/
|
|
214
214
|
))
|
|
215
215
|
|
|
216
216
|
method_name = match[1]
|
|
@@ -38,6 +38,12 @@ module Rubik
|
|
|
38
38
|
lines << " • Failed: #{failure.failed}#{cause ? " (#{cause.class}: #{cause.message})" : " (#{failure.kind})"}"
|
|
39
39
|
lines << " • Still dirty — a retry writes only these: #{failure.pending.join(', ')}" unless failure.pending.empty?
|
|
40
40
|
lines << " • Skipped conflicts (disk changed externally): #{failure.conflicts.join(', ')}" unless failure.conflicts.empty?
|
|
41
|
+
if failure.respond_to?(:skipped) && !failure.skipped.empty?
|
|
42
|
+
skipped_desc = failure.skipped.map do |s|
|
|
43
|
+
s.is_a?(Hash) ? "#{s[:path]} (#{s[:reason]})" : s.to_s
|
|
44
|
+
end.join(", ")
|
|
45
|
+
lines << " • Skipped invalid paths (anti-injection/garbage): #{skipped_desc}"
|
|
46
|
+
end
|
|
41
47
|
if sync
|
|
42
48
|
annotation = sync_annotation(failure, sync)
|
|
43
49
|
lines << annotation if annotation
|
data/lib/rubik/buffer/manager.rb
CHANGED
|
@@ -21,20 +21,22 @@ module Rubik
|
|
|
21
21
|
# the underlying error. `pending` lists this run's entries that are
|
|
22
22
|
# still dirty (a retry writes only those).
|
|
23
23
|
class ApplyFailedError < StandardError
|
|
24
|
-
attr_reader :kind, :applied, :failed, :pending, :conflicts
|
|
24
|
+
attr_reader :kind, :applied, :failed, :pending, :conflicts, :skipped
|
|
25
25
|
|
|
26
|
-
def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [])
|
|
26
|
+
def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [], skipped: [])
|
|
27
27
|
super(message)
|
|
28
28
|
@kind = kind
|
|
29
29
|
@applied = applied
|
|
30
30
|
@failed = failed
|
|
31
31
|
@pending = pending
|
|
32
32
|
@conflicts = conflicts
|
|
33
|
+
@skipped = skipped
|
|
33
34
|
end
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
class Manager
|
|
37
|
-
attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs,
|
|
38
|
+
attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs,
|
|
39
|
+
:guard, :last_conflicts, :last_skipped
|
|
38
40
|
attr_accessor :allow_external_writes, :config
|
|
39
41
|
|
|
40
42
|
def initialize(root_dir: Dir.pwd, additional_roots: [], allowed_external_dirs: [], allow_external_writes: false,
|
|
@@ -52,7 +54,8 @@ module Rubik
|
|
|
52
54
|
@buffer = {}
|
|
53
55
|
@dirty_files = {}
|
|
54
56
|
@original_hashes = {}
|
|
55
|
-
@last_conflicts = []
|
|
57
|
+
@last_conflicts = [].freeze
|
|
58
|
+
@last_skipped = [].freeze
|
|
56
59
|
@last_applied = nil
|
|
57
60
|
end
|
|
58
61
|
|
|
@@ -317,25 +320,35 @@ module Rubik
|
|
|
317
320
|
@monitor.synchronize do
|
|
318
321
|
applied_paths = []
|
|
319
322
|
conflicts = []
|
|
323
|
+
skipped = []
|
|
320
324
|
keys_to_apply = determine_keys_to_apply(target_paths)
|
|
321
325
|
effective_policy = (policy || external_modification_policy).to_s.downcase
|
|
322
326
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
327
|
+
begin
|
|
328
|
+
keys_to_apply.each_with_index do |rel_path, index|
|
|
329
|
+
next unless @dirty_files.key?(rel_path)
|
|
326
330
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
end
|
|
331
|
+
unless validate_and_clean_garbage(rel_path)
|
|
332
|
+
skipped << { path: rel_path, reason: :path_validator_rejected }
|
|
333
|
+
next
|
|
334
|
+
end
|
|
332
335
|
|
|
333
|
-
|
|
336
|
+
if external_conflict?(rel_path) && effective_policy != "overwrite"
|
|
337
|
+
conflicts << rel_path
|
|
338
|
+
@event_bus&.emit(:buffer_apply_conflict, paths: conflicts.dup.freeze, path: rel_path)
|
|
339
|
+
next
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply,
|
|
343
|
+
applied_paths: applied_paths, conflicts: conflicts, skipped: skipped)
|
|
344
|
+
end
|
|
345
|
+
ensure
|
|
346
|
+
@last_conflicts = conflicts.dup.freeze
|
|
347
|
+
@last_skipped = skipped.dup.freeze
|
|
348
|
+
@last_applied = { at: Time.now.utc, paths: applied_paths.dup.freeze } unless applied_paths.empty?
|
|
334
349
|
end
|
|
335
350
|
|
|
336
|
-
@
|
|
337
|
-
@last_applied = { at: Time.now.utc, paths: applied_paths.dup.freeze } unless applied_paths.empty?
|
|
338
|
-
@event_bus&.emit(:buffer_applied, paths: applied_paths) unless applied_paths.empty?
|
|
351
|
+
@event_bus&.emit(:buffer_applied, paths: applied_paths.dup.freeze) unless applied_paths.empty?
|
|
339
352
|
applied_paths
|
|
340
353
|
end
|
|
341
354
|
end
|
|
@@ -353,7 +366,8 @@ module Rubik
|
|
|
353
366
|
@buffer.clear
|
|
354
367
|
@dirty_files.clear
|
|
355
368
|
@original_hashes.clear
|
|
356
|
-
@last_conflicts.
|
|
369
|
+
@last_conflicts = [].freeze
|
|
370
|
+
@last_skipped = [].freeze
|
|
357
371
|
@last_applied = nil
|
|
358
372
|
end
|
|
359
373
|
end
|
|
@@ -410,7 +424,7 @@ module Rubik
|
|
|
410
424
|
end
|
|
411
425
|
end
|
|
412
426
|
|
|
413
|
-
def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:)
|
|
427
|
+
def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:, skipped:)
|
|
414
428
|
full_path = File.expand_path(rel_path, @root_dir)
|
|
415
429
|
|
|
416
430
|
# TOCTOU close: re-verify containment immediately before the
|
|
@@ -422,15 +436,11 @@ module Rubik
|
|
|
422
436
|
containment_kind, = containment_root(full_path)
|
|
423
437
|
unless containment_kind
|
|
424
438
|
@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
439
|
raise apply_failure(
|
|
430
440
|
"Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
|
|
431
441
|
"(possible symlink swap since buffering). Re-verify the workspace and retry.",
|
|
432
442
|
kind: :containment_blocked, applied: applied_paths, failed: rel_path,
|
|
433
|
-
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
443
|
+
pending: pending_after(keys_to_apply, index), conflicts: conflicts, skipped: skipped
|
|
434
444
|
)
|
|
435
445
|
end
|
|
436
446
|
|
|
@@ -439,13 +449,12 @@ module Rubik
|
|
|
439
449
|
begin
|
|
440
450
|
Rubik::Platform.atomic_write(full_path, content)
|
|
441
451
|
rescue StandardError => e
|
|
442
|
-
@last_conflicts = conflicts
|
|
443
452
|
raise apply_failure(
|
|
444
453
|
"Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
|
|
445
454
|
"#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
|
|
446
455
|
"#{pending_after(keys_to_apply, index).size} file(s) still dirty",
|
|
447
456
|
kind: :write_failed, applied: applied_paths, failed: rel_path,
|
|
448
|
-
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
457
|
+
pending: pending_after(keys_to_apply, index), conflicts: conflicts, skipped: skipped
|
|
449
458
|
), cause: e
|
|
450
459
|
end
|
|
451
460
|
|
|
@@ -494,11 +503,12 @@ module Rubik
|
|
|
494
503
|
|
|
495
504
|
# Build the structured partial-apply error with frozen report copies —
|
|
496
505
|
# a reader must see the state at raise time, not later mutations.
|
|
497
|
-
def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:)
|
|
506
|
+
def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:, skipped: [])
|
|
498
507
|
ApplyFailedError.new(
|
|
499
508
|
message, kind: kind,
|
|
500
509
|
applied: applied.dup.freeze, failed: failed,
|
|
501
|
-
pending: pending.dup.freeze, conflicts: conflicts.dup.freeze
|
|
510
|
+
pending: pending.dup.freeze, conflicts: conflicts.dup.freeze,
|
|
511
|
+
skipped: skipped.dup.freeze
|
|
502
512
|
)
|
|
503
513
|
end
|
|
504
514
|
|