rubik 0.3.5 → 0.4.1

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +99 -3
  4. data/lib/rubik/ast/c_extractor.rb +1 -1
  5. data/lib/rubik/ast/fallback_cleaner.rb +3 -1
  6. data/lib/rubik/ast/graph.rb +20 -1
  7. data/lib/rubik/ast/graph_extensions.rb +89 -1
  8. data/lib/rubik/ast/python_extractor.rb +5 -5
  9. data/lib/rubik/ast/rust_extractor.rb +1 -1
  10. data/lib/rubik/ast/typescript_extractor.rb +3 -3
  11. data/lib/rubik/diagnostics/parser.rb +65 -13
  12. data/lib/rubik/llm/openai_compatible.rb +2 -1
  13. data/lib/rubik/llm/registry.rb +81 -74
  14. data/lib/rubik/lsp/manager.rb +15 -5
  15. data/lib/rubik/orchestrator/step_executor.rb +139 -155
  16. data/lib/rubik/orchestrator/subagent/completion.rb +1 -0
  17. data/lib/rubik/orchestrator/subagent/dependency.rb +1 -0
  18. data/lib/rubik/orchestrator/subagent/supervisor.rb +20 -3
  19. data/lib/rubik/orchestrator/subagent/waiting.rb +16 -9
  20. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +1 -1
  21. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -2
  22. data/lib/rubik/orchestrator/turn_executor.rb +3 -2
  23. data/lib/rubik/platform/path.rb +2 -1
  24. data/lib/rubik/platform/sanitize.rb +4 -1
  25. data/lib/rubik/prompt/token_estimator.rb +16 -12
  26. data/lib/rubik/repl/step_formatter.rb +2 -1
  27. data/lib/rubik/runtime/diagnostic_engine.rb +1 -1
  28. data/lib/rubik/security/blocked_guidance.rb +1 -1
  29. data/lib/rubik/security/classifier/classifier.rb +3 -1
  30. data/lib/rubik/security/redactor.rb +3 -3
  31. data/lib/rubik/storage/layout.rb +3 -1
  32. data/lib/rubik/storage/render.rb +1 -1
  33. data/lib/rubik/storage/vakanuvis/vocabulary.rb +5 -2
  34. data/lib/rubik/tools/base.rb +2 -1
  35. data/lib/rubik/tools/truncator.rb +21 -8
  36. data/lib/rubik/ui/renderer.rb +1 -0
  37. data/lib/rubik/version.rb +1 -1
  38. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8a3ecbfb98ba136f13800cc4a2d9ab68703b30c0086a3297c005abe6991d060
4
- data.tar.gz: 72e1fff12c37f318f5e1b817d0ef88601110a83099f05edbd10c3d294564027c
3
+ metadata.gz: 15cadac070995d7b8d1ac155c6ddfe14c4e4418e596efe6b734af6cb9240f6b4
4
+ data.tar.gz: 21b3376be313a5133ca84180a36770b0c344495b12ed3c00390715d8dd1658eb
5
5
  SHA512:
6
- metadata.gz: 7a17907eee5524e8053a9f73eb946ebe8766001a64ca17db048240837c1f154f7d2ba3650ce765e8ab830dc6da8840a1ea15e147490a03af1f5203d33b678294
7
- data.tar.gz: 54d72614c2c6fd31a0ff482144c7953148d780516ea748f248027a6943d0b91c40fdfd64d1867036109f8b666bc3ce9ecb58516f0418f3c775335f249fead2b5
6
+ metadata.gz: d9edcdfa2830b03dc8d9a5345d8215a5204ba96d4aa5ef93ca0742633a02ff089f2dda9fab638d4a42a489f1b166f74c9a11ecc5f64b3f602b7cb1cbbdd09a5a
7
+ data.tar.gz: 2b478a0aa15376eaac5636c9d6e7f8d5dfac5534394153958d6417ecf3547dbb9ae654d7c59e5d92df6a84e2038d869145a3ba2b7c8dbdc62472106c7727c33d
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,850+ unit, integration, and contract tests. |
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,9 +6,104 @@ 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
- ## Unreleased
9
+ ## [0.4.1] - 2026-10-29
10
10
 
