rubik 0.1.6 → 0.3.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 +5 -1
- data/CHANGELOG.md +169 -0
- data/README.md +137 -255
- data/bin/rubik +9 -0
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/cli/workspace_trust.rb +78 -0
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/kernel/init.rb +15 -0
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/orchestrator/auto_apply.rb +1 -1
- data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
- data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
- data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
- data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
- data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
- data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
- data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
- data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
- data/lib/rubik/platform/process.rb +152 -3
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/prompt/context_builder.rb +54 -20
- data/lib/rubik/prompt/doctrine.rb +158 -22
- data/lib/rubik/prompt/token_estimator.rb +5 -3
- data/lib/rubik/prompt/tool_teachings.rb +19 -12
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
- data/lib/rubik/sandbox/driver/docker.rb +14 -60
- data/lib/rubik/sandbox/driver/podman.rb +13 -56
- data/lib/rubik/sandbox/driver/process.rb +12 -64
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +116 -7
- data/lib/rubik/storage/store.rb +52 -11
- data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/tools/builtins/storage.rb +8 -5
- data/lib/rubik/tools/builtins/subagent.rb +20 -53
- data/lib/rubik/tools/builtins/synthesis.rb +6 -1
- data/lib/rubik/tools/builtins.rb +1 -0
- data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
- data/lib/rubik/version.rb +1 -1
- metadata +15 -1
data/lib/rubik/ast/graph.rb
CHANGED
|
@@ -36,9 +36,17 @@ module Rubik
|
|
|
36
36
|
@symbols = {}
|
|
37
37
|
@qualified_symbols = {}
|
|
38
38
|
@file_symbols = Hash.new { |h, k| h[k] = [] }
|
|
39
|
+
@extraction_status = {}
|
|
39
40
|
@mutex = Mutex.new
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# Last extraction status recorded for a file (issue #147): :ok,
|
|
44
|
+
# :partial, :failed, or :unavailable. Lets callers tell "no symbols"
|
|
45
|
+
# apart from "extraction failed" without re-running the extractor.
|
|
46
|
+
def extraction_status(file_path)
|
|
47
|
+
@mutex.synchronize { @extraction_status[file_path] }
|
|
48
|
+
end
|
|
49
|
+
|
|
42
50
|
def symbols
|
|
43
51
|
@mutex.synchronize do
|
|
44
52
|
SymbolMap.new(@symbols.dup, @qualified_symbols.dup)
|
|
@@ -54,6 +62,7 @@ module Rubik
|
|
|
54
62
|
@symbols.clear
|
|
55
63
|
@qualified_symbols.clear
|
|
56
64
|
@file_symbols.clear
|
|
65
|
+
@extraction_status.clear
|
|
57
66
|
end
|
|
58
67
|
end
|
|
59
68
|
|
|
@@ -66,6 +75,7 @@ module Rubik
|
|
|
66
75
|
def remove_file(file_path)
|
|
67
76
|
@mutex.synchronize do
|
|
68
77
|
_remove_file_unlocked(file_path)
|
|
78
|
+
@extraction_status.delete(file_path)
|
|
69
79
|
end
|
|
70
80
|
end
|
|
71
81
|
|
|
@@ -218,12 +228,28 @@ module Rubik
|
|
|
218
228
|
end
|
|
219
229
|
|
|
220
230
|
def _index_file_unlocked(file_path, content = nil)
|
|
231
|
+
result = Extractor.extract_result(file_path, content)
|
|
232
|
+
@extraction_status[file_path] = result.status
|
|
233
|
+
|
|
234
|
+
# A failed extraction must not silently DELETE the file's previously
|
|
235
|
+
# indexed symbols (issue #147): stale-but-known beats unknown, and
|
|
236
|
+
# the log line makes the downgrade visible. The best-effort symbols
|
|
237
|
+
# are only indexed when there is nothing to protect.
|
|
238
|
+
if result.status == :failed
|
|
239
|
+
existing = _get_file_symbols_unlocked(file_path)
|
|
240
|
+
unless existing.empty?
|
|
241
|
+
Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); keeping previously indexed symbols")
|
|
242
|
+
return existing
|
|
243
|
+
end
|
|
244
|
+
Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); no symbols indexed")
|
|
245
|
+
end
|
|
246
|
+
|
|
221
247
|
_remove_file_unlocked(file_path)
|
|
222
248
|
|
|
223
|
-
extracted = Extractor.extract(file_path, content)
|
|
224
249
|
ext = File.extname(file_path).downcase
|
|
225
250
|
lang = CacheStore::LANGUAGE_MAP[ext] || "code"
|
|
226
251
|
|
|
252
|
+
extracted = result.symbols
|
|
227
253
|
extracted.each do |sym|
|
|
228
254
|
@symbols[sym.name] = sym
|
|
229
255
|
q_key = "#{lang}:#{file_path}##{sym.name}"
|
|
@@ -5,31 +5,71 @@ require_relative "base_walker"
|
|
|
5
5
|
module Rubik
|
|
6
6
|
module AST
|
|
7
7
|
module TreeSitter
|
|
8
|
+
# Shared C/C++ walker (issue #147). Supported scope, pinned by
|
|
9
|
+
# test_ast_cpp_extraction.rb:
|
|
10
|
+
# • includes, typedefs, and struct/union/class specifiers (nested
|
|
11
|
+
# classes qualify as Outer::Inner);
|
|
12
|
+
# • free functions and in-class member definitions/declarations,
|
|
13
|
+
# including constructors, destructors, and operator overloads;
|
|
14
|
+
# • namespaces: symbol names carry the namespace path (app::run);
|
|
15
|
+
# • templates: a template_declaration is unwrapped and its inner
|
|
16
|
+
# class/function is walked (specialization nuances are out of scope).
|
|
17
|
+
# Qualification follows the project convention: namespace/enclosing
|
|
18
|
+
# segments join with "::", an in-class member joins its class with "#"
|
|
19
|
+
# (app::Widget#run — the same Class#method shape the TypeScript and
|
|
20
|
+
# Python walkers use). Out-of-line definitions keep their written
|
|
21
|
+
# spelling (`void Widget::run` stays "Widget::run").
|
|
22
|
+
# Out of scope (documented boundary): macro bodies, free-function
|
|
23
|
+
# prototypes outside a class, local classes inside function bodies, and
|
|
24
|
+
# declarators hidden behind attributes/`decltype` syntax.
|
|
8
25
|
class CWalker < BaseWalker
|
|
26
|
+
RECORD_KINDS = {
|
|
27
|
+
"class_specifier" => :class,
|
|
28
|
+
"struct_specifier" => :struct,
|
|
29
|
+
"union_specifier" => :union
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
DECLARATOR_WRAPPERS = [
|
|
33
|
+
"function_declarator", "pointer_declarator", "reference_declarator", "parenthesized_declarator"
|
|
34
|
+
].freeze
|
|
35
|
+
NAME_NODE_KINDS = [
|
|
36
|
+
"identifier", "field_identifier", "type_identifier", "destructor_name", "operator_name"
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
9
39
|
def walk(root_node)
|
|
10
40
|
symbols = []
|
|
11
41
|
return symbols unless root_node
|
|
12
42
|
|
|
13
|
-
walk_children(root_node, symbols)
|
|
43
|
+
walk_children(root_node, symbols, [], member: false)
|
|
14
44
|
symbols
|
|
15
45
|
end
|
|
16
46
|
|
|
17
47
|
private
|
|
18
48
|
|
|
19
|
-
|
|
49
|
+
# `scope` is the enclosing declaration path (namespaces, then the
|
|
50
|
+
# innermost class, outermost first); `member` marks a class body so
|
|
51
|
+
# declarations are read as member declarations.
|
|
52
|
+
def walk_children(node, symbols, scope, member:)
|
|
20
53
|
(0...node.named_child_count).each do |i|
|
|
21
54
|
child = node.named_child(i)
|
|
22
55
|
case child.kind
|
|
23
56
|
when "preproc_include"
|
|
24
57
|
walk_include(child, symbols)
|
|
25
58
|
when "type_definition"
|
|
26
|
-
walk_typedef(child, symbols)
|
|
27
|
-
when
|
|
28
|
-
|
|
59
|
+
walk_typedef(child, symbols, scope)
|
|
60
|
+
when *RECORD_KINDS.keys
|
|
61
|
+
walk_record(child, symbols, scope)
|
|
62
|
+
when "namespace_definition"
|
|
63
|
+
walk_namespace(child, symbols, scope)
|
|
29
64
|
when "function_definition"
|
|
30
|
-
walk_function(child, symbols)
|
|
65
|
+
walk_function(child, symbols, scope, member: member)
|
|
66
|
+
when "field_declaration", "declaration"
|
|
67
|
+
walk_member_declaration(child, symbols, scope) if member
|
|
31
68
|
else
|
|
32
|
-
|
|
69
|
+
# Plain recursion also unwraps template_declaration nodes (their
|
|
70
|
+
# inner class/function is walked in place) and linkage
|
|
71
|
+
# specifications.
|
|
72
|
+
walk_children(child, symbols, scope, member: member)
|
|
33
73
|
end
|
|
34
74
|
end
|
|
35
75
|
end
|
|
@@ -44,7 +84,7 @@ module Rubik
|
|
|
44
84
|
)
|
|
45
85
|
end
|
|
46
86
|
|
|
47
|
-
def walk_typedef(node, symbols)
|
|
87
|
+
def walk_typedef(node, symbols, scope)
|
|
48
88
|
declarator = node.child_by_field_name("declarator")
|
|
49
89
|
name = if declarator
|
|
50
90
|
text_for(declarator).strip
|
|
@@ -54,32 +94,47 @@ module Rubik
|
|
|
54
94
|
end
|
|
55
95
|
return if name.empty?
|
|
56
96
|
|
|
97
|
+
qualified = qualify(scope, name)
|
|
57
98
|
sig = clean_signature(node)
|
|
58
99
|
symbols << build_symbol(
|
|
59
|
-
name:
|
|
100
|
+
name: qualified,
|
|
60
101
|
kind: :struct,
|
|
61
102
|
node: node,
|
|
62
|
-
signature: sig.empty? ? "typedef struct #{
|
|
103
|
+
signature: sig.empty? ? "typedef struct #{qualified}" : sig
|
|
63
104
|
)
|
|
64
105
|
end
|
|
65
106
|
|
|
66
|
-
def
|
|
107
|
+
def walk_record(node, symbols, scope)
|
|
67
108
|
name_node = node.child_by_field_name("name")
|
|
68
109
|
return unless name_node
|
|
69
110
|
|
|
70
111
|
name = text_for(name_node).strip
|
|
71
112
|
return if name.empty?
|
|
72
113
|
|
|
73
|
-
|
|
114
|
+
kind = RECORD_KINDS.fetch(node.kind, :struct)
|
|
115
|
+
qualified = qualify(scope, name)
|
|
116
|
+
sig = clean_signature(node)
|
|
74
117
|
symbols << build_symbol(
|
|
75
|
-
name:
|
|
76
|
-
kind:
|
|
118
|
+
name: qualified,
|
|
119
|
+
kind: kind,
|
|
77
120
|
node: node,
|
|
78
|
-
signature: sig
|
|
121
|
+
signature: sig.empty? ? "#{kind} #{qualified}" : sig
|
|
79
122
|
)
|
|
123
|
+
|
|
124
|
+
body = node.child_by_field_name("body")
|
|
125
|
+
walk_children(body, symbols, scope + [name], member: true) if body
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Namespace names extend the qualification path; everything inside is
|
|
129
|
+
# walked with the namespace prefix (app::run, app::Widget#run).
|
|
130
|
+
def walk_namespace(node, symbols, scope)
|
|
131
|
+
name_node = node.child_by_field_name("name")
|
|
132
|
+
ns_scope = name_node ? scope + [text_for(name_node).strip] : scope
|
|
133
|
+
body = node.child_by_field_name("body")
|
|
134
|
+
walk_children(body, symbols, ns_scope, member: false) if body
|
|
80
135
|
end
|
|
81
136
|
|
|
82
|
-
def walk_function(node, symbols)
|
|
137
|
+
def walk_function(node, symbols, scope, member:)
|
|
83
138
|
decl = node.child_by_field_name("declarator")
|
|
84
139
|
return unless decl
|
|
85
140
|
|
|
@@ -88,10 +143,11 @@ module Rubik
|
|
|
88
143
|
|
|
89
144
|
body = node.child_by_field_name("body")
|
|
90
145
|
calls = extract_calls(body, ["call_expression"])
|
|
146
|
+
qualified = member ? member_name(scope, fn_name) : qualify(scope, fn_name)
|
|
91
147
|
|
|
92
148
|
sig = clean_signature(node)
|
|
93
149
|
symbols << build_symbol(
|
|
94
|
-
name:
|
|
150
|
+
name: qualified,
|
|
95
151
|
kind: :function,
|
|
96
152
|
node: node,
|
|
97
153
|
signature: sig.empty? ? "#{fn_name}()" : sig,
|
|
@@ -99,22 +155,74 @@ module Rubik
|
|
|
99
155
|
)
|
|
100
156
|
end
|
|
101
157
|
|
|
158
|
+
# In-class member DECLARATIONS without a body: `void run();`, `C();`,
|
|
159
|
+
# `~C();`, `void operator+(int);`, `operator bool() const;`. Plain
|
|
160
|
+
# field declarations (`int x;`) are not symbols.
|
|
161
|
+
def walk_member_declaration(node, symbols, scope)
|
|
162
|
+
return if scope.empty?
|
|
163
|
+
|
|
164
|
+
decl = node.child_by_field_name("declarator")
|
|
165
|
+
return unless decl
|
|
166
|
+
|
|
167
|
+
name = if decl.kind == "operator_cast"
|
|
168
|
+
type_node = decl.child_by_field_name("type")
|
|
169
|
+
type_node ? "operator #{text_for(type_node).strip}" : nil
|
|
170
|
+
elsif callable_declarator?(decl)
|
|
171
|
+
extract_function_name(decl)
|
|
172
|
+
end
|
|
173
|
+
return if name.nil? || name.empty?
|
|
174
|
+
|
|
175
|
+
sig = clean_signature(node)
|
|
176
|
+
symbols << build_symbol(
|
|
177
|
+
name: member_name(scope, name),
|
|
178
|
+
kind: :function,
|
|
179
|
+
node: node,
|
|
180
|
+
signature: sig.empty? ? "#{name}()" : sig
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def callable_declarator?(decl)
|
|
185
|
+
current = decl
|
|
186
|
+
while current
|
|
187
|
+
return true if current.kind == "function_declarator"
|
|
188
|
+
|
|
189
|
+
break unless DECLARATOR_WRAPPERS.include?(current.kind)
|
|
190
|
+
|
|
191
|
+
current = current.child_by_field_name("declarator")
|
|
192
|
+
end
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Resolves a callable's name from its declarator chain: identifiers
|
|
197
|
+
# (C and free functions), field_identifier (in-class methods),
|
|
198
|
+
# destructor_name (~C), operator_name (operator+), and
|
|
199
|
+
# qualified_identifier (out-of-line Widget::run). Only known
|
|
200
|
+
# declarator wrappers are descended; anything else ends the walk with
|
|
201
|
+
# nil — a wrong name is worse than a missing symbol.
|
|
102
202
|
def extract_function_name(declarator_node)
|
|
103
203
|
current = declarator_node
|
|
104
204
|
while current
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
current = current.child_by_field_name("declarator")
|
|
108
|
-
when "identifier"
|
|
109
|
-
return text_for(current).strip
|
|
110
|
-
else
|
|
111
|
-
# Try first identifier child
|
|
112
|
-
ident = current.children.find { |c| c.kind == "identifier" }
|
|
113
|
-
return text_for(ident).strip if ident
|
|
205
|
+
kind = current.kind
|
|
206
|
+
return text_for(current).strip if NAME_NODE_KINDS.include?(kind) || kind == "qualified_identifier"
|
|
114
207
|
|
|
115
|
-
|
|
116
|
-
|
|
208
|
+
return nil unless DECLARATOR_WRAPPERS.include?(kind)
|
|
209
|
+
|
|
210
|
+
current = current.child_by_field_name("declarator")
|
|
117
211
|
end
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Namespace/enclosing-class segments join with "::"; an in-class
|
|
216
|
+
# member joins its class with "#" (see the class comment).
|
|
217
|
+
def qualify(scope, name)
|
|
218
|
+
scope.empty? ? name : "#{scope.join('::')}::#{name}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def member_name(scope, name)
|
|
222
|
+
return name if scope.empty?
|
|
223
|
+
|
|
224
|
+
*outer, klass = scope
|
|
225
|
+
(outer + ["#{klass}##{name}"]).join("::")
|
|
118
226
|
end
|
|
119
227
|
end
|
|
120
228
|
end
|
|
@@ -7,6 +7,7 @@ rescue LoadError
|
|
|
7
7
|
TREE_SITTER_AVAILABLE = false
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
require_relative "extraction_result"
|
|
10
11
|
require_relative "treesitter/base_walker"
|
|
11
12
|
require_relative "treesitter/python_walker"
|
|
12
13
|
require_relative "treesitter/go_walker"
|
|
@@ -55,37 +56,64 @@ module Rubik
|
|
|
55
56
|
Thread.current[:rubik_ts_parsers]&.clear
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# Array-or-nil seam (unchanged contract for every existing caller):
|
|
60
|
+
# nil means "fall back to the regex extractor".
|
|
58
61
|
def extract(file_path, content = nil)
|
|
59
|
-
|
|
62
|
+
result = extract_result(file_path, content)
|
|
63
|
+
result.trustworthy? ? result.symbols : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Structured outcome (issue #147): status :ok / :partial / :failed /
|
|
67
|
+
# :unavailable plus best-effort symbols, so an empty result is never
|
|
68
|
+
# confused with a broken parse. See AST::ExtractionResult.
|
|
69
|
+
def extract_result(file_path, content = nil)
|
|
70
|
+
return unavailable_result unless available?
|
|
60
71
|
|
|
61
72
|
ext = File.extname(file_path).downcase
|
|
62
73
|
mapping = LANGUAGE_MAP[ext]
|
|
63
|
-
return
|
|
74
|
+
return unavailable_result unless mapping
|
|
64
75
|
|
|
65
76
|
code = content || (File.exist?(file_path) ? File.read(file_path) : "")
|
|
66
|
-
return [] if code.empty?
|
|
77
|
+
return ExtractionResult.new(symbols: [], status: :ok, source: :tree_sitter) if code.empty?
|
|
67
78
|
|
|
68
79
|
code = code.encode("UTF-8", invalid: :replace, undef: :replace) unless code.valid_encoding?
|
|
69
80
|
|
|
70
81
|
parser = parser_for(mapping[:parser])
|
|
71
|
-
return
|
|
82
|
+
return failed_result("no parser for #{mapping[:parser]}") unless parser
|
|
72
83
|
|
|
73
84
|
tree = parser.parse(code)
|
|
74
|
-
return
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
symbols
|
|
85
|
+
return failed_result("parser returned no tree") unless tree&.root_node
|
|
86
|
+
|
|
87
|
+
symbols = mapping[:walker].new(file_path, code).walk(tree.root_node) || []
|
|
88
|
+
if root_has_error?(tree.root_node)
|
|
89
|
+
return failed_result("tree-sitter reported syntax errors with no symbols") if symbols.empty?
|
|
90
|
+
|
|
91
|
+
return ExtractionResult.new(symbols: symbols, status: :partial, source: :tree_sitter,
|
|
92
|
+
detail: "tree-sitter reported syntax errors")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
ExtractionResult.new(symbols: symbols, status: :ok, source: :tree_sitter)
|
|
86
96
|
rescue StandardError, SystemStackError, ScriptError => e
|
|
87
97
|
warn "[WARN] TreeSitter extraction failed for #{file_path}: #{e.message}" if ENV["RUBIK_DEBUG"]
|
|
88
|
-
|
|
98
|
+
failed_result("#{e.class}: #{e.message}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def root_has_error?(node)
|
|
104
|
+
if node.respond_to?(:has_error?)
|
|
105
|
+
node.has_error?
|
|
106
|
+
elsif node.respond_to?(:has_error)
|
|
107
|
+
node.has_error
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def failed_result(detail)
|
|
112
|
+
ExtractionResult.new(symbols: [], status: :failed, source: :none, detail: detail)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def unavailable_result
|
|
116
|
+
ExtractionResult.new(symbols: [], status: :unavailable, source: :none)
|
|
89
117
|
end
|
|
90
118
|
end
|
|
91
119
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubik
|
|
4
|
+
module Buffer
|
|
5
|
+
# The one home for the apply CONTRACT vocabulary (issue #146):
|
|
6
|
+
# - who may apply the buffer: the model's apply_buffer tool is gated by
|
|
7
|
+
# buffer.model_can_apply (the operator !apply flow never is);
|
|
8
|
+
# - what a mid-run failure reports: the buffer is per-file atomic, NOT
|
|
9
|
+
# a multi-file transaction, so the report names what DID reach disk,
|
|
10
|
+
# what failed, what stays dirty for a retry, and skipped conflicts.
|
|
11
|
+
# Both planes (ApplyBufferTool, the !apply REPL command) render through
|
|
12
|
+
# here so the truth cannot drift between model and operator output.
|
|
13
|
+
module ApplyContract
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Authority resolution: the shell's live config wins when present; a
|
|
17
|
+
# tool rebuilt without a shell (ScopedRegistry branch workspaces) falls
|
|
18
|
+
# back to the buffer manager's own config. Missing config keeps the
|
|
19
|
+
# documented default (true).
|
|
20
|
+
def model_can_apply?(shell_config:, manager_config:)
|
|
21
|
+
cfg = shell_config || manager_config
|
|
22
|
+
return true if cfg.nil? || !cfg.respond_to?(:fetch)
|
|
23
|
+
|
|
24
|
+
cfg.fetch("buffer.model_can_apply", true) != false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Partial-apply report lines. `sync` (when given) receives the applied
|
|
28
|
+
# paths and performs the context/AST sync; a raising sync is annotated,
|
|
29
|
+
# never allowed to swallow the report.
|
|
30
|
+
def failure_lines(failure, sync: nil)
|
|
31
|
+
lines = ["Apply stopped before completing; the buffer is not a multi-file transaction:"]
|
|
32
|
+
lines << if failure.applied.empty?
|
|
33
|
+
" • Nothing was written to disk."
|
|
34
|
+
else
|
|
35
|
+
" • Already written to disk (do not re-apply): #{failure.applied.join(', ')}"
|
|
36
|
+
end
|
|
37
|
+
cause = failure.cause
|
|
38
|
+
lines << " • Failed: #{failure.failed}#{cause ? " (#{cause.class}: #{cause.message})" : " (#{failure.kind})"}"
|
|
39
|
+
lines << " • Still dirty — a retry writes only these: #{failure.pending.join(', ')}" unless failure.pending.empty?
|
|
40
|
+
lines << " • Skipped conflicts (disk changed externally): #{failure.conflicts.join(', ')}" unless failure.conflicts.empty?
|
|
41
|
+
if sync
|
|
42
|
+
annotation = sync_annotation(failure, sync)
|
|
43
|
+
lines << annotation if annotation
|
|
44
|
+
end
|
|
45
|
+
lines
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# "Buffer is clean" alone reads as "nothing happened" when an earlier
|
|
49
|
+
# apply (the tool or the auto-apply policy) already flushed the edits
|
|
50
|
+
# to disk. Surface the last successful apply so the model can verify
|
|
51
|
+
# against reality instead of re-editing or force-applying (issue #97
|
|
52
|
+
# finding 6). When the mode cannot be read, the origin stays
|
|
53
|
+
# deliberately two-sided rather than inventing a specific cause.
|
|
54
|
+
def clean_buffer_message(last_applied, mode)
|
|
55
|
+
paths = last_applied.is_a?(Hash) ? Array(last_applied[:paths]) : []
|
|
56
|
+
return "No unapplied buffer changes to write (buffer is clean)." if paths.empty?
|
|
57
|
+
|
|
58
|
+
origin = if !mode.nil? && !mode.to_s.strip.empty? && mode.to_s.downcase != "manual"
|
|
59
|
+
"the auto-apply policy (buffer.auto_apply: #{mode})"
|
|
60
|
+
else
|
|
61
|
+
"an earlier apply_buffer call or the auto-apply policy"
|
|
62
|
+
end
|
|
63
|
+
"No unapplied buffer changes to write (buffer is clean): #{paths.size} file(s) were already written to disk " \
|
|
64
|
+
"at #{last_applied[:at].utc.iso8601} by #{origin}: #{paths.join(', ')}."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sync_annotation(failure, sync)
|
|
68
|
+
return nil if failure.applied.empty?
|
|
69
|
+
|
|
70
|
+
sync.call(failure.applied)
|
|
71
|
+
nil
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
" • context/AST sync failed for the already-written files: #{e.class}: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
private_class_method :sync_annotation
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/rubik/buffer/manager.rb
CHANGED
|
@@ -13,6 +13,25 @@ module Rubik
|
|
|
13
13
|
module Buffer
|
|
14
14
|
class InvalidPathError < StandardError; end
|
|
15
15
|
|
|
16
|
+
# Mid-run apply failure with the partial-progress report (issue #146):
|
|
17
|
+
# the buffer is NOT a multi-file transaction — entries already written
|
|
18
|
+
# stay on disk, and this error says exactly which. `kind` names the
|
|
19
|
+
# failure class (:write_failed or :containment_blocked); `cause` carries
|
|
20
|
+
# the underlying error. `pending` lists this run's entries that are
|
|
21
|
+
# still dirty (a retry writes only those).
|
|
22
|
+
class ApplyFailedError < StandardError
|
|
23
|
+
attr_reader :kind, :applied, :failed, :pending, :conflicts
|
|
24
|
+
|
|
25
|
+
def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [])
|
|
26
|
+
super(message)
|
|
27
|
+
@kind = kind
|
|
28
|
+
@applied = applied
|
|
29
|
+
@failed = failed
|
|
30
|
+
@pending = pending
|
|
31
|
+
@conflicts = conflicts
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
16
35
|
class Manager
|
|
17
36
|
attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs, :guard, :last_conflicts
|
|
18
37
|
attr_accessor :allow_external_writes, :config
|
|
@@ -270,6 +289,12 @@ module Rubik
|
|
|
270
289
|
diffs.join("\n")
|
|
271
290
|
end
|
|
272
291
|
|
|
292
|
+
# Applies dirty buffer entries to disk (per-file atomic writes — NOT a
|
|
293
|
+
# multi-file transaction). On a mid-run failure the partial progress is
|
|
294
|
+
# carried by ApplyFailedError: applied entries ARE on disk, the failed
|
|
295
|
+
# entry stays dirty, and pending entries keep their buffer content, so
|
|
296
|
+
# a retry writes only the remainder. External-modification conflicts
|
|
297
|
+
# are skipped (recorded in last_conflicts) unless policy is "overwrite".
|
|
273
298
|
def apply!(target_paths = nil, policy: nil)
|
|
274
299
|
@monitor.synchronize do
|
|
275
300
|
applied_paths = []
|
|
@@ -282,7 +307,7 @@ module Rubik
|
|
|
282
307
|
|
|
283
308
|
effective_policy = (policy || external_modification_policy).to_s.downcase
|
|
284
309
|
|
|
285
|
-
keys_to_apply.
|
|
310
|
+
keys_to_apply.each_with_index do |rel_path, index|
|
|
286
311
|
next unless @dirty_files.key?(rel_path)
|
|
287
312
|
|
|
288
313
|
unless PathValidator.valid?(rel_path)
|
|
@@ -323,14 +348,28 @@ module Rubik
|
|
|
323
348
|
# last_conflicts on every run" holds for readers (AutoApply's
|
|
324
349
|
# report_conflicts) even on the failure path.
|
|
325
350
|
@last_conflicts = conflicts
|
|
326
|
-
raise
|
|
327
|
-
|
|
328
|
-
|
|
351
|
+
raise apply_failure(
|
|
352
|
+
"Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
|
|
353
|
+
"(possible symlink swap since buffering). Re-verify the workspace and retry.",
|
|
354
|
+
kind: :containment_blocked, applied: applied_paths, failed: rel_path,
|
|
355
|
+
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
356
|
+
)
|
|
329
357
|
end
|
|
330
358
|
|
|
331
359
|
content = @buffer[rel_path]
|
|
332
360
|
|
|
333
|
-
|
|
361
|
+
begin
|
|
362
|
+
Rubik::Platform.atomic_write(full_path, content)
|
|
363
|
+
rescue StandardError => e
|
|
364
|
+
@last_conflicts = conflicts
|
|
365
|
+
raise apply_failure(
|
|
366
|
+
"Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
|
|
367
|
+
"#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
|
|
368
|
+
"#{pending_after(keys_to_apply, index).size} file(s) still dirty",
|
|
369
|
+
kind: :write_failed, applied: applied_paths, failed: rel_path,
|
|
370
|
+
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
371
|
+
), cause: e
|
|
372
|
+
end
|
|
334
373
|
|
|
335
374
|
@original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
|
|
336
375
|
@dirty_files.delete(rel_path)
|
|
@@ -422,6 +461,23 @@ module Rubik
|
|
|
422
461
|
rescue Errno::ENOENT, Errno::EACCES
|
|
423
462
|
nil
|
|
424
463
|
end
|
|
464
|
+
|
|
465
|
+
# Build the structured partial-apply error with frozen report copies —
|
|
466
|
+
# a reader must see the state at raise time, not later mutations.
|
|
467
|
+
def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:)
|
|
468
|
+
ApplyFailedError.new(
|
|
469
|
+
message, kind: kind,
|
|
470
|
+
applied: applied.dup.freeze, failed: failed,
|
|
471
|
+
pending: pending.dup.freeze, conflicts: conflicts.dup.freeze
|
|
472
|
+
)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# This run's remaining dirty entries after the failed index — exactly
|
|
476
|
+
# what a retry will still have to write (garbage-skipped entries are
|
|
477
|
+
# already out of dirty_files when this runs).
|
|
478
|
+
def pending_after(keys, index)
|
|
479
|
+
keys[(index + 1)..].to_a.select { |k| @dirty_files.key?(k) }
|
|
480
|
+
end
|
|
425
481
|
end
|
|
426
482
|
end
|
|
427
483
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
require "yaml/store"
|
|
6
|
+
|
|
7
|
+
module Rubik
|
|
8
|
+
module CLI
|
|
9
|
+
class WorkspaceTrust
|
|
10
|
+
TRUSTED_WORKSPACES_KEY = "trusted_workspaces"
|
|
11
|
+
TRUST_OPTION = "Trust and continue"
|
|
12
|
+
EXIT_OPTION = "Exit"
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def authorize!(workspace:, input: $stdin, output: $stdout, store_path: default_store_path)
|
|
16
|
+
return true unless UI::Capability.interactive?(input: input, output: output)
|
|
17
|
+
|
|
18
|
+
workspace_path = File.realpath(workspace)
|
|
19
|
+
return true if trusted?(workspace_path, store_path: store_path)
|
|
20
|
+
|
|
21
|
+
output.puts <<~NOTICE
|
|
22
|
+
Workspace trust is required before Rubik loads this workspace:
|
|
23
|
+
#{workspace_path}
|
|
24
|
+
|
|
25
|
+
Rubik can read and modify workspace files and run foreground and background commands.
|
|
26
|
+
Some commands may run without a separate approval. OS sandboxing is disabled by default.
|
|
27
|
+
|
|
28
|
+
`buffer.model_can_apply: false` only restricts applying staged buffer changes; it does
|
|
29
|
+
not restrict shell commands.
|
|
30
|
+
NOTICE
|
|
31
|
+
|
|
32
|
+
choice = TTY::Prompt.new(input: input, output: output).select(
|
|
33
|
+
"Choose an option:",
|
|
34
|
+
[TRUST_OPTION, EXIT_OPTION],
|
|
35
|
+
default: EXIT_OPTION,
|
|
36
|
+
filter: false,
|
|
37
|
+
per_page: 2
|
|
38
|
+
)
|
|
39
|
+
return false unless choice == TRUST_OPTION
|
|
40
|
+
|
|
41
|
+
trust!(workspace_path, store_path: store_path)
|
|
42
|
+
true
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
output.puts "[Rubik] Workspace trust could not be established: #{e.message}. Exiting."
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def trusted?(workspace, store_path: default_store_path)
|
|
49
|
+
return false unless File.file?(store_path)
|
|
50
|
+
|
|
51
|
+
store = YAML::Store.new(store_path, true)
|
|
52
|
+
store.transaction(true) do
|
|
53
|
+
Array(store[TRUSTED_WORKSPACES_KEY]).include?(File.realpath(workspace))
|
|
54
|
+
end
|
|
55
|
+
rescue Psych::Exception, SystemCallError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def trust!(workspace, store_path: default_store_path)
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(store_path), mode: 0o700)
|
|
61
|
+
workspace_path = File.realpath(workspace)
|
|
62
|
+
store = YAML::Store.new(store_path, true)
|
|
63
|
+
store.transaction do
|
|
64
|
+
paths = Array(store[TRUSTED_WORKSPACES_KEY])
|
|
65
|
+
store[TRUSTED_WORKSPACES_KEY] = (paths + [workspace_path]).uniq
|
|
66
|
+
end
|
|
67
|
+
File.chmod(0o600, store_path)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def default_store_path
|
|
73
|
+
File.join(Platform.home_dir, ".rubik", "trusted_workspaces.yml")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|