shared_tools 0.4.4 → 0.6.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/CHANGELOG.md +40 -0
- data/README.md +211 -7
- data/lib/shared_tools/apply_patch_tool.rb +4 -0
- data/lib/shared_tools/bash_tool.rb +4 -0
- data/lib/shared_tools/bundle_tool.rb +4 -0
- data/lib/shared_tools/command_guard.rb +35 -0
- data/lib/shared_tools/csv_read_tool.rb +4 -0
- data/lib/shared_tools/csv_write_tool.rb +4 -0
- data/lib/shared_tools/data_path.rb +52 -0
- data/lib/shared_tools/diff_tool.rb +4 -0
- data/lib/shared_tools/download_file_tool.rb +4 -0
- data/lib/shared_tools/gem_tool.rb +4 -0
- data/lib/shared_tools/git_tool.rb +5 -0
- data/lib/shared_tools/glob_tool.rb +4 -0
- data/lib/shared_tools/http_request_tool.rb +4 -0
- data/lib/shared_tools/json_query_tool.rb +4 -0
- data/lib/shared_tools/lint_tool.rb +4 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
- data/lib/shared_tools/mcp/chart_client.rb +18 -15
- data/lib/shared_tools/mcp/client_support.rb +103 -0
- data/lib/shared_tools/mcp/github_client.rb +23 -19
- data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
- data/lib/shared_tools/mcp/memory_client.rb +20 -15
- data/lib/shared_tools/mcp/notion_client.rb +21 -19
- data/lib/shared_tools/mcp/playwright_client.rb +19 -15
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
- data/lib/shared_tools/mcp/slack_client.rb +30 -25
- data/lib/shared_tools/mcp/tavily_client.rb +21 -21
- data/lib/shared_tools/mcp.rb +32 -41
- data/lib/shared_tools/multi_edit_tool.rb +4 -0
- data/lib/shared_tools/parse_ruby_tool.rb +4 -0
- data/lib/shared_tools/process_kill_tool.rb +4 -0
- data/lib/shared_tools/process_list_tool.rb +4 -0
- data/lib/shared_tools/process_output_tool.rb +4 -0
- data/lib/shared_tools/process_registry.rb +224 -0
- data/lib/shared_tools/process_runner.rb +69 -0
- data/lib/shared_tools/process_start_tool.rb +4 -0
- data/lib/shared_tools/python_tests_tool.rb +4 -0
- data/lib/shared_tools/replace_in_files_tool.rb +4 -0
- data/lib/shared_tools/ruby_outline.rb +207 -0
- data/lib/shared_tools/run_tests_tool.rb +4 -0
- data/lib/shared_tools/text_diff.rb +80 -0
- data/lib/shared_tools/todo_write_tool.rb +4 -0
- data/lib/shared_tools/toml_parser.rb +409 -0
- data/lib/shared_tools/toml_query_tool.rb +4 -0
- data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
- data/lib/shared_tools/tools/bash_tool.rb +102 -0
- data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
- data/lib/shared_tools/tools/browser_tool.rb +1 -1
- data/lib/shared_tools/tools/bundle_tool.rb +96 -0
- data/lib/shared_tools/tools/calculator_tool.rb +1 -1
- data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
- data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
- data/lib/shared_tools/tools/computer_tool.rb +1 -1
- data/lib/shared_tools/tools/cron_tool.rb +1 -1
- data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
- data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
- data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
- data/lib/shared_tools/tools/data_science_kit.rb +1 -1
- data/lib/shared_tools/tools/database_query_tool.rb +1 -1
- data/lib/shared_tools/tools/database_tool.rb +1 -1
- data/lib/shared_tools/tools/diff_tool.rb +48 -0
- data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
- data/lib/shared_tools/tools/disk_tool.rb +1 -1
- data/lib/shared_tools/tools/dns_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc_tool.rb +1 -1
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/download_file_tool.rb +87 -0
- data/lib/shared_tools/tools/enabler.rb +2 -2
- data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/shell_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval_tool.rb +1 -1
- data/lib/shared_tools/tools/gem_tool.rb +157 -0
- data/lib/shared_tools/tools/git/add_tool.rb +73 -0
- data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
- data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
- data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
- data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
- data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
- data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
- data/lib/shared_tools/tools/git/helpers.rb +70 -0
- data/lib/shared_tools/tools/git/log_tool.rb +71 -0
- data/lib/shared_tools/tools/git/show_tool.rb +66 -0
- data/lib/shared_tools/tools/git/status_tool.rb +46 -0
- data/lib/shared_tools/tools/git.rb +18 -0
- data/lib/shared_tools/tools/git_tool.rb +148 -0
- data/lib/shared_tools/tools/glob_tool.rb +75 -0
- data/lib/shared_tools/tools/http_helpers.rb +119 -0
- data/lib/shared_tools/tools/http_request_tool.rb +91 -0
- data/lib/shared_tools/tools/json_query_tool.rb +81 -0
- data/lib/shared_tools/tools/lint_tool.rb +93 -0
- data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
- data/lib/shared_tools/tools/notification_tool.rb +1 -1
- data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
- data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
- data/lib/shared_tools/tools/process_list_tool.rb +41 -0
- data/lib/shared_tools/tools/process_output_tool.rb +69 -0
- data/lib/shared_tools/tools/process_start_tool.rb +0 -0
- data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
- data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
- data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
- data/lib/shared_tools/tools/search_codebase_tool.rb +178 -47
- data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
- data/lib/shared_tools/tools/system_info_tool.rb +1 -1
- data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
- data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
- data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
- data/lib/shared_tools/tools/tree_tool.rb +100 -0
- data/lib/shared_tools/tools/weather_tool.rb +1 -1
- data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
- data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
- data/lib/shared_tools/tree_tool.rb +4 -0
- data/lib/shared_tools/url_guard.rb +106 -0
- data/lib/shared_tools/utilities.rb +2 -2
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/web_fetch_tool.rb +4 -0
- data/lib/shared_tools/yaml_query_tool.rb +4 -0
- data/lib/shared_tools.rb +1 -1
- metadata +516 -409
- data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
- data/lib/shared_tools/tools/version.rb +0 -7
data/lib/shared_tools/mcp.rb
CHANGED
|
@@ -1,53 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/shared_tools/mcp.rb
|
|
2
4
|
#
|
|
3
|
-
# MCP (Model Context Protocol)
|
|
4
|
-
# Requires
|
|
5
|
-
#
|
|
6
|
-
# @see https://github.com/patvice/ruby_llm-mcp
|
|
7
|
-
# @see https://www.rubyllm-mcp.com
|
|
8
|
-
#
|
|
9
|
-
# Two categories of client are provided, both requiring no pre-installed binaries:
|
|
5
|
+
# MCP (Model Context Protocol) server definitions for SharedTools.
|
|
6
|
+
# Requires ruby_llm >= 2.1, whose built-in RubyLLM::MCP is the client.
|
|
7
|
+
# (The separate ruby_llm-mcp gem is not used and cannot be loaded alongside it.)
|
|
10
8
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
9
|
+
# Each server is a RubyLLM::MCP subclass under SharedTools::MCP. Requiring a
|
|
10
|
+
# definition has no side effects — nothing is started, downloaded or connected
|
|
11
|
+
# until a chat or robot first asks the server for its tools.
|
|
13
12
|
#
|
|
14
|
-
#
|
|
13
|
+
# REMOTE HTTP (Streamable HTTP)
|
|
14
|
+
# SharedTools::MCP::Tavily # Web search (TAVILY_API_KEY)
|
|
15
15
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
16
|
+
# LOCAL BINARY (stdio; install with Klass.install_packages!)
|
|
17
|
+
# SharedTools::MCP::GitHub # GITHUB_PERSONAL_ACCESS_TOKEN
|
|
18
|
+
# SharedTools::MCP::Notion # NOTION_TOKEN
|
|
19
|
+
# SharedTools::MCP::Slack # SLACK_MCP_XOXP_TOKEN or SLACK_MCP_XOXB_TOKEN
|
|
20
|
+
# SharedTools::MCP::HuggingFace # HF_TOKEN
|
|
18
21
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
22
|
+
# NPX AUTO-DOWNLOAD (stdio via npx -y; requires Node.js)
|
|
23
|
+
# SharedTools::MCP::Memory # Persistent knowledge graph
|
|
24
|
+
# SharedTools::MCP::SequentialThinking # Chain-of-thought reasoning
|
|
25
|
+
# SharedTools::MCP::Chart # Chart / visualisation generation
|
|
26
|
+
# SharedTools::MCP::BraveSearch # Web search (BRAVE_API_KEY)
|
|
27
|
+
# SharedTools::MCP::Playwright # Browser automation
|
|
23
28
|
#
|
|
24
|
-
# Requiring this file loads
|
|
25
|
-
#
|
|
26
|
-
# time equals the slowest single client rather than the sum of all clients.
|
|
29
|
+
# Requiring this file loads every definition and records, per server, whether
|
|
30
|
+
# its required environment variables are set (see SharedTools.mcp_status).
|
|
27
31
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# client = RubyLLM::MCP.clients["client-name"]
|
|
32
|
-
# chat = RubyLLM.chat.with_tools(*client.tools)
|
|
32
|
+
# require 'shared_tools/mcp'
|
|
33
|
+
# chat = RubyLLM.chat.with_mcp(*SharedTools::MCP.available)
|
|
34
|
+
# robot = RobotLab.build(name: "bot", mcp: SharedTools::MCP.available)
|
|
33
35
|
|
|
34
|
-
require_relative "mcp/
|
|
36
|
+
require_relative "mcp/client_support"
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
# Derive the canonical client name from the filename:
|
|
38
|
-
# "brave_search_client.rb" → "brave-search", "hugging_face_client.rb" → "hugging-face"
|
|
39
|
-
client_name = File.basename(path, "_client.rb").tr("_", "-")
|
|
38
|
+
Dir[File.join(__dir__, "mcp", "*_client.rb")].sort.each { |path| require path }
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# LoadError (missing env var / package) is a ScriptError, not a StandardError,
|
|
46
|
-
# so bare `rescue => e` does not catch it. We record the failure and warn so
|
|
47
|
-
# that missing-key clients are simply skipped when requiring mcp.rb as a whole.
|
|
48
|
-
SharedTools.record_mcp_result(client_name, error: e)
|
|
49
|
-
warn "SharedTools::MCP — skipping #{File.basename(path)}: #{e.message}"
|
|
50
|
-
end
|
|
40
|
+
SharedTools::MCP.clients.each do |klass|
|
|
41
|
+
missing = klass.missing_envars
|
|
42
|
+
error = missing.empty? ? nil : LoadError.new("Missing envars: #{missing.join(', ')}")
|
|
43
|
+
SharedTools.record_mcp_result(klass.default_name, error: error)
|
|
51
44
|
end
|
|
52
|
-
|
|
53
|
-
threads.each(&:join)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
# A single managed background process, used by ProcessRegistry. Its stdout
|
|
7
|
+
# and stderr are drained continuously by reader threads into bounded
|
|
8
|
+
# buffers, so a chatty child can't deadlock on a full pipe or grow memory
|
|
9
|
+
# without limit. Output is read incrementally (each read returns only what's
|
|
10
|
+
# new since the last one). The child runs in its own process group so it —
|
|
11
|
+
# and any children it spawns — can be killed together.
|
|
12
|
+
class ManagedProcess
|
|
13
|
+
MAX_BUFFER = 256 * 1024 # retain at most this many unread bytes per stream
|
|
14
|
+
|
|
15
|
+
attr_reader :id, :name, :argv, :pid, :started_at
|
|
16
|
+
|
|
17
|
+
def initialize(id:, argv:, env:, chdir:, name:)
|
|
18
|
+
@id = id
|
|
19
|
+
@argv = argv
|
|
20
|
+
@name = name
|
|
21
|
+
@started_at = Time.now
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@out = +""
|
|
24
|
+
@err = +""
|
|
25
|
+
@out_dropped = false
|
|
26
|
+
@err_dropped = false
|
|
27
|
+
|
|
28
|
+
opts = { unsetenv_others: true, pgroup: true }
|
|
29
|
+
opts[:chdir] = chdir if chdir && !chdir.to_s.empty?
|
|
30
|
+
|
|
31
|
+
stdin, stdout, stderr, @wait_thr = Open3.popen3(env, *argv, **opts)
|
|
32
|
+
@pid = @wait_thr.pid
|
|
33
|
+
begin
|
|
34
|
+
stdin.close
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@readers = [drain(stdout, :out), drain(stderr, :err)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def running?
|
|
43
|
+
@wait_thr.alive?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def status
|
|
47
|
+
running? ? :running : :exited
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exit_code
|
|
51
|
+
return nil if running?
|
|
52
|
+
|
|
53
|
+
@wait_thr.value.exitstatus
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def age
|
|
59
|
+
Time.now - @started_at
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns and clears the output accumulated since the previous call.
|
|
63
|
+
def read_new
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
data = { out: @out.dup, err: @err.dup, out_dropped: @out_dropped, err_dropped: @err_dropped }
|
|
66
|
+
@out = +""
|
|
67
|
+
@err = +""
|
|
68
|
+
@out_dropped = false
|
|
69
|
+
@err_dropped = false
|
|
70
|
+
data
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# SIGTERM the whole tree, escalate to SIGKILL after a grace period.
|
|
75
|
+
# Descendants are collected up front (before the parent dies and they get
|
|
76
|
+
# reparented), then signalled both via the process group and individually
|
|
77
|
+
# — so cleanup is reliable even in sandboxes that don't deliver
|
|
78
|
+
# process-group signals to non-leader members.
|
|
79
|
+
def kill(grace: 2.0)
|
|
80
|
+
return unless @wait_thr.alive?
|
|
81
|
+
|
|
82
|
+
targets = [@pid] + descendants(@pid)
|
|
83
|
+
signal_group("TERM")
|
|
84
|
+
targets.each { |pid| signal_pid(pid, "TERM") }
|
|
85
|
+
|
|
86
|
+
deadline = Time.now + grace
|
|
87
|
+
sleep(0.05) while @wait_thr.alive? && Time.now < deadline
|
|
88
|
+
|
|
89
|
+
signal_group("KILL")
|
|
90
|
+
targets.each { |pid| signal_pid(pid, "KILL") }
|
|
91
|
+
begin
|
|
92
|
+
@wait_thr.value
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def signal_group(sig)
|
|
101
|
+
Process.kill("-#{sig}", @pid) # negative pid => the process group
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def signal_pid(pid, sig)
|
|
107
|
+
Process.kill(sig, pid)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# All transitive children of root, via /proc (Linux). Collected before the
|
|
113
|
+
# parent is killed, so the parent->child links are still intact. No-ops
|
|
114
|
+
# (returns []) on hosts without /proc, e.g. macOS.
|
|
115
|
+
def descendants(root)
|
|
116
|
+
return [] unless File.directory?("/proc")
|
|
117
|
+
|
|
118
|
+
children = Hash.new { |h, k| h[k] = [] }
|
|
119
|
+
Dir.glob("/proc/[0-9]*/stat").each do |file|
|
|
120
|
+
data = File.read(file)
|
|
121
|
+
open_paren = data.index("(")
|
|
122
|
+
close_paren = data.rindex(")")
|
|
123
|
+
next unless open_paren && close_paren
|
|
124
|
+
|
|
125
|
+
pid = data[0...open_paren].to_i
|
|
126
|
+
ppid = data[(close_paren + 2)..].to_s.split[1].to_i
|
|
127
|
+
children[ppid] << pid
|
|
128
|
+
rescue StandardError
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
result = []
|
|
133
|
+
queue = children[root].dup
|
|
134
|
+
until queue.empty?
|
|
135
|
+
pid = queue.shift
|
|
136
|
+
next if result.include?(pid)
|
|
137
|
+
|
|
138
|
+
result << pid
|
|
139
|
+
queue.concat(children[pid])
|
|
140
|
+
end
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def drain(io, which)
|
|
145
|
+
Thread.new do
|
|
146
|
+
loop do
|
|
147
|
+
append(which, io.readpartial(4096))
|
|
148
|
+
end
|
|
149
|
+
rescue EOFError, IOError
|
|
150
|
+
nil
|
|
151
|
+
ensure
|
|
152
|
+
begin
|
|
153
|
+
io.close
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def append(which, chunk)
|
|
161
|
+
@mutex.synchronize do
|
|
162
|
+
buf = which == :out ? @out : @err
|
|
163
|
+
buf << chunk
|
|
164
|
+
next unless buf.bytesize > MAX_BUFFER
|
|
165
|
+
|
|
166
|
+
overflow = buf.bytesize - MAX_BUFFER
|
|
167
|
+
buf.replace(buf.byteslice(overflow, MAX_BUFFER) || +"")
|
|
168
|
+
if which == :out
|
|
169
|
+
@out_dropped = true
|
|
170
|
+
else
|
|
171
|
+
@err_dropped = true
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Thread-safe registry of background processes, shared across tool calls
|
|
178
|
+
# within a process. Holds an upper bound on concurrent live processes and
|
|
179
|
+
# cleans everything up at interpreter exit so nothing is orphaned.
|
|
180
|
+
module ProcessRegistry
|
|
181
|
+
class LimitError < StandardError; end
|
|
182
|
+
|
|
183
|
+
@mutex = Mutex.new
|
|
184
|
+
@procs = {}
|
|
185
|
+
@counter = 0
|
|
186
|
+
|
|
187
|
+
class << self
|
|
188
|
+
def start(argv:, env:, chdir:, name:, max: 8)
|
|
189
|
+
@mutex.synchronize do
|
|
190
|
+
live = @procs.values.count(&:running?)
|
|
191
|
+
raise LimitError, "too many background processes (limit #{max}); kill some first" if live >= max
|
|
192
|
+
|
|
193
|
+
@counter += 1
|
|
194
|
+
id = "proc_#{@counter}"
|
|
195
|
+
@procs[id] = ManagedProcess.new(id: id, argv: argv, env: env, chdir: chdir, name: name)
|
|
196
|
+
id
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def get(id)
|
|
201
|
+
@mutex.synchronize { @procs[id] }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def all
|
|
205
|
+
@mutex.synchronize { @procs.values.dup }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def delete(id)
|
|
209
|
+
@mutex.synchronize { @procs.delete(id) }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def kill_all
|
|
213
|
+
all.each { |proc| proc.kill(grace: 0.2) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def reset!
|
|
217
|
+
all.each { |proc| proc.kill(grace: 0.2) }
|
|
218
|
+
@mutex.synchronize { @procs = {} }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
at_exit { SharedTools::ProcessRegistry.kill_all }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
# Shared subprocess runner used by BashTool and the toolchain tools
|
|
8
|
+
# (BundleTool, LintTool, RunTestsTool, PythonTestsTool). Always array-form
|
|
9
|
+
# (no shell), streams both pipes so a chatty child can't deadlock on a full
|
|
10
|
+
# pipe buffer, and hard-kills the process if it blows the wall-clock budget.
|
|
11
|
+
#
|
|
12
|
+
# Returns [stdout, stderr, status] where status is a Process::Status or the
|
|
13
|
+
# symbol :timeout.
|
|
14
|
+
module ProcessRunner
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def capture(argv, env: {}, stdin: nil, timeout: 30, unsetenv_others: true, chdir: nil)
|
|
18
|
+
opts = { unsetenv_others: unsetenv_others }
|
|
19
|
+
opts[:chdir] = chdir if chdir
|
|
20
|
+
Open3.popen3(env, *argv, **opts) do |i, o, e, thr|
|
|
21
|
+
write_stdin(i, stdin)
|
|
22
|
+
pump(o, e, thr, timeout)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def write_stdin(io, stdin)
|
|
27
|
+
io.write(stdin) if stdin && !stdin.empty?
|
|
28
|
+
rescue StandardError
|
|
29
|
+
# Child may have exited/closed the pipe before we finished writing.
|
|
30
|
+
nil
|
|
31
|
+
ensure
|
|
32
|
+
begin
|
|
33
|
+
io.close
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pump(stdout, stderr, wait_thr, timeout)
|
|
40
|
+
out = +""
|
|
41
|
+
err = +""
|
|
42
|
+
readers = [stdout, stderr]
|
|
43
|
+
|
|
44
|
+
Timeout.timeout(timeout) do
|
|
45
|
+
until readers.empty?
|
|
46
|
+
ready, = IO.select(readers)
|
|
47
|
+
ready.each do |io|
|
|
48
|
+
chunk = io.read_nonblock(4096)
|
|
49
|
+
(io.equal?(stdout) ? out : err) << chunk
|
|
50
|
+
rescue IO::WaitReadable
|
|
51
|
+
next
|
|
52
|
+
rescue EOFError
|
|
53
|
+
readers.delete(io)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
[out, err, wait_thr.value]
|
|
57
|
+
end
|
|
58
|
+
rescue Timeout::Error
|
|
59
|
+
kill(wait_thr.pid)
|
|
60
|
+
[out, err, :timeout]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def kill(pid)
|
|
64
|
+
Process.kill("KILL", pid)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
# Extracts a structural outline (classes, modules, methods, constants) from
|
|
7
|
+
# Ruby source, with line numbers and nesting depth, for ParseRubyTool.
|
|
8
|
+
# Parsing only — the code is never executed.
|
|
9
|
+
#
|
|
10
|
+
# Two backends sit behind one dispatcher and produce identical Entry lists:
|
|
11
|
+
#
|
|
12
|
+
# * PrismBackend — used automatically when `require "prism"` succeeds.
|
|
13
|
+
# Prism is bundled with Ruby 3.3+, so on a modern Ruby this needs no gem
|
|
14
|
+
# install. It is the same parser the VM itself uses, so its line numbers
|
|
15
|
+
# and structure are authoritative.
|
|
16
|
+
# * RipperBackend — the stdlib fallback for runtimes that don't bundle
|
|
17
|
+
# Prism. Dependency-free, always present.
|
|
18
|
+
module RubyOutline
|
|
19
|
+
class ParseError < StandardError; end
|
|
20
|
+
|
|
21
|
+
Entry = Struct.new(:kind, :name, :line, :depth, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# True when the Prism backend can be loaded on this Ruby. Memoized.
|
|
26
|
+
def prism_available?
|
|
27
|
+
return @prism_available if defined?(@prism_available)
|
|
28
|
+
|
|
29
|
+
@prism_available = begin
|
|
30
|
+
require "prism"
|
|
31
|
+
true
|
|
32
|
+
rescue LoadError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def active_backend
|
|
38
|
+
prism_available? ? PrismBackend : RipperBackend
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns an Array<Entry> in source order. Raises ParseError on a syntax
|
|
42
|
+
# error. Pass backend: to force a specific one.
|
|
43
|
+
def extract(source, backend: active_backend)
|
|
44
|
+
backend.extract(source.to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# --- Prism backend ----------------------------------------------------
|
|
48
|
+
module PrismBackend
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def extract(source)
|
|
52
|
+
require "prism"
|
|
53
|
+
result = Prism.parse(source)
|
|
54
|
+
raise ParseError, "source is not valid Ruby (syntax error)" unless result.success?
|
|
55
|
+
|
|
56
|
+
acc = []
|
|
57
|
+
visit(result.value, 0, acc)
|
|
58
|
+
acc
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def visit(node, depth, acc)
|
|
62
|
+
return if node.nil?
|
|
63
|
+
|
|
64
|
+
case node
|
|
65
|
+
when Prism::ClassNode
|
|
66
|
+
acc << Entry.new(kind: :class, name: node.constant_path.slice,
|
|
67
|
+
line: node.constant_path.location.start_line, depth: depth)
|
|
68
|
+
visit(node.body, depth + 1, acc)
|
|
69
|
+
when Prism::ModuleNode
|
|
70
|
+
acc << Entry.new(kind: :module, name: node.constant_path.slice,
|
|
71
|
+
line: node.constant_path.location.start_line, depth: depth)
|
|
72
|
+
visit(node.body, depth + 1, acc)
|
|
73
|
+
when Prism::SingletonClassNode
|
|
74
|
+
acc << Entry.new(kind: :singleton_class, name: "<< #{node.expression.slice}",
|
|
75
|
+
line: node.location.start_line, depth: depth)
|
|
76
|
+
visit(node.body, depth + 1, acc)
|
|
77
|
+
when Prism::DefNode
|
|
78
|
+
name = node.receiver ? "#{node.receiver.slice}.#{node.name}" : node.name.to_s
|
|
79
|
+
acc << Entry.new(kind: :method, name: name, line: node.name_loc.start_line, depth: depth)
|
|
80
|
+
# method bodies are not descended into
|
|
81
|
+
when Prism::ConstantWriteNode
|
|
82
|
+
acc << Entry.new(kind: :constant, name: node.name.to_s,
|
|
83
|
+
line: node.name_loc.start_line, depth: depth)
|
|
84
|
+
else
|
|
85
|
+
node.compact_child_nodes.each { |child| visit(child, depth, acc) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# --- Ripper backend (stdlib) ------------------------------------------
|
|
91
|
+
module RipperBackend
|
|
92
|
+
module_function
|
|
93
|
+
|
|
94
|
+
def extract(source)
|
|
95
|
+
sexp = Ripper.sexp(source.to_s)
|
|
96
|
+
raise ParseError, "source is not valid Ruby (syntax error)" if sexp.nil?
|
|
97
|
+
|
|
98
|
+
acc = []
|
|
99
|
+
walk(sexp, 0, acc)
|
|
100
|
+
acc
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def walk(node, depth, acc)
|
|
104
|
+
return unless node.is_a?(Array)
|
|
105
|
+
|
|
106
|
+
if node[0].is_a?(Symbol)
|
|
107
|
+
dispatch(node, depth, acc)
|
|
108
|
+
else
|
|
109
|
+
node.each { |child| walk(child, depth, acc) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def dispatch(node, depth, acc)
|
|
114
|
+
case node[0]
|
|
115
|
+
when :program
|
|
116
|
+
walk(node[1], depth, acc)
|
|
117
|
+
when :class
|
|
118
|
+
acc << Entry.new(kind: :class, name: const_name(node[1]), line: line_of(node[1]), depth: depth)
|
|
119
|
+
walk(node[3], depth + 1, acc) # bodystmt
|
|
120
|
+
when :module
|
|
121
|
+
acc << Entry.new(kind: :module, name: const_name(node[1]), line: line_of(node[1]), depth: depth)
|
|
122
|
+
walk(node[2], depth + 1, acc)
|
|
123
|
+
when :sclass # class << self
|
|
124
|
+
acc << Entry.new(kind: :singleton_class, name: "<< #{simple_name(node[1])}", line: line_of(node), depth: depth)
|
|
125
|
+
walk(node[2], depth + 1, acc)
|
|
126
|
+
when :def
|
|
127
|
+
acc << Entry.new(kind: :method, name: ident_name(node[1]), line: line_of(node[1]), depth: depth)
|
|
128
|
+
when :defs # def self.x / def Recv.x
|
|
129
|
+
name = "#{simple_name(node[1])}.#{ident_name(node[3])}"
|
|
130
|
+
acc << Entry.new(kind: :method, name: name, line: line_of(node[3]), depth: depth)
|
|
131
|
+
when :bodystmt
|
|
132
|
+
walk(node[1], depth, acc)
|
|
133
|
+
when :assign
|
|
134
|
+
handle_assign(node, depth, acc)
|
|
135
|
+
else
|
|
136
|
+
node[1..].each { |child| walk(child, depth, acc) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def handle_assign(node, depth, acc)
|
|
141
|
+
target = node[1]
|
|
142
|
+
return unless target.is_a?(Array) && target[0] == :var_field
|
|
143
|
+
|
|
144
|
+
field = target[1]
|
|
145
|
+
return unless field.is_a?(Array) && field[0] == :@const
|
|
146
|
+
|
|
147
|
+
acc << Entry.new(kind: :constant, name: field[1], line: field[2]&.first, depth: depth)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def const_name(node)
|
|
151
|
+
case node && node[0]
|
|
152
|
+
when :const_ref, :top_const_ref, :var_ref
|
|
153
|
+
simple_name(node[1])
|
|
154
|
+
when :const_path_ref # Foo::Bar
|
|
155
|
+
"#{const_name(node[1])}::#{simple_name(node[2])}"
|
|
156
|
+
else
|
|
157
|
+
simple_name(node)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def simple_name(node)
|
|
162
|
+
return node.to_s unless node.is_a?(Array)
|
|
163
|
+
|
|
164
|
+
case node[0]
|
|
165
|
+
when :@const, :@ident, :@ivar, :@gvar, :@kw then node[1].to_s
|
|
166
|
+
when :const_ref, :var_ref, :var_field then simple_name(node[1])
|
|
167
|
+
when :const_path_ref then "#{simple_name(node[1])}::#{simple_name(node[2])}"
|
|
168
|
+
else
|
|
169
|
+
leaf = find_name_leaf(node)
|
|
170
|
+
leaf ? leaf[1].to_s : "?"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def ident_name(node)
|
|
175
|
+
return node.to_s unless node.is_a?(Array)
|
|
176
|
+
|
|
177
|
+
node[0] == :@ident || node[0] == :@const || node[0] == :@kw ? node[1].to_s : simple_name(node)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def line_of(node)
|
|
181
|
+
return nil unless node.is_a?(Array)
|
|
182
|
+
|
|
183
|
+
if node.size == 3 && node[2].is_a?(Array) && node[2].size == 2 &&
|
|
184
|
+
node[2][0].is_a?(Integer) && node[0].is_a?(Symbol)
|
|
185
|
+
return node[2][0]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
node.each do |child|
|
|
189
|
+
line = line_of(child)
|
|
190
|
+
return line if line
|
|
191
|
+
end
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def find_name_leaf(node)
|
|
196
|
+
return nil unless node.is_a?(Array)
|
|
197
|
+
return node if %i[@const @ident @kw].include?(node[0])
|
|
198
|
+
|
|
199
|
+
node.each do |child|
|
|
200
|
+
found = find_name_leaf(child)
|
|
201
|
+
return found if found
|
|
202
|
+
end
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|