11
- Nothing yet.
11
+ A hardening patch: CodeQL-driven fixes remove ReDoS-prone regular
12
+ expressions and tighten output escaping, LSP client loss retracts its
13
+ coeffects symmetrically, and subagent waits wake reactively instead of
14
+ polling.
15
+
16
+ ### Fixed
17
+
18
+ - **CodeQL scanning alerts resolved — ReDoS, escaping, and workflow
19
+ permissions:** polynomial-time regular expressions in AST extractors,
20
+ the diagnostics parser, storage slug generation, and path helpers are
21
+ replaced with deterministic string operations or bounded alternation;
22
+ `Render.listing` escapes backslashes before pipes in Markdown tables;
23
+ and the CI workflow's default `GITHUB_TOKEN` is floored to
24
+ `contents: read`.
25
+ - **Streamed thought duplication in REPL suppressed (#287):** when an LLM turn
26
+ with tool calls streams preliminary text live to the terminal, `TurnExecutor`
27
+ and UI renderers no longer reprint the identical thought line prefixed with
28
+ `💭` at verbose/debug log levels.
29
+ - **LSP client loss retracts the `lsp:<lang>` coeffect symmetrically
30
+ (#306):** a new `unregister_client_unlocked` runs on every client-removal
31
+ path — `stop_client`, `stop_all`, crash recovery, and dead-client
32
+ detection — so the context `unprovide` pairs with the original `provide`
33
+ and `registered_disposable_langs` tracking is cleared. Crash paths
34
+ previously leaked the coeffect and its dispose hook, leaving stale
35
+ services behind after a server died.
36
+
37
+ ### Performance
38
+
39
+ - **Subagent waiters wake reactively via ConditionVariable (#299):** the
40
+ wait loop parks on a monitor-owned condition variable instead of polling
41
+ every 100 ms; every settle, pause, unpark, and launch transition
42
+ (completion, `release_ready!`, kill, resume, budget/error/question
43
+ events) broadcasts the wakeup. The sleep fallback keeps standalone
44
+ `WaitSupervisor` includers working.
45
+ - **LSP diagnostics aggregation merges outside the monitor (#294):**
46
+ `all_diagnostics` and `all_diagnostic_snapshots` collect client hashes
47
+ under the monitor and bulk-merge outside it, dropping the per-client
48
+ merge from the locked section; the result is always a fresh hash.
49
+ - **`compact.map` chains fused into `filter_map` (#290, #291, #297,
50
+ #302):** single-pass collection replaces the intermediate arrays in
51
+ `WaitSupervisor#wait`, subagent dependency normalization,
52
+ `dangerous_system_roots`, and `ConversationId.normalize_list`, with the
53
+ nil-skip semantics of the originals preserved exactly.
54
+
55
+ ## [0.4.0] - 2026-10-27
56
+
57
+ A diagnostics-reach minor: the diagnostics engine gains native
58
+ parsers for Python tracebacks, TypeScript/JavaScript compiler
59
+ output, and C++ error streams, alongside an AST-graph correctness
60
+ restore, multibyte-safe truncation, and hot-path performance
61
+ streamlines.
62
+
63
+ ### Added
64
+
65
+ - **Diagnostics parser speaks Python, TypeScript/JavaScript, and
66
+ C++ (#286):** Python tracebacks (`File "...", line N` headers
67
+ paired with exception lines) parse into structured entries with
68
+ syntax-vs-type categorization; tsc-style `file(line,col): error
69
+ TSxxxx:` and `file:line:col: error` streams parse with error codes
70
+ and columns; C++ compilers route through the existing zig/c
71
+ parser. Unrecognized output still falls back to the generic
72
+ parser.
73
+
74
+ ### Fixed
75
+
76
+ - **AST plain-symbol lookups on file removal restored (#280, #282):**
77
+ PR #281's squash merge accidentally reverted the #280 work; the
78
+ reviewed restoration is back on main. Removing a file from the AST
79
+ graph again repairs only the plain symbol entries it owned, and
80
+ `SymbolMap` completes Hash parity (single-yield `#each`,
81
+ `#to_h` applied once, `#dig` raising `TypeError` on non-diggable
82
+ intermediates), with removal/parity edge cases covered by tests.
83
+ - **Truncator output is valid UTF-8 at every step (#284):** byteslice
84
+ cutting multibyte characters midway left invalid byte sequences in
85
+ intermediate results and undercounted `omitted_chars`; sliced
86
+ substrings are now scrubbed immediately in all strategies, so
87
+ line counts and character statistics stay accurate.
88
+
89
+ ### Performance
90
+
91
+ - **Python `extract_calls` is cheaper (#278):** the string scan and
92
+ filtering path is streamlined, cutting work in hot call-extraction.
93
+ - **TokenEstimator message field evaluation streamlined (#279):**
94
+ per-message field access in token estimation is evaluated more
95
+ cheaply.
96
+ - **Security classifier avoids per-call allocations (#283):** the
97
+ home-token list in `Engine#system_path?` is hoisted into a frozen
98
+ constant, removing an array allocation on every system-path check.
99
+
100
+ ### Changed
101
+
102
+ - **No-behavior-change decompositions (#281, #285):**
103
+ `StepExecutor#execute_registered_tool` is split into private
104
+ helpers, with the file kept under the 350-line hygiene cap, and
105
+ the LLM registry's `register_provider_spec` extracts
106
+ `build_provider_instance` and `register_models_catalog` helpers.
12
107
 
13
108
  ## [0.3.5] - 2026-10-24
14
109
 
@@ -797,5 +892,6 @@ installation section.
797
892
  - Context: serialized index mutations, snapshot-outside-lock reconcile,
798
893
  streaming-first truncation.
799
894
 
800
- [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.1.0...HEAD
895
+ [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...HEAD
896
+ [0.4.1]: https://github.com/ozgurulukir/rubik/compare/v0.4.0...v0.4.1
801
897
  [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_*\s]+)\s+([A-Za-z0-9_]+)\s*\((.*?)\)\s*\{/))
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)
@@ -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
- REGEX_KEYWORDS.include?(raw[0..k][/([A-Za-z_][A-Za-z0-9_]*)\z/])
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)
@@ -216,10 +216,29 @@ module Rubik
216
216
  lang = CacheStore::LANGUAGE_MAP[ext] || "code"
217
217
 
218
218
  existing.each do |sym|
219
- @symbols.delete(sym.name) if @symbols[sym.name] == sym
220
219
  @qualified_symbols.delete("#{lang}:#{file_path}##{sym.name}")
221
220
  @qualified_symbols.delete("#{lang}:#{sym.file}##{sym.name}") if sym.file
222
221
  end
222
+
223
+ removed = {}.compare_by_identity
224
+ existing.each { |sym| removed[sym] = true }
225
+ existing.map(&:name).uniq.each do |name|
226
+ next unless removed.key?(@symbols[name])
227
+
228
+ replacement = _find_remaining_symbol_unlocked(name)
229
+ replacement ? @symbols[name] = replacement : @symbols.delete(name)
230
+ end
231
+ end
232
+
233
+ # The plain map keeps the most recently indexed definition of a name, so
234
+ # fall back to the most recently indexed file that still defines it.
235
+ def _find_remaining_symbol_unlocked(name)
236
+ @file_symbols.reverse_each do |_file_path, syms|
237
+ syms.reverse_each do |sym|
238
+ return sym if sym.name == name
239
+ end
240
+ end
241
+ nil
223
242
  end
224
243
 
225
244
  def _get_file_symbols_unlocked(file_path)
@@ -2,7 +2,9 @@
2
2
 
3
3
  module Rubik
4
4
  module AST
5
- class SymbolMap
5
+ class SymbolMap < Hash
6
+ FETCH_DEFAULT = Object.new.freeze
7
+
6
8
  def values
7
9
  qualified_values = @qualified_symbols.sort_by { |key, _symbol| key }.map(&:last)
8
10
  qualified_ids = {}.compare_by_identity
@@ -18,6 +20,92 @@ module Rubik
18
20
  def empty?
19
21
  super && @qualified_symbols.empty?
20
22
  end
23
+
24
+ def fetch(key, default = FETCH_DEFAULT)
25
+ if key?(key)
26
+ self[key]
27
+ elsif block_given?
28
+ yield(key)
29
+ elsif !default.equal?(FETCH_DEFAULT)
30
+ default
31
+ else
32
+ raise KeyError, "key not found: #{key.inspect}"
33
+ end
34
+ end
35
+
36
+ def include?(key)
37
+ key?(key)
38
+ end
39
+ alias member? include?
40
+
41
+ def keys
42
+ (super + @qualified_symbols.keys).uniq
43
+ end
44
+
45
+ def each(&)
46
+ return enum_for(:each) unless block_given?
47
+
48
+ combined = (Hash.instance_method(:to_a).bind_call(self) + @qualified_symbols.to_a).uniq(&:first)
49
+ combined.each(&)
50
+ self
51
+ end
52
+ alias each_pair each
53
+
54
+ def each_key(&)
55
+ return enum_for(:each_key) unless block_given?
56
+
57
+ keys.each(&)
58
+ self
59
+ end
60
+
61
+ def each_value(&)
62
+ return enum_for(:each_value) unless block_given?
63
+
64
+ values.each(&)
65
+ self
66
+ end
67
+
68
+ def assoc(key)
69
+ return nil unless key?(key)
70
+
71
+ [key, self[key]]
72
+ end
73
+
74
+ def to_h(&)
75
+ h = Hash.instance_method(:to_h).bind_call(self)
76
+ @qualified_symbols.each { |k, v| h[k] ||= v }
77
+ if block_given?
78
+ h.each_with_object({}) do |(k, v), acc|
79
+ nk, nv = yield(k, v)
80
+ acc[nk] = nv
81
+ end
82
+ else
83
+ h
84
+ end
85
+ end
86
+
87
+ def dig(key, *rest)
88
+ val = self[key]
89
+ return val if rest.empty? || val.nil?
90
+ return val.dig(*rest) if val.respond_to?(:dig)
91
+
92
+ raise TypeError, "#{val.class} does not have #dig method"
93
+ end
94
+
95
+ def slice(*keys_to_slice)
96
+ plain = {}
97
+ qual = {}
98
+ plain_key_check = Hash.instance_method(:key?).bind(self)
99
+ plain_fetch = Hash.instance_method(:[]).bind(self)
100
+ keys_to_slice.each do |k|
101
+ if plain_key_check.call(k)
102
+ plain[k] = plain_fetch.call(k)
103
+ elsif @qualified_symbols.key?(k)
104
+ qual[k] = @qualified_symbols[k]
105
+ end
106
+ end
107
+ SymbolMap.new(plain, qual)
108
+ end
21
109
  end
22
110
 
23
111
  class Graph
@@ -86,12 +86,12 @@ module Rubik
86
86
 
87
87
  private
88
88
 
89
+ # PERF: Uses zero-capture lookahead, scan with uniq, and delete_if filtering
90
+ # to eliminate Set instantiation and block allocations (~12% faster, ~26% fewer allocations).
89
91
  def extract_calls(body, self_name)
90
- calls = Set.new
91
- body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/) do |c|
92
- calls << c unless c == self_name || EXCLUDED_KEYWORDS.include?(c)
93
- end
94
- calls.to_a
92
+ calls = body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/).uniq
93
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
94
+ calls
95
95
  end
96
96
 
97
97
  def find_block_end(start_idx, base_indent)
@@ -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_]+)\s*(?:<.*?>)?\s*\((.*?)\)/))
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\s*(?:\*\s*)?([A-Za-z0-9_$]+)?\s*\(/))
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*[^=]+)?\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z0-9_$]+)\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*[^{]+)?\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]
@@ -21,8 +21,12 @@ module Rubik
21
21
  parse_go(text)
