docscribe 1.6.0 → 1.6.2

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -193
  3. data/exe/docscribe-client +26 -7
  4. data/lib/docscribe/cli/config_builder.rb +37 -2
  5. data/lib/docscribe/cli/coverage.rb +5 -5
  6. data/lib/docscribe/cli/formatters/json.rb +74 -29
  7. data/lib/docscribe/cli/formatters/sarif.rb +20 -3
  8. data/lib/docscribe/cli/options.rb +17 -2
  9. data/lib/docscribe/cli/rbs_gen.rb +4 -4
  10. data/lib/docscribe/cli/run.rb +107 -24
  11. data/lib/docscribe/cli/update_types.rb +61 -17
  12. data/lib/docscribe/cli.rb +19 -13
  13. data/lib/docscribe/config/defaults.rb +1 -0
  14. data/lib/docscribe/config/rbs.rb +22 -1
  15. data/lib/docscribe/config/template.rb +3 -0
  16. data/lib/docscribe/config/validation.rb +19 -0
  17. data/lib/docscribe/config.rb +1 -0
  18. data/lib/docscribe/infer/behavior.rb +13 -13
  19. data/lib/docscribe/infer/params.rb +2 -2
  20. data/lib/docscribe/infer/raises.rb +5 -6
  21. data/lib/docscribe/infer/returns.rb +1612 -151
  22. data/lib/docscribe/infer.rb +7 -7
  23. data/lib/docscribe/inline_rewriter/doc_builder.rb +485 -102
  24. data/lib/docscribe/inline_rewriter.rb +263 -97
  25. data/lib/docscribe/plugin/registry.rb +1 -0
  26. data/lib/docscribe/server/base.rb +255 -0
  27. data/lib/docscribe/server/client.rb +95 -0
  28. data/lib/docscribe/server/daemon.rb +678 -0
  29. data/lib/docscribe/server/protocol.rb +50 -0
  30. data/lib/docscribe/server.rb +4 -835
  31. data/lib/docscribe/types/primitive.rb +160 -0
  32. data/lib/docscribe/types/sorbet/base_provider.rb +33 -1
  33. data/lib/docscribe/types/yard/formatter.rb +35 -6
  34. data/lib/docscribe/types/yard/parser.rb +25 -20
  35. data/lib/docscribe/types/yard/validator.rb +131 -0
  36. data/lib/docscribe/validator/generic_compatibility.rb +698 -0
  37. data/lib/docscribe/validator/type_mismatch_validator.rb +287 -0
  38. data/lib/docscribe/version.rb +1 -1
  39. metadata +12 -3
@@ -132,6 +132,7 @@ module Docscribe
132
132
  @tag_entries.clear
133
133
  @collector_entries.clear
134
134
  @order_seq = 0
135
+ nil
135
136
  end
136
137
  end
137
138
  end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'fileutils'
