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.
@@ -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.
@@ -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 downgrades
494
- # this verification to the narrow fallback regex, silently skipping
495
- # allowlist/probe logic — a security-observable downgrade nobody
496
- # should need RUBIK_DEBUG to notice.
497
- warn "[WARN] AST classification error, falling back to regex gate: #{e.message}"
498
- log_audit("classifier_error", command: cmd_str, reason: "#{e.class}: #{e.message}")
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
- if @shell&.config && @shell.config.fetch("buffer.model_can_apply", true) == false
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 = @buffer_manager.apply!(policy: policy)
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
- # "Buffer is clean" alone reads as "nothing happened" when an earlier
78
- # apply (apply_buffer itself or the auto-apply policy) already flushed
79
- # the edits to disk. Surface the last successful apply so the model can
80
- # verify against reality instead of re-editing or force-applying
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
- # 3. Live Context Promotion & Subsystem Configuration (Zero-Downtime Hot Swapping)
204
- case target
205
- when "lsp"
206
- cfg_key = "lsp.servers.#{lang}"
207
- prev_cmd = @config&.fetch(cfg_key)
208
- had_client = @lsp_manager ? !@lsp_manager.client_for(lang).nil? : false
209
- @config&.set(cfg_key, proposed_cmd)
210
- if @lsp_manager
211
- @lsp_manager.stop_client(lang)
212
- start_res = @lsp_manager.start_client(lang)
213
- unless start_res.is_a?(Hash) && start_res[:ok] == true
214
- # Failure Reversibility (AGENTS.md §1.A): roll back the in-memory
215
- # state; disk stays clean because persist happens only after a
216
- # verified start (atomic promotion).
217
- @lsp_manager.stop_client(lang)
218
- @config&.set(cfg_key, prev_cmd)
219
- @lsp_manager.start_client(lang) if had_client
220
- cause = start_res.is_a?(Hash) ? start_res[:error] : "unexpected start_client response"
221
- return ToolOutput.error("Failed to start LSP server for #{lang} ('#{proposed_cmd}'); " \
222
- "previous configuration restored. Cause: #{cause}")
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
- # Persist to project config only after the live promotion succeeded.
230
- persist_res = @config&.set(cfg_key, proposed_cmd, persist: :project)
231
- when "test_runner"
232
- persist_res = @config&.set("test.runners.#{lang}", proposed_cmd, persist: :project)
233
- if @adapter_registry
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
- root_ctx.provide("runner:#{lang}", proposed_cmd)
246
- when "linter"
247
- persist_res = @config&.set("linter.commands.#{lang}", proposed_cmd, persist: :project)
248
- if @adapter_registry
249
- adapter = @adapter_registry.get(lang)
250
- if adapter
251
- adapter.config ||= @config
252
- adapter.custom_format_cmd = proposed_cmd
253
- else
254
- require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
255
- new_adapter = Rubik::Adapter::GenericAdapter.new(lang, format_cmd: proposed_cmd, guard: @guard)
256
- new_adapter.config = @config
257
- @adapter_registry.register(lang, new_adapter, ctx: root_ctx)
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
- root_ctx.provide("linter:#{lang}", proposed_cmd)
261
- when "command"
262
- root_ctx.provide("command:#{lang}", proposed_cmd)
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 and hot-swapped in the live context with zero downtime."
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
- case target
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.1.6"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.6
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