22
22
  when :rust
23
23
  parse_rust(text)
24
- when :zig, :c
24
+ when :zig, :c, :cpp, :"c++", :cxx
25
25
  parse_zig_c(text)
26
+ when :python, :py
27
+ parse_python(text)
28
+ when :typescript, :javascript, :ts, :js
29
+ parse_ts_js(text)
26
30
  else
27
31
  parse_generic(text)
28
32
  end
@@ -49,11 +53,11 @@ module Rubik
49
53
  def parse_go(text)
50
54
  entries = []
51
55
  text.each_line do |line|
52
- if (m = line.match(/^([^:]+):(\d+):(\d+):\s*(undefined:\s*.*)$/))
53
- entries << Entry.new(file: m[1], line: m[2], col: m[3], message: m[4], category: :undefined_ref, raw_text: line)
54
- elsif (m = line.match(/^([^:]+):(\d+):(\d+):\s*(.*)$/))
55
- entries << Entry.new(file: m[1], line: m[2], col: m[3], message: m[4], category: :syntax, raw_text: line)
56
- end
56
+ next unless (m = line.match(/^([^:]+):(\d+):(\d+):(.*)$/))
57
+
58
+ msg = m[4].strip
59
+ cat = msg.start_with?("undefined:") ? :undefined_ref : :syntax
60
+ entries << Entry.new(file: m[1], line: m[2], col: m[3], message: msg, category: cat, raw_text: line)
57
61
  end