5
+ require 'digest/md5'
6
+ require 'tmpdir'
7
+
8
+ module Docscribe
9
+ # Server/daemon mode for persistent multi-request operation.
10
+ #
11
+ # Architecture:
12
+ # - Daemon process loads Ruby runtime once, listens on a Unix socket
13
+ # - Client sends JSON-line requests, receives JSON-line responses
14
+ # - Auto-shutdown after idle timeout
15
+ # - Protocol: JSON-RPC 2.0 over Unix socket
16
+ module Server
17
+ # Unix socket path max is 104 bytes on macOS (the more restrictive).
18
+ # Dir.tmpdir on macOS often returns a long path under /var/folders/.../T
19
+ # that exceeds this limit, so we fall back to /tmp when needed.
20
+ SOCKET_DIR = begin
21
+ tmp = Dir.tmpdir || '/tmp'
22
+ sock_overhead = "/docscribe-#{'a' * 32}.sock".bytesize # 48
23
+ tmp.bytesize <= 104 - sock_overhead ? tmp : '/tmp'
24
+ end
25
+ IDLE_TIMEOUT = 300
26
+
27
+ class << self
28
+ # Start the server daemon if not running.
29
+ #
30
+ # @param [String?] config_path optional config file path
31
+ # @param [Boolean] daemonize redirect stdin/stdout/stderr to /dev/null
32
+ # @param [Integer] timeout max seconds to wait for readiness
33
+ # @return [void]
34
+ def ensure_running!(config_path: nil, daemonize: false, timeout: 5)
35
+ return if running?(config_path)
36
+
37
+ check_platform_support!
38
+
39
+ lock_path = "#{socket_path(config_path)}.lock"
40
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
41
+ lock.flock(File::LOCK_EX)
42
+ next if running?(config_path)
43
+
44
+ start_daemon_process(config_path: config_path, daemonize: daemonize)
45
+ end
46
+ wait_for_ready(config_path: config_path, timeout: timeout)
47
+ end
48
+
49
+ # Start the server daemon and wait for it to become ready.
50
+ #
51
+ # @param [String?] config_path optional config path for socket/pid lookup
52
+ # @param [Integer] timeout max seconds to wait for readiness
53
+ # @param [Boolean] raise_on_timeout
54
+ # @raise [StandardError]
55
+ # @return [Boolean]
56
+ def wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) # rubocop:disable SortedMethodsByCall/Waterfall
57
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
58
+ loop do
59
+ return true if running?(config_path)
60
+
61
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
62
+ raise('Docscribe: server failed to start') if raise_on_timeout
63
+
64
+ warn('Docscribe server failed to start within timeout')
65
+ return false
66
+ end
67
+
68
+ sleep 0.1
69
+ end
70
+ end
71
+
72
+ # Whether a server process is listening on the socket.
73
+ #
74
+ # On ECONNREFUSED, checks whether the PID process is still alive:
75
+ # if yes, the daemon is still starting up (don't clean up);
76
+ # if no, removes stale socket and pid files.
77
+ #
78
+ # @param [String?] config_path optional config path for socket lookup
79
+ # @raise [Errno::ECONNREFUSED]
80
+ # @raise [Errno::ENOENT]
81
+ # @raise [Errno::ENOTSOCK]
82
+ # @raise [StandardError]
83
+ # @return [Boolean]
84
+ # @return [Boolean] if Errno::ECONNREFUSED
85
+ # @return [void, Boolean] if Errno::ENOENT, Errno::ENOTSOCK
86
+ # @return [Boolean] if StandardError
87
+ def running?(config_path = nil)
88
+ return false unless defined?(UNIXSocket)
89
+
90
+ socket = UNIXSocket.new(socket_path(config_path))
91
+ socket.close
92
+ true
93
+ rescue Errno::ECONNREFUSED
94
+ handle_stale_socket?(config_path)
95
+ rescue Errno::ENOENT, Errno::ENOTSOCK
96
+ clean_socket_files(config_path) && false
97
+ rescue StandardError
98
+ false
99
+ end
100
+
101
+ # Handle ECONNREFUSED: check if the pid process is alive.
102
+ # Cleans up only if the process is dead.
103
+ #
104
+ # @param [String?] config_path
105
+ # @return [Boolean] false (not running)
106
+ def handle_stale_socket?(config_path)
107
+ pid = read_pid(config_path)
108
+ return false if pid && process_alive?(pid)
109
+
110
+ clean_socket_files(config_path)
111
+ false
112
+ end
113
+
114
+ # @param [Integer] pid
115
+ # @raise [Errno::ESRCH]
116
+ # @return [Boolean]
117
+ # @return [Boolean] if Errno::ESRCH
118
+ def process_alive?(pid)
119
+ Process.kill(0, pid)
120
+ true
121
+ rescue Errno::ESRCH
122
+ false
123
+ end
124
+
125
+ # @param [String?] config_path
126
+ # @raise [StandardError]
127
+ # @return [Integer?]
128
+ # @return [nil] if StandardError
129
+ def read_pid(config_path = nil)
130
+ File.read(pid_path(config_path)).to_i if File.exist?(pid_path(config_path))
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ # Remove stale socket and pid files.
136
+ #
137
+ # @param [String?] config_path
138
+ # @return [void]
139
+ def clean_socket_files(config_path)
140
+ FileUtils.rm_f(socket_path(config_path))
141
+ FileUtils.rm_f(pid_path(config_path))
142
+ end
143
+
144
+ # @param [String?] config_path
145
+ # @return [String]
146
+ def pid_path(config_path = nil)
147
+ "#{socket_path(config_path)}.pid"
148
+ end
149
+
150
+ ENV_FILES = %w[Gemfile.lock rbs_collection.lock.yaml docscribe.yml].freeze
151
+ SIG_RBS_GLOB = 'sig/**/*.rbs'
152
+
153
+ # @param [String] config_path
154
+ # @return [String]
155
+ def config_hash(config_path)
156
+ resolved = File.expand_path(config_path)
157
+ mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
158
+ Digest::MD5.hexdigest("#{resolved}:#{mtime}")
159
+ end
160
+
161
+ # Check platform compatibility before starting server.
162
+ #
163
+ # @raise [StandardError]
164
+ # @return [void]
165
+ def check_platform_support!
166
+ unless defined?(UNIXSocket)
167
+ raise 'Server mode requires Unix domain sockets, which are not available on Windows. ' \
168
+ 'Use docscribe directly without --server flag.'
169
+ end
170
+ return if Process.respond_to?(:fork)
171
+
172
+ raise 'Server mode requires Process.fork, which is not available on JRuby. ' \
173
+ 'Use docscribe directly without --server flag.'
174
+ end
175
+
176
+ # Derive a project-specific socket path from the current working directory.
177
+ # Uses MD5 (deterministic across processes) instead of String#hash
178
+ # (which varies per Ruby process due to random seeding).
179
+ # When a config_path is given, its path + mtime are included in the hash
180
+ # so different configs get different daemons.
181
+ # Environment files (Gemfile.lock, rbs_collection.lock.yaml) are also
182
+ # included so daemon is invalidated when gems or RBS types change.
183
+ #
184
+ # @param [String?] config_path optional config path to differentiate
185
+ # @return [String]
186
+ def socket_path(config_path = nil)
187
+ seed = +Dir.pwd
188
+ seed << ":#{env_hash}"
189
+ if config_path
190
+ resolved = File.expand_path(config_path)
191
+ mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
192
+ seed << ":#{resolved}:#{mtime}"
193
+ end
194
+ "#{SOCKET_DIR}/docscribe-#{Digest::MD5.hexdigest(seed)}.sock"
195
+ end
196
+
197
+ # Hash of environment files that affect analysis results.
198
+ # When any of these change, the daemon is invalidated (new socket path).
199
+ # Includes Gemfile.lock, rbs_collection.lock.yaml, docscribe.yml and all sig/**/*.rbs.
200
+ #
201
+ # @return [String]
202
+ def env_hash
203
+ parts = ENV_FILES.map { |file| env_file_mtime(file) }
204
+ parts.concat(sig_env_parts)
205
+ Digest::MD5.hexdigest(parts.join(':'))
206
+ end
207
+
208
+ # Hash of RBS signature files for cache invalidation inside daemon.
209
+ # Used by Daemon#rewrite_file to detect sig changes without requiring a new socket.
210
+ #
211
+ # @return [String]
212
+ def sig_hash
213
+ files = sig_files
214
+ parts = files.map { |p| "#{p}:#{File.mtime(p).to_f}" }
215
+ parts << "count:#{files.size}"
216
+ Digest::MD5.hexdigest(parts.join('|'))
217
+ end
218
+
219
+ # @param [String] file
220
+ # @return [String]
221
+ def env_file_mtime(file)
222
+ path = File.join(Dir.pwd, file)
223
+ File.exist?(path) ? File.mtime(path).to_f.to_s : '0'
224
+ end
225
+
226
+ # @return [Array<String>]
227
+ def sig_env_parts
228
+ files = sig_files
229
+ mtimes = files.map { |p| File.mtime(p).to_f.to_s }
230
+ mtimes << files.size.to_s
231
+ mtimes
232
+ end
233
+
234
+ # @return [Array<String>]
235
+ def sig_files
236
+ Dir.glob(File.join(Dir.pwd, SIG_RBS_GLOB)).sort
237
+ end
238
+
239
+ public :read_pid, :pid_path, :socket_path
240
+
241
+ # @param [String?] config_path
242
+ # @param [Boolean] daemonize
243
+ # @return [void]
244
+ def start_daemon_process(config_path:, daemonize:)
245
+ warn 'Docscribe: starting server...' if daemonize
246
+ pid = Process.fork do # steep:ignore NoMethod
247
+ [$stdin, $stdout].each { _1.reopen(File::NULL) }
248
+ $stderr.reopen(File::NULL)
249
+ Daemon.new(config_path: config_path).start
250
+ end
251
+ Process.detach(pid)
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ module Docscribe
6
+ module Server
7
+ # Client for communicating with a running Docscribe daemon.
8
+ class Client
9
+ # @param [String?] socket_path custom socket path (defaults to server default)
10
+ # @param [String?] config_path optional config path for socket lookup
11
+ # @return [void]
12
+ def initialize(socket_path = nil, config_path: nil)
13
+ @socket_path = socket_path || Server.socket_path(config_path)
14
+ end
15
+
16
+ # Send a check request to the server.
17
+ #
18
+ # @param [String] file path to file to check
19
+ # @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
20
+ # @param [Object] rest extra JSON-RPC params (e.g. cli_overrides)
21
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
22
+ def check(file:, strategy: :safe, **rest)
23
+ request('check', file: file, strategy: strategy, **rest)
24
+ end
25
+
26
+ # Send a fix request to the server.
27
+ #
28
+ # @param [String] file path to file to fix
29
+ # @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
30
+ # @param [Object] rest extra JSON-RPC params (e.g. cli_overrides)
31
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
32
+ def fix(file:, strategy: :safe, **rest)
33
+ request('fix', file: file, strategy: strategy, **rest)
34
+ end
35
+
36
+ # Send a shutdown request to the server.
37
+ #
38
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
39
+ def shutdown
40
+ request('shutdown')
41
+ end
42
+
43
+ # Send an update_types request to the server.
44
+ #
45
+ # @param [String] dir directory to update (defaults to '.')
46
+ # @param [Object] rest extra JSON-RPC params (e.g. cli_overrides)
47
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
48
+ def update_types(dir: '.', **rest)
49
+ request('update_types', dir: dir, **rest)
50
+ end
51
+
52
+ # Ping the server and get version/pid/uptime info.
53
+ #
54
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
55
+ def ping
56
+ request('ping')
57
+ end
58
+
59
+ private
60
+
61
+ # Send a JSON-RPC request and read the response.
62
+ #
63
+ # @private
64
+ # @param [String] method method name
65
+ # @param [Object] params request parameters
66
+ # @return [Hash<String, Object>?]
67
+ def request(method, **params)
68
+ connect do |socket|
69
+ req = Protocol.build_request(method, params)
70
+ socket.write(Protocol.serialize(req))
71
+ socket.close_write
72
+ line = socket.gets
73
+ break unless line
74
+
75
+ Protocol.parse_response(line)
76
+ end
77
+ end
78
+
79
+ # Connect to the Unix socket and yield the connection.
80
+ #
81
+ # @private
82
+ # @raise [Errno::ECONNREFUSED]
83
+ # @raise [Errno::ENOENT]
84
+ # @return [U?] yield return value or nil on connection error
85
+ def connect
86
+ socket = UNIXSocket.new(@socket_path)
87
+ yield socket
88
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
89
+ nil
90
+ ensure
91
+ socket&.close
92
+ end
93
+ end
94
+ end
95
+ end