rubik 0.1.6 → 0.2.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 +88 -0
- data/README.md +30 -17
- 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/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/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/platform/process.rb +74 -2
- 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/repl/commands/buffer.rb +28 -18
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +115 -6
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/version.rb +1 -1
- metadata +6 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require_relative "discovery"
|
|
7
|
+
require_relative "diagnostic_engine"
|
|
8
|
+
require_relative "../lsp/client"
|
|
9
|
+
|
|
10
|
+
module Rubik
|
|
11
|
+
module Runtime
|
|
12
|
+
# Functional checks own their resources through the caller's probe context.
|
|
13
|
+
class RepairProbe
|
|
14
|
+
def initialize(root_dir:)
|
|
15
|
+
@root_dir = root_dir
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
PROBE_TIMEOUT = 5
|
|
19
|
+
# These commands support help without running tests or formatting files.
|
|
20
|
+
HELP_PROBE_BINARIES = ["ruby", "python", "python3", "pytest", "rspec", "rake", "bundle", "rubocop", "standardrb", "ruff", "black", "rustfmt", "cargo", "go", "gofmt",
|
|
21
|
+
"goimports", "clang-format", "zig", "prettier", "biome", "npm", "pnpm", "yarn", "bun", "mix", "dart", "crystal", "echo"].freeze
|
|
22
|
+
|
|
23
|
+
def verify(target, lang, proposed_cmd, sub_ctx, probe_args: nil)
|
|
24
|
+
return probe_failure(proposed_cmd, 1, "Unknown target component type: #{target}") unless ["lsp", "test_runner", "linter", "command"].include?(target)
|
|
25
|
+
unless Rubik::Runtime::Discovery.binary_available?(proposed_cmd, root_dir: @root_dir)
|
|
26
|
+
return probe_failure(proposed_cmd, 127, "Availability check failed: executable not found in PATH")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if target == "lsp"
|
|
30
|
+
client = Rubik::LSP::Client.new(lang, root_dir: @root_dir, server_cmd: proposed_cmd)
|
|
31
|
+
sub_ctx.on_dispose { client.stop }
|
|
32
|
+
begin
|
|
33
|
+
result = Timeout.timeout(PROBE_TIMEOUT) { client.start }
|
|
34
|
+
return probe_failure(proposed_cmd, 1, "Functional probe failed: LSP initialize handshake rejected") unless result && client.alive?
|
|
35
|
+
rescue Timeout::Error
|
|
36
|
+
return probe_failure(proposed_cmd, 124, "LSP initialize handshake timed out")
|
|
37
|
+
end
|
|
38
|
+
return [true, nil]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
argv = Shellwords.split(proposed_cmd)
|
|
42
|
+
if probe_args.nil?
|
|
43
|
+
unless HELP_PROBE_BINARIES.include?(File.basename(argv.first.to_s))
|
|
44
|
+
return probe_failure(proposed_cmd, 1, "Available but unverified: provide non-mutating probe_args for this executable")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
probe_args = ["--help"]
|
|
48
|
+
end
|
|
49
|
+
return probe_failure(proposed_cmd, 1, "probe_args must be a non-empty array of strings") unless probe_args.is_a?(Array) && !probe_args.empty? && probe_args.all?(String)
|
|
50
|
+
|
|
51
|
+
argv.concat(probe_args)
|
|
52
|
+
# Array-form spawn never interprets shell operators. File-backed output
|
|
53
|
+
# avoids pipe-reader threads surviving a timeout or a forked descendant.
|
|
54
|
+
output = Tempfile.new("rubik-repair-probe")
|
|
55
|
+
sub_ctx.on_dispose { output.close! }
|
|
56
|
+
options = { chdir: @root_dir, in: File::NULL, out: output, err: output }
|
|
57
|
+
options[:pgroup] = true unless Rubik::Platform.windows?
|
|
58
|
+
pid = Process.spawn(*argv, **options)
|
|
59
|
+
reaped = false
|
|
60
|
+
sub_ctx.on_dispose do
|
|
61
|
+
if Rubik::Platform.windows?
|
|
62
|
+
Rubik::Platform.kill_tree(pid) unless reaped
|
|
63
|
+
else
|
|
64
|
+
begin
|
|
65
|
+
Process.kill("KILL", -pid)
|
|
66
|
+
rescue Errno::ESRCH
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
Process.waitpid(pid) unless reaped
|
|
71
|
+
rescue Errno::ECHILD
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PROBE_TIMEOUT
|
|
75
|
+
loop do
|
|
76
|
+
waited = Process.waitpid2(pid, Process::WNOHANG)
|
|
77
|
+
if waited
|
|
78
|
+
reaped = true
|
|
79
|
+
status = waited.last
|
|
80
|
+
return [true, nil] if status.success?
|
|
81
|
+
|
|
82
|
+
output.rewind
|
|
83
|
+
return probe_failure(proposed_cmd, status.exitstatus || 1, "Functional probe failed: #{output.read(4096)}")
|
|
84
|
+
end
|
|
85
|
+
return probe_failure(proposed_cmd, 124, "Functional probe timed out") if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
86
|
+
|
|
87
|
+
sleep 0.01
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def probe_failure(command, code, message)
|
|
94
|
+
[false, Rubik::Runtime::DiagnosticEngine.diagnose(cmd: command, exit_code: code, stderr: message)]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -59,6 +59,25 @@ module Rubik
|
|
|
59
59
|
"If genuinely required and safe, the operator can allowlist it once:",
|
|
60
60
|
" !allow-cmd add #{quoted}"
|
|
61
61
|
]
|
|
62
|
+
elsif kind == :classifier_error
|
|
63
|
+
# AST walk crashed (fail-closed): the command was never
|
|
64
|
+
# classified, so no rule matched — blame-free guidance
|
|
65
|
+
# plus the explicit statement that AST evaluation failed.
|
|
66
|
+
[
|
|
67
|
+
"Security blocked: #{message}",
|
|
68
|
+
"The command AST could not be evaluated (classifier error), so the command was not classified.",
|
|
69
|
+
"If genuinely required and safe, the operator can allowlist the exact simple command once:",
|
|
70
|
+
" !allow-cmd add #{quoted}"
|
|
71
|
+
]
|
|
72
|
+
elsif kind == :parse_ambiguous
|
|
73
|
+
# Tree-sitter reported syntax errors: a partial verdict is
|
|
74
|
+
# not a verdict — the command was blocked unparsed.
|
|
75
|
+
[
|
|
76
|
+
"Security blocked: #{message}",
|
|
77
|
+
"The command AST could not be fully parsed (tree-sitter syntax error), so the command was not classified.",
|
|
78
|
+
"If genuinely required and safe, the operator can allowlist it once:",
|
|
79
|
+
" !allow-cmd add #{quoted}"
|
|
80
|
+
]
|
|
62
81
|
elsif cmd && err.respond_to?(:probe_like) && err.probe_like
|
|
63
82
|
# Probe branch first: `python3 --version` is probe-shaped and
|
|
64
83
|
# must not be hijacked by the text-munging variant below.
|
data/lib/rubik/security/guard.rb
CHANGED
|
@@ -109,6 +109,13 @@ module Rubik
|
|
|
109
109
|
# whose ONLY arguments are these flags is read-only by construction.
|
|
110
110
|
SAFE_PROBE_FLAGS = ["--version", "-v", "--help", "-h"].freeze
|
|
111
111
|
|
|
112
|
+
# Shell metacharacters that make a raw (AST-less) token-prefix allowlist
|
|
113
|
+
# match dishonest: they can join, redirect, substitute, or line-split
|
|
114
|
+
# the string, so "the first tokens match" would not mean "the whole
|
|
115
|
+
# simple command matches". The classifier-crash rescue only accepts a
|
|
116
|
+
# single simple command; anything else fails closed.
|
|
117
|
+
RAW_ALLOWLIST_UNSAFE_PATTERN = /[;&|<>`$(){}\[\]\\\n\r]/
|
|
118
|
+
|
|
112
119
|
attr_reader :audit_log_path
|
|
113
120
|
|
|
114
121
|
# Live-reconfigurable audit destination (ConfigWiring applies config
|
|
@@ -486,16 +493,55 @@ module Rubik
|
|
|
486
493
|
)
|
|
487
494
|
end
|
|
488
495
|
|
|
496
|
+
# Ambiguous parse (tree-sitter syntax errors) is a classification
|
|
497
|
+
# failure, not a pass: the walk cannot vouch for the whole
|
|
498
|
+
# expression, so a safe-looking partial verdict must not fall
|
|
499
|
+
# through to the catastrophic-only regex gate. The allowlist stays
|
|
500
|
+
# the valve because a real AST exists — every segment is checked
|
|
501
|
+
# against the operator entries.
|
|
502
|
+
if !allow_unknown && ast.parse_warnings.to_a.any?
|
|
503
|
+
allowlisted = begin
|
|
504
|
+
command_allowlisted?(ast, cmd_str: cmd_str)
|
|
505
|
+
rescue StandardError => e
|
|
506
|
+
warn "[Guard] allowlist evaluation failed, failing closed: #{e.class}: #{e.message}"
|
|
507
|
+
false
|
|
508
|
+
end
|
|
509
|
+
if allowlisted
|
|
510
|
+
log_audit("allowed_command", command: cmd_str, reason: "operator_allowlist")
|
|
511
|
+
verify_command_fallback_regex!(cmd_str)
|
|
512
|
+
return true
|
|
513
|
+
end
|
|
514
|
+
log_audit("blocked_command", command: cmd_str, reason: "parse_ambiguous",
|
|
515
|
+
parse_warnings: ast.parse_warnings.to_a.size)
|
|
516
|
+
raise BlockedWriteError.new(
|
|
517
|
+
"Command AST could not be fully parsed (tree-sitter syntax error); execution blocked (fail-closed): #{cmd_str}",
|
|
518
|
+
kind: :parse_ambiguous,
|
|
519
|
+
blocked_command: cmd_str,
|
|
520
|
+
rule_name: "parse_ambiguous",
|
|
521
|
+
probe_like: probe_shape?(cmd_str),
|
|
522
|
+
segment_report: nil
|
|
523
|
+
)
|
|
524
|
+
end
|
|
525
|
+
|
|
489
526
|
return true if [:readonly, :local_effects, :external_effects].include?(ast.classification) && ast.parse_warnings.empty?
|
|
490
527
|
rescue BlockedWriteError
|
|
491
528
|
raise
|
|
492
529
|
rescue StandardError => e
|
|
493
|
-
# Unconditional warn + audit trail: a classifier crash
|
|
494
|
-
#
|
|
495
|
-
#
|
|
496
|
-
#
|
|
497
|
-
|
|
498
|
-
|
|
530
|
+
# Unconditional warn + audit trail: a classifier crash is a
|
|
531
|
+
# security-observable downgrade nobody should need RUBIK_DEBUG to
|
|
532
|
+
# notice. The shell gate fails closed — an exception must never
|
|
533
|
+
# mint execution permission; the operator allowlist is the one
|
|
534
|
+
# sanctioned valve. The synthesis seam (allow_unknown) keeps
|
|
535
|
+
# flowing into its own deny layers (DANGEROUS_SYNTHESIS_PATTERNS
|
|
536
|
+
# + RubyVM walk), which run after this gate.
|
|
537
|
+
if allow_unknown
|
|
538
|
+
warn "[WARN] AST classification error on non-shell input, continuing to synthesis deny layers: #{e.message}"
|
|
539
|
+
log_audit("classifier_error", command: cmd_str, reason: "#{e.class}: #{e.message}",
|
|
540
|
+
decision: "synthesis_layers")
|
|
541
|
+
else
|
|
542
|
+
warn "[WARN] AST classification error, failing closed: #{e.class}: #{e.message}"
|
|
543
|
+
block_on_classifier_failure!(cmd_str, e)
|
|
544
|
+
end
|
|
499
545
|
end
|
|
500
546
|
|
|
501
547
|
verify_command_fallback_regex!(cmd_str)
|
|
@@ -685,6 +731,69 @@ module Rubik
|
|
|
685
731
|
report ? report[:segments].size : 0
|
|
686
732
|
end
|
|
687
733
|
|
|
734
|
+
# Classifier-crash gate for the shell path: structured block by
|
|
735
|
+
# default, one sanctioned rescue — an operator allowlist entry
|
|
736
|
+
# covering the exact single-command prefix (the same explicit
|
|
737
|
+
# authorization contract as the unknown-command gate). The
|
|
738
|
+
# catastrophic fallback regex still runs afterwards on the rescue
|
|
739
|
+
# path; on the block path it gets first refusal so a catastrophic
|
|
740
|
+
# command keeps its precise :dangerous_pattern reason.
|
|
741
|
+
def block_on_classifier_failure!(cmd_str, error)
|
|
742
|
+
allowlisted = begin
|
|
743
|
+
command_allowlisted_raw?(cmd_str)
|
|
744
|
+
rescue StandardError => e
|
|
745
|
+
warn "[Guard] raw allowlist evaluation failed, failing closed: #{e.class}: #{e.message}"
|
|
746
|
+
false
|
|
747
|
+
end
|
|
748
|
+
log_audit("classifier_error", command: cmd_str, reason: "#{error.class}: #{error.message}",
|
|
749
|
+
decision: allowlisted ? "operator_allowlist" : "blocked")
|
|
750
|
+
|
|
751
|
+
if allowlisted
|
|
752
|
+
log_audit("allowed_command", command: cmd_str, reason: "operator_allowlist_classifier_error")
|
|
753
|
+
return
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
verify_command_fallback_regex!(cmd_str)
|
|
757
|
+
log_audit("blocked_command", command: cmd_str, reason: "classifier_error")
|
|
758
|
+
raise BlockedWriteError.new(
|
|
759
|
+
"AST evaluation failed, so the command could not be classified; execution blocked (fail-closed): #{cmd_str}",
|
|
760
|
+
kind: :classifier_error,
|
|
761
|
+
blocked_command: cmd_str,
|
|
762
|
+
rule_name: "classifier_error",
|
|
763
|
+
probe_like: probe_shape?(cmd_str),
|
|
764
|
+
segment_report: nil
|
|
765
|
+
)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# Token-prefix allowlist check for an input whose AST does not exist
|
|
769
|
+
# (classifier crashed). Only a single simple command qualifies: with
|
|
770
|
+
# shell metacharacters present the string may hold several segments
|
|
771
|
+
# and the gate's "every segment must match" contract cannot be
|
|
772
|
+
# honored — fail closed instead of over-promising the prefix match.
|
|
773
|
+
# Quoting is handled by Shellwords; an unparseable string (unbalanced
|
|
774
|
+
# quote) fails closed.
|
|
775
|
+
def command_allowlisted_raw?(cmd_str)
|
|
776
|
+
return false if @command_allowlist.empty?
|
|
777
|
+
return false if cmd_str.match?(RAW_ALLOWLIST_UNSAFE_PATTERN)
|
|
778
|
+
|
|
779
|
+
tokens = nil
|
|
780
|
+
begin
|
|
781
|
+
tokens = Shellwords.split(cmd_str)
|
|
782
|
+
rescue ArgumentError
|
|
783
|
+
return false
|
|
784
|
+
end
|
|
785
|
+
return false if tokens.empty?
|
|
786
|
+
return false if Rubik::Security::Classifier::Matcher::BUILTIN_DANGEROUS_CMDS.include?(tokens[0].to_s)
|
|
787
|
+
|
|
788
|
+
@command_allowlist.any? do |entry|
|
|
789
|
+
next false if entry.length > tokens.length
|
|
790
|
+
|
|
791
|
+
entry.each_with_index.all? do |token, i|
|
|
792
|
+
i.zero? ? binary_allowlist_match?(tokens[i], token) : tokens[i] == token
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
688
797
|
def verify_command_fallback_regex!(cmd_str)
|
|
689
798
|
return true unless DANGEROUS_COMMAND_PATTERNS.any? { |p| cmd_str.match?(p) }
|
|
690
799
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "../base"
|
|
4
4
|
require_relative "../output"
|
|
5
5
|
require_relative "../../runtime"
|
|
6
|
+
require_relative "../../runtime/repair_probe"
|
|
7
|
+
require_relative "../../buffer/apply_contract"
|
|
6
8
|
|
|
7
9
|
module Rubik
|
|
8
10
|
module Tools
|
|
@@ -33,7 +35,9 @@ module Rubik
|
|
|
33
35
|
def execute(args = {})
|
|
34
36
|
return ToolOutput.error("Buffer manager is not connected.") unless @buffer_manager
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
shell_config = @shell.respond_to?(:config) ? @shell.config : nil
|
|
39
|
+
manager_config = @buffer_manager.respond_to?(:config) ? @buffer_manager.config : nil
|
|
40
|
+
unless Rubik::Buffer::ApplyContract.model_can_apply?(shell_config: shell_config, manager_config: manager_config)
|
|
37
41
|
return ToolOutput.error("Model buffer application is disabled by configuration " \
|
|
38
42
|
"(buffer.model_can_apply: false). The human operator will review and apply changes.")
|
|
39
43
|
end
|
|
@@ -43,7 +47,15 @@ module Rubik
|
|
|
43
47
|
policy = force ? "overwrite" : nil
|
|
44
48
|
|
|
45
49
|
if @buffer_manager.dirty?
|
|
46
|
-
applied =
|
|
50
|
+
applied = nil
|
|
51
|
+
begin
|
|
52
|
+
applied = @buffer_manager.apply!(policy: policy)
|
|
53
|
+
rescue Rubik::Buffer::ApplyFailedError => e
|
|
54
|
+
# Partial apply: files in e.applied ARE on disk — sync the model's
|
|
55
|
+
# view for them and report the remainder honestly (issue #146).
|
|
56
|
+
lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { @shell&.sync_context_and_ast(paths) })
|
|
57
|
+
return ToolOutput.error(lines.join("\n"))
|
|
58
|
+
end
|
|
47
59
|
conflicts = @buffer_manager.last_conflicts || []
|
|
48
60
|
@shell&.sync_context_and_ast(applied) unless applied.empty?
|
|
49
61
|
|
|
@@ -68,30 +80,16 @@ module Rubik
|
|
|
68
80
|
messages.join("\n")
|
|
69
81
|
end
|
|
70
82
|
else
|
|
71
|
-
clean_buffer_message
|
|
83
|
+
Rubik::Buffer::ApplyContract.clean_buffer_message(@buffer_manager.last_applied, auto_apply_mode)
|
|
72
84
|
end
|
|
73
85
|
end
|
|
74
86
|
|
|
75
87
|
private
|
|
76
88
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# (issue #97 finding 6). When the mode cannot be read, the origin stays
|
|
82
|
-
# deliberately two-sided rather than inventing a specific cause.
|
|
83
|
-
def clean_buffer_message
|
|
84
|
-
last = @buffer_manager.last_applied
|
|
85
|
-
return "No unapplied buffer changes to write (buffer is clean)." unless last.is_a?(Hash) && Array(last[:paths]).any?
|
|
86
|
-
|
|
87
|
-
mode = @shell.respond_to?(:config) && @shell.config.respond_to?(:fetch) ? @shell.config.fetch("buffer.auto_apply", "manual") : nil
|
|
88
|
-
origin = if !mode.nil? && !mode.to_s.strip.empty? && mode.to_s.downcase != "manual"
|
|
89
|
-
"the auto-apply policy (buffer.auto_apply: #{mode})"
|
|
90
|
-
else
|
|
91
|
-
"an earlier apply_buffer call or the auto-apply policy"
|
|
92
|
-
end
|
|
93
|
-
"No unapplied buffer changes to write (buffer is clean): #{last[:paths].size} file(s) were already written to disk " \
|
|
94
|
-
"at #{last[:at].utc.iso8601} by #{origin}: #{last[:paths].join(', ')}."
|
|
89
|
+
def auto_apply_mode
|
|
90
|
+
return nil unless @shell.respond_to?(:config) && @shell.config.respond_to?(:fetch)
|
|
91
|
+
|
|
92
|
+
@shell.config.fetch("buffer.auto_apply", "manual")
|
|
95
93
|
end
|
|
96
94
|
end
|
|
97
95
|
|
|
@@ -117,6 +115,10 @@ module Rubik
|
|
|
117
115
|
type: "string",
|
|
118
116
|
description: "Replacement or custom command string (optional, probed automatically if auto_probe: true)"
|
|
119
117
|
},
|
|
118
|
+
probe_args: {
|
|
119
|
+
type: "array", items: { type: "string" },
|
|
120
|
+
description: "Explicit non-mutating functional probe arguments appended to the exact proposed command; required for unknown executables"
|
|
121
|
+
},
|
|
120
122
|
auto_probe: {
|
|
121
123
|
type: "boolean",
|
|
122
124
|
description: "Whether to automatically probe system binaries and candidate commands (default: true)"
|
|
@@ -187,7 +189,7 @@ module Rubik
|
|
|
187
189
|
error_diagnostic = nil
|
|
188
190
|
|
|
189
191
|
begin
|
|
190
|
-
verified, error_diagnostic = verify_binary(target, lang, proposed_cmd, sub_ctx)
|
|
192
|
+
verified, error_diagnostic = verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: args["probe_args"] || args[:probe_args])
|
|
191
193
|
rescue StandardError => e
|
|
192
194
|
error_diagnostic = Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
193
195
|
cmd: proposed_cmd,
|
|
@@ -200,66 +202,114 @@ module Rubik
|
|
|
200
202
|
|
|
201
203
|
return ToolOutput.error("Verification failed for #{target} (#{lang}):\n#{error_diagnostic&.format_for_llm}") unless verified
|
|
202
204
|
|
|
203
|
-
#
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
205
|
+
# Live changes belong to a disposable transaction. Successful project
|
|
206
|
+
# persistence is an operator decision and outlives lifecycle cleanup.
|
|
207
|
+
promotion_ctx = root_ctx.extend
|
|
208
|
+
released = false
|
|
209
|
+
@repair_scopes ||= {}
|
|
210
|
+
cfg_key = { "lsp" => "lsp.servers", "test_runner" => "test.runners",
|
|
211
|
+
"linter" => "linter.commands" }[target]
|
|
212
|
+
cfg_key = "#{cfg_key}.#{lang}" if cfg_key
|
|
213
|
+
prev_cmd = @config&.fetch(cfg_key) if cfg_key
|
|
214
|
+
service_key = "#{{ 'test_runner' => 'runner' }.fetch(target, target)}:#{lang}"
|
|
215
|
+
previous_services = root_ctx.services
|
|
216
|
+
phase = "Promotion"
|
|
217
|
+
begin
|
|
218
|
+
promotion_ctx.on_dispose do
|
|
219
|
+
next if released
|
|
220
|
+
|
|
221
|
+
@repair_scopes.delete([root_ctx, service_key]) if @repair_scopes[[root_ctx, service_key]].equal?(promotion_ctx)
|
|
222
|
+
unless root_ctx.disposed?
|
|
223
|
+
if previous_services.key?(service_key)
|
|
224
|
+
restored = target == "lsp" ? @lsp_manager&.clients&.[](lang) : nil
|
|
225
|
+
root_ctx.provide(service_key, restored || previous_services[service_key])
|
|
226
|
+
else
|
|
227
|
+
root_ctx.unprovide(service_key)
|
|
228
|
+
end
|
|
223
229
|
end
|
|
224
|
-
active_client = @lsp_manager.client_for(lang)
|
|
225
|
-
root_ctx.provide("lsp:#{lang}", active_client || proposed_cmd)
|
|
226
|
-
else
|
|
227
|
-
root_ctx.provide("lsp:#{lang}", proposed_cmd)
|
|
228
230
|
end
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
adapter = @adapter_registry.get(lang)
|
|
235
|
-
if adapter
|
|
236
|
-
adapter.config ||= @config
|
|
237
|
-
adapter.custom_test_cmd = proposed_cmd
|
|
238
|
-
else
|
|
239
|
-
require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
|
|
240
|
-
new_adapter = Rubik::Adapter::GenericAdapter.new(lang, test_cmd: proposed_cmd, guard: @guard)
|
|
241
|
-
new_adapter.config = @config
|
|
242
|
-
@adapter_registry.register(lang, new_adapter, ctx: root_ctx)
|
|
231
|
+
if cfg_key && @config
|
|
232
|
+
promotion_ctx.on_dispose do
|
|
233
|
+
next if released
|
|
234
|
+
|
|
235
|
+
prev_cmd.nil? ? @config.delete(cfg_key) : @config.set(cfg_key, prev_cmd)
|
|
243
236
|
end
|
|
237
|
+
@config.set(cfg_key, proposed_cmd)
|
|
244
238
|
end
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
@
|
|
239
|
+
|
|
240
|
+
case target
|
|
241
|
+
when "lsp"
|
|
242
|
+
raise "LSP manager and config are required for live activation" unless @lsp_manager && @config
|
|
243
|
+
|
|
244
|
+
had_client = !@lsp_manager.clients[lang].nil?
|
|
245
|
+
# The manager owns its client table. Restore the old configured
|
|
246
|
+
# client on rollback; probe clients never enter that table.
|
|
247
|
+
promotion_ctx.on_dispose do
|
|
248
|
+
next if released
|
|
249
|
+
|
|
250
|
+
@lsp_manager.stop_client(lang)
|
|
251
|
+
prev_cmd.nil? ? @config.delete(cfg_key) : @config.set(cfg_key, prev_cmd)
|
|
252
|
+
if had_client && !root_ctx.disposed?
|
|
253
|
+
restored = @lsp_manager.start_client(lang)
|
|
254
|
+
raise "Previous LSP client could not be restarted: #{restored}" unless restored[:ok]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
@lsp_manager.stop_client(lang)
|
|
258
|
+
start_res = @lsp_manager.start_client(lang)
|
|
259
|
+
raise "Failed to start LSP server: #{start_res}" unless start_res.is_a?(Hash) && start_res[:ok]
|
|
260
|
+
|
|
261
|
+
service = @lsp_manager.clients[lang]
|
|
262
|
+
raise "LSP manager reported success without an active client" unless service
|
|
263
|
+
when "test_runner", "linter"
|
|
264
|
+
if @adapter_registry
|
|
265
|
+
adapter = @adapter_registry.get(lang)
|
|
266
|
+
if adapter
|
|
267
|
+
field = target == "test_runner" ? :custom_test_cmd : :custom_format_cmd
|
|
268
|
+
old_value = adapter.public_send(field)
|
|
269
|
+
old_config = adapter.config
|
|
270
|
+
promotion_ctx.on_dispose do
|
|
271
|
+
next if released
|
|
272
|
+
|
|
273
|
+
adapter.public_send("#{field}=", old_value)
|
|
274
|
+
adapter.config = old_config
|
|
275
|
+
end
|
|
276
|
+
adapter.config ||= @config
|
|
277
|
+
adapter.public_send("#{field}=", proposed_cmd)
|
|
278
|
+
else
|
|
279
|
+
require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
|
|
280
|
+
options = target == "test_runner" ? { test_cmd: proposed_cmd } : { format_cmd: proposed_cmd }
|
|
281
|
+
adapter = Rubik::Adapter::GenericAdapter.new(lang, **options, guard: @guard)
|
|
282
|
+
adapter.config = @config
|
|
283
|
+
promotion_ctx.on_dispose do
|
|
284
|
+
@adapter_registry.unregister(lang) if !released && @adapter_registry.get(lang).equal?(adapter)
|
|
285
|
+
end
|
|
286
|
+
@adapter_registry.register(lang, adapter)
|
|
287
|
+
end
|
|
258
288
|
end
|
|
289
|
+
service = proposed_cmd
|
|
290
|
+
when "command"
|
|
291
|
+
service = proposed_cmd
|
|
259
292
|
end
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
293
|
+
|
|
294
|
+
root_ctx.provide(service_key, service)
|
|
295
|
+
phase = "Persistence"
|
|
296
|
+
persist_res = @config.set(cfg_key, proposed_cmd, persist: :project) if cfg_key && @config
|
|
297
|
+
raise persist_res[:error].to_s if persist_res.is_a?(Hash) && !persist_res[:ok]
|
|
298
|
+
rescue StandardError => e
|
|
299
|
+
promotion_ctx.dispose
|
|
300
|
+
rollback_errors = promotion_ctx.last_dispose_errors
|
|
301
|
+
detail = rollback_errors.empty? ? "previous session state restored" : "rollback incomplete: #{rollback_errors}"
|
|
302
|
+
return ToolOutput.error("#{phase} failed for #{target} (#{lang}): #{e.message}; #{detail}")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Keep one lifetime inverse per repaired key, preserving the original
|
|
306
|
+
# baseline across repeated repairs without retaining every transaction.
|
|
307
|
+
scope_key = [root_ctx, service_key]
|
|
308
|
+
if @repair_scopes.key?(scope_key)
|
|
309
|
+
released = true
|
|
310
|
+
promotion_ctx.dispose
|
|
311
|
+
else
|
|
312
|
+
@repair_scopes[scope_key] = promotion_ctx
|
|
263
313
|
end
|
|
264
314
|
|
|
265
315
|
if @config.nil? && ["lsp", "test_runner", "linter"].include?(target)
|
|
@@ -267,71 +317,14 @@ module Rubik
|
|
|
267
317
|
"so the command is active for this session only and was not saved."
|
|
268
318
|
end
|
|
269
319
|
|
|
270
|
-
if persist_res.is_a?(Hash) && !persist_res[:ok]
|
|
271
|
-
return "⚠️ Promoted '#{proposed_cmd}' for #{target} (#{lang}) — active in this session, " \
|
|
272
|
-
"but saving to the project config failed: #{persist_res[:error]}"
|
|
273
|
-
end
|
|
274
|
-
|
|
275
320
|
"✨ Successfully verified and promoted runtime service for #{target} (#{lang}): '#{proposed_cmd}'. " \
|
|
276
|
-
"Service is now active
|
|
321
|
+
"Service is now active in the live context."
|
|
277
322
|
end
|
|
278
323
|
|
|
279
324
|
private
|
|
280
325
|
|
|
281
|
-
def verify_binary(target, lang, proposed_cmd, sub_ctx)
|
|
282
|
-
|
|
283
|
-
when "lsp"
|
|
284
|
-
client = Rubik::LSP::Client.new(lang, root_dir: @root_dir, server_cmd: proposed_cmd)
|
|
285
|
-
if client.available?
|
|
286
|
-
sub_ctx.provide("lsp_probe:#{lang}", client)
|
|
287
|
-
[true, nil]
|
|
288
|
-
else
|
|
289
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
290
|
-
cmd: proposed_cmd,
|
|
291
|
-
exit_code: 127,
|
|
292
|
-
stderr: "Pre-flight check failed: LSP binary '#{client.binary_name}' not available on system"
|
|
293
|
-
)]
|
|
294
|
-
end
|
|
295
|
-
when "test_runner"
|
|
296
|
-
if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
|
|
297
|
-
sub_ctx.provide("runner_probe:#{lang}", proposed_cmd)
|
|
298
|
-
[true, nil]
|
|
299
|
-
else
|
|
300
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
301
|
-
cmd: proposed_cmd,
|
|
302
|
-
exit_code: 127,
|
|
303
|
-
stderr: "Pre-flight check failed: Test runner executable not found in PATH"
|
|
304
|
-
)]
|
|
305
|
-
end
|
|
306
|
-
when "linter"
|
|
307
|
-
if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
|
|
308
|
-
sub_ctx.provide("linter_probe:#{lang}", proposed_cmd)
|
|
309
|
-
[true, nil]
|
|
310
|
-
else
|
|
311
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
312
|
-
cmd: proposed_cmd,
|
|
313
|
-
exit_code: 127,
|
|
314
|
-
stderr: "Pre-flight check failed: Linter/formatter executable not found in PATH"
|
|
315
|
-
)]
|
|
316
|
-
end
|
|
317
|
-
when "command"
|
|
318
|
-
if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
|
|
319
|
-
sub_ctx.provide("command_probe:#{lang}", proposed_cmd)
|
|
320
|
-
[true, nil]
|
|
321
|
-
else
|
|
322
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
323
|
-
cmd: proposed_cmd,
|
|
324
|
-
exit_code: 127,
|
|
325
|
-
stderr: "Pre-flight check failed: Command executable not found in PATH"
|
|
326
|
-
)]
|
|
327
|
-
end
|
|
328
|
-
else
|
|
329
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
330
|
-
cmd: proposed_cmd,
|
|
331
|
-
exit_code: 1,
|
|
332
|
-
stderr: "Unknown target component type: #{target}"
|
|
333
|
-
)]
|
|
334
|
-
end
|
|
326
|
+
def verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: nil)
|
|
327
|
+
Rubik::Runtime::RepairProbe.new(root_dir: @root_dir).verify(target, lang, proposed_cmd, sub_ctx, probe_args: probe_args)
|
|
335
328
|
end
|
|
336
329
|
end
|
|
337
330
|
|
data/lib/rubik/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubik
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Özgür Ulukır
|
|
@@ -244,6 +244,7 @@ files:
|
|
|
244
244
|
- lib/rubik/ast/body_loader.rb
|
|
245
245
|
- lib/rubik/ast/c_extractor.rb
|
|
246
246
|
- lib/rubik/ast/cache_store.rb
|
|
247
|
+
- lib/rubik/ast/extraction_result.rb
|
|
247
248
|
- lib/rubik/ast/extractor.rb
|
|
248
249
|
- lib/rubik/ast/fallback_cleaner.rb
|
|
249
250
|
- lib/rubik/ast/go_extractor.rb
|
|
@@ -266,6 +267,7 @@ files:
|
|
|
266
267
|
- lib/rubik/benchmark/runner.rb
|
|
267
268
|
- lib/rubik/benchmark/synth_repo.rb
|
|
268
269
|
- lib/rubik/buffer.rb
|
|
270
|
+
- lib/rubik/buffer/apply_contract.rb
|
|
269
271
|
- lib/rubik/buffer/diff.rb
|
|
270
272
|
- lib/rubik/buffer/manager.rb
|
|
271
273
|
- lib/rubik/buffer/path_validator.rb
|
|
@@ -403,6 +405,8 @@ files:
|
|
|
403
405
|
- lib/rubik/plugin.rb
|
|
404
406
|
- lib/rubik/plugin/dsl.rb
|
|
405
407
|
- lib/rubik/plugin/manager.rb
|
|
408
|
+
- lib/rubik/plugin/package_inventory.rb
|
|
409
|
+
- lib/rubik/plugin/requires_dag.rb
|
|
406
410
|
- lib/rubik/plugin/storage.rb
|
|
407
411
|
- lib/rubik/prompt.rb
|
|
408
412
|
- lib/rubik/prompt/context_builder.rb
|
|
@@ -476,6 +480,7 @@ files:
|
|
|
476
480
|
- lib/rubik/runtime/loop_service.rb
|
|
477
481
|
- lib/rubik/runtime/manager.rb
|
|
478
482
|
- lib/rubik/runtime/overlay_storage.rb
|
|
483
|
+
- lib/rubik/runtime/repair_probe.rb
|
|
479
484
|
- lib/rubik/runtime/service_definition.rb
|
|
480
485
|
- lib/rubik/runtime/service_registration.rb
|
|
481
486
|
- lib/rubik/runtime/task_manager.rb
|