58
62
  entries
59
63
  end
@@ -63,9 +67,9 @@ module Rubik
63
67
  current_error = nil
64
68
 
65
69
  text.each_line do |line|
66
- if (m = line.match(/^error\[(E\d+)\]:\s*(.*)$/))
67
- current_error = { code: m[1], message: m[2], category: :type }
68
- elsif current_error && (m = line.match(/-->\s*([^:]+):(\d+):(\d+)/))
70
+ if (m = line.match(/^error\[(E\d+)\]:(.*)$/))
71
+ current_error = { code: m[1], message: m[2].strip, category: :type }
72
+ elsif current_error && (m = line.match(/-->\s*([^:\s]+):(\d+):(\d+)/))
69
73
  entries << Entry.new(
70
74
  file: m[1],
71
75
  line: m[2],
@@ -84,20 +88,68 @@ module Rubik
84
88
  def parse_zig_c(text)
85
89
  entries = []
86
90
  text.each_line do |line|
87
- next unless (m = line.match(/^([^:]+):(\d+):(\d+):\s*error:\s*(.*)$/))
91
+ next unless (m = line.match(/^([^:]+):(\d+):(\d+):\s*error:(.*)$/))
88
92
 
89
- msg = m[4]
93
+ msg = m[4].strip
90
94
  cat = msg.include?("undeclared identifier") || msg.include?("use of undeclared") ? :undefined_ref : :syntax
91
95
  entries << Entry.new(file: m[1], line: m[2], col: m[3], message: msg, category: cat, raw_text: line)
92
96
  end
93
97
  entries
94
98
  end
95
99
 
100
+ def parse_python(text)
101
+ entries = []
102
+ current_file = nil
103
+ current_line = nil
104
+
105
+ text.each_line do |line|
106
+ if (m = line.match(/File "([^"]+)", line (\d+)(?:, in .*)?/))
107
+ current_file = m[1]
108
+ current_line = m[2].to_i
109
+ elsif (m = line.match(/^([A-Za-z0-9_]+Error|Exception):(.*)$/))
110
+ cat = m[1] =~ /\A(?:Syntax|Indentation|Tab)Error\z/ ? :syntax : :type
111
+ entries << Entry.new(
112
+ file: current_file,
113
+ line: current_line,
114
+ message: "#{m[1]}: #{m[2].strip}",
115
+ category: cat,
116
+ raw_text: line
117
+ )
118
+ current_file = nil
119
+ current_line = nil
120
+ end
121
+ end
122
+ entries.empty? ? parse_generic(text) : entries
123
+ end
124
+
125
+ def parse_ts_js(text)
126
+ entries = []
127
+ text.each_line do |line|
128
+ next unless (m = line.match(%r{^((?:[a-zA-Z]:[\\/])?[^(:\s]+)(?:\((\d+),(\d+)\)|:(\d+):(\d+))(?::\s*-\s*|:\s*|\s*-\s*|\s+)error(?:\s+(TS\d+))?:?(.*)$}i))
129
+
130
+ file = m[1]
131
+ line_num = (m[2] || m[4]).to_i
132
+ col_num = (m[3] || m[5]).to_i
133
+ code = m[6]
134
+ msg = m[7].strip
135
+ entries << Entry.new(
136
+ file: file,
137
+ line: line_num,
138
+ col: col_num,
139
+ code: code,
140
+ message: msg,
141
+ category: :syntax,
142
+ raw_text: line
143
+ )
144
+ end
145
+ entries.empty? ? parse_generic(text) : entries
146
+ end
147
+
96
148
  def parse_generic(text)
97
149
  entries = []
98
150
  text.each_line do |line|
99
- if (m = line.match(/^([^:\s]+):(\d+):(?:\d+:)?\s*(.*)$/))
100
- entries << Entry.new(file: m[1], line: m[2], message: m[3], category: :unknown, raw_text: line)
151
+ if (m = line.match(/^([^:\s]+):(\d+):(?:\d+:)?(.*)$/))
152
+ entries << Entry.new(file: m[1], line: m[2], message: m[3].strip, category: :unknown, raw_text: line)
101
153
  end
102
154
  end
103
155
  entries
@@ -14,7 +14,8 @@ module Rubik
14
14
  raw = url.to_s.strip
15
15
  return raw if raw.empty?
16
16
 
17
- clean = raw.sub(%r{/+\z}, "")
17
+ clean = raw
18
+ clean = clean.delete_suffix("/") while clean.end_with?("/")
18
19
  return clean if clean.end_with?("/chat/completions") || clean.end_with?("/completions")
19
20
 
20
21
  if clean == "https://api.deepseek.com"
@@ -98,87 +98,15 @@ module Rubik
98
98
  end
99
99
 
100
100
  def register_provider_spec(p_name, p_spec)
101
- type = p_spec["type"].to_s.downcase
102
101
  endpoint = p_spec["endpoint"] || p_spec["host"] || p_spec["url"]
103
- api_key_env = p_spec["api_key_env"]
104
- api_key = p_spec["api_key"]
105
102
  global_headers = extract_global_headers(@config)
106
103
  spec_headers = p_spec["headers"] || {}
107
104
  headers = global_headers.merge(spec_headers)
108
105
  models = p_spec["models"] || []
109
106
  default_model = p_spec["default_model"] || (models.is_a?(Array) && models.first.is_a?(Hash) ? models.first["name"] : models.first)
110
107
 
111
- provider_instance = case type
112
- when "openai_compatible", "openai"
113
- OpenAICompatibleProvider.new(
114
- name: p_name,
115
- endpoint: endpoint || "https://api.openai.com/v1/chat/completions",
116
- api_key: api_key,
117
- api_key_env: api_key_env,
118
- headers: headers,
119
- model: default_model || "gpt-4o",
120
- default_context_limit: spec_limit(p_spec, :context),
121
- default_max_output_tokens: spec_limit(p_spec, :output),
122
- tool_native: p_spec.fetch("tool_native", true)
123
- )
124
- when "ollama"
125
- OllamaProvider.new(
126
- name: p_name,
127
- endpoint: endpoint || "http://localhost:11434",
128
- model: default_model || "qwen2.5-coder:7b",
129
- num_ctx: p_spec["num_ctx"],
130
- headers: headers
131
- )
132
- when "anthropic"
133
- AnthropicProvider.new(
134
- name: p_name,
135
- model: default_model || "claude-sonnet-5-5",
136
- # Honor the same api_key / api_key_env that
137
- # openai-compatible specs already forward;
138
- # otherwise Anthropic auth fails silently.
139
- api_key: api_key,
140
- api_key_env: api_key_env,
141
- headers: headers
142
- )
143
- else
144
- # Default fallback to openai_compatible
145
- OpenAICompatibleProvider.new(
146
- name: p_name,
147
- endpoint: endpoint || "http://localhost:8000/v1/chat/completions",
148
- api_key: api_key,
149
- api_key_env: api_key_env,
150
- headers: headers,
151
- model: default_model || "default",
152
- default_context_limit: spec_limit(p_spec, :context),
153
- default_max_output_tokens: spec_limit(p_spec, :output),
154
- tool_native: p_spec.fetch("tool_native", true)
155
- )
156
- end
157
-
158
- @providers[p_name] = provider_instance
159
-
160
- # Register models in catalog
161
- return unless models.is_a?(Array)
162
-
163
- models.each do |m_entry|
164
- if m_entry.is_a?(Hash)
165
- m_name = m_entry["name"]
166
- c_limit = m_entry["context_limit"] || m_entry["context_window"] || 32_768
167
- @models_catalog["#{p_name}/#{m_name}"] = {
168
- provider: p_name,
169
- name: m_name,
170
- context_limit: c_limit.to_i,
171
- endpoint: endpoint
172
- }
173
- elsif m_entry.is_a?(String)
174
- @models_catalog["#{p_name}/#{m_entry}"] = {
175
- provider: p_name,
176
- name: m_entry,
177
- context_limit: 32_768,
178
- endpoint: endpoint
179
- }
180
- end
181
- end
108
+ @providers[p_name] = build_provider_instance(p_name, p_spec, headers, default_model, endpoint)
109
+ register_models_catalog(p_name, models, endpoint)
182
110
  end
183
111
 
184
112
  def register(provider_name, provider_instance)
@@ -331,6 +259,85 @@ module Rubik
331
259
  end
332
260
  result
333
261
  end
262
+
263
+ private
264
+
265
+ def build_provider_instance(p_name, p_spec, headers, default_model, endpoint)
266
+ type = p_spec["type"].to_s.downcase
267
+ api_key_env = p_spec["api_key_env"]
268
+ api_key = p_spec["api_key"]
269
+
270
+ case type
271
+ when "openai_compatible", "openai"
272
+ OpenAICompatibleProvider.new(
273
+ name: p_name,
274
+ endpoint: endpoint || "https://api.openai.com/v1/chat/completions",
275
+ api_key: api_key,
276
+ api_key_env: api_key_env,
277
+ headers: headers,
278
+ model: default_model || "gpt-4o",
279
+ default_context_limit: spec_limit(p_spec, :context),
280
+ default_max_output_tokens: spec_limit(p_spec, :output),
281
+ tool_native: p_spec.fetch("tool_native", true)
282
+ )
283
+ when "ollama"
284
+ OllamaProvider.new(
285
+ name: p_name,
286
+ endpoint: endpoint || "http://localhost:11434",
287
+ model: default_model || "qwen2.5-coder:7b",
288
+ num_ctx: p_spec["num_ctx"],
289
+ headers: headers
290
+ )
291
+ when "anthropic"
292
+ AnthropicProvider.new(
293
+ name: p_name,
294
+ model: default_model || "claude-sonnet-5-5",
295
+ # Honor the same api_key / api_key_env that
296
+ # openai-compatible specs already forward;
297
+ # otherwise Anthropic auth fails silently.
298
+ api_key: api_key,
299
+ api_key_env: api_key_env,
300
+ headers: headers
301
+ )
302
+ else
303
+ # Default fallback to openai_compatible
304
+ OpenAICompatibleProvider.new(
305
+ name: p_name,
306
+ endpoint: endpoint || "http://localhost:8000/v1/chat/completions",
307
+ api_key: api_key,
308
+ api_key_env: api_key_env,
309
+ headers: headers,
310
+ model: default_model || "default",
311
+ default_context_limit: spec_limit(p_spec, :context),
312
+ default_max_output_tokens: spec_limit(p_spec, :output),
313
+ tool_native: p_spec.fetch("tool_native", true)
314
+ )
315
+ end
316
+ end
317
+
318
+ def register_models_catalog(p_name, models, endpoint)
319
+ return unless models.is_a?(Array)
320
+
321
+ models.each do |m_entry|
322
+ if m_entry.is_a?(Hash)
323
+ m_name = m_entry["name"]
324
+ c_limit = m_entry["context_limit"] || m_entry["context_window"] || 32_768
325
+ @models_catalog["#{p_name}/#{m_name}"] = {
326
+ provider: p_name,
327
+ name: m_name,
328
+ context_limit: c_limit.to_i,
329
+ endpoint: endpoint
330
+ }
331
+ elsif m_entry.is_a?(String)
332
+ @models_catalog["#{p_name}/#{m_entry}"] = {
333
+ provider: p_name,
334
+ name: m_entry,
335
+ context_limit: 32_768,
336
+ endpoint: endpoint
337
+ }
338
+ end
339
+ end
340
+ end
334
341
  end
335
342
  end
336
343
  end