hyperprobe-agent 1.2.27.pre.1 → 1.2.27.pre.3

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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ripper'
4
+
5
+ module HyperProbe
6
+ module Core
7
+ # Installation-time analysis only. Retain the result on the probe, not a
8
+ # binding. nil means unknown: callers must omit locals, not fall back to all.
9
+ # Tokenless lines and lines shared by different scopes are intentionally
10
+ # unknown. Source must still match the code loaded by the application.
11
+ # Parsers with incomplete lambda ASTs (including JRuby 10.1.1's arrow
12
+ # lambdas) fail closed; ordinary lambda/proc blocks remain supported.
13
+ class LexicalScope
14
+ MAX_SOURCE_BYTES = 512 * 1024
15
+ MAX_NODES = 20_000
16
+ MAX_DEPTH = 128
17
+ CLASS = Object.instance_method(:class)
18
+ Scope = Struct.new(:parent, :declared, :assigned, :locals, :visible)
19
+ private_constant :CLASS, :Scope
20
+
21
+ def self.locals_for(path, line)
22
+ return nil unless CLASS.bind(path).call.equal?(String) && CLASS.bind(line).call.equal?(Integer)
23
+ return nil unless line.positive?
24
+ return nil unless File.file?(path)
25
+
26
+ source = File.open(path, 'rb') do |file|
27
+ return nil unless file.stat.file? && file.stat.size <= MAX_SOURCE_BYTES
28
+ file.read(MAX_SOURCE_BYTES + 1)
29
+ end
30
+ return nil if source.bytesize > MAX_SOURCE_BYTES
31
+
32
+ new(source).locals_for(line)
33
+ rescue StandardError, SystemStackError
34
+ nil
35
+ end
36
+
37
+ def initialize(source)
38
+ tree = Ripper.sexp(source)
39
+ raise ArgumentError, 'Invalid Ruby source' unless tree
40
+
41
+ # Check iteratively before any recursive AST work (including positions).
42
+ stack = [[tree, 0]]
43
+ count = 0
44
+ until stack.empty?
45
+ node, depth = stack.pop
46
+ count += 1
47
+ raise ArgumentError, 'AST limit' if count > MAX_NODES || depth > MAX_DEPTH
48
+ node.each { |child| stack << [child, depth + 1] if child.is_a?(Array) }
49
+ end
50
+
51
+ @lambda_headers = lambda_headers(source)
52
+ @lambda_index = 0
53
+ @scopes = []
54
+ @lines = {}
55
+ @unknown_lines = {}
56
+ walk(tree, scope(nil))
57
+ raise ArgumentError, 'Unmatched lambda' unless @lambda_index == @lambda_headers.length
58
+
59
+ # Ancestors are complete before descendants, including assignments
60
+ # textually after a block. Such names are conservatively kept private.
61
+ @scopes.each do |current|
62
+ ancestors = current.parent ? current.parent.visible : []
63
+ current.locals = (current.declared + (current.assigned - ancestors)).uniq.freeze
64
+ current.visible = (ancestors + current.locals).uniq
65
+ end
66
+ end
67
+
68
+ def locals_for(line)
69
+ return nil if @unknown_lines[line]
70
+
71
+ owners = @lines[line]
72
+ owners && owners.length == 1 ? owners.first.locals.dup : nil
73
+ end
74
+
75
+ private
76
+
77
+ def scope(parent)
78
+ current = Scope.new(parent, [], [], nil, nil)
79
+ @scopes << current
80
+ current
81
+ end
82
+
83
+ def walk(node, current)
84
+ return unless node.is_a?(Array)
85
+
86
+ kind = node[0]
87
+ if kind.is_a?(Symbol) && kind.to_s.start_with?('@')
88
+ first = node[2][0]
89
+ last = first + node[1].count("\n")
90
+ last -= 1 if node[1].end_with?("\n")
91
+ (first..last).each do |line|
92
+ owners = (@lines[line] ||= [])
93
+ owners << current unless owners.any? { |owner| owner.equal?(current) }
94
+ end
95
+ return
96
+ end
97
+
98
+ case kind
99
+ when :def, :defs
100
+ # A line alone cannot distinguish definition from default evaluation.
101
+ header, params, body = kind == :def ? [node[1..2], node[2], node[3]] : [node[1..4], node[4], node[5]]
102
+ token_positions(header).each { |position| @unknown_lines[position[0]] = true }
103
+ method_scope = scope(nil)
104
+ parameters(params, method_scope)
105
+ walk(node[1], current) if kind == :defs
106
+ walk(params, method_scope)
107
+ walk(body, method_scope)
108
+ when :class, :module, :sclass
109
+ inner = scope(nil)
110
+ token_positions(node[1...-1]).each { |position| @unknown_lines[position[0]] = true }
111
+ walk(node[1...-1], current)
112
+ walk(node[-1], inner)
113
+ when :do_block, :brace_block, :lambda
114
+ inner = scope(current)
115
+ parameters(node[1], inner)
116
+ if kind == :lambda
117
+ raise ArgumentError, 'Incomplete lambda AST' unless node[1].is_a?(Array)
118
+
119
+ header = @lambda_headers.fetch(@lambda_index)
120
+ @lambda_index += 1
121
+ inner.declared.concat(header[:locals])
122
+ # Ripper leaves lambda semicolon locals out of sexp entirely.
123
+ # Validate the association when there are positioned parameters.
124
+ positions = token_positions(node[1])
125
+ unless positions.all? { |position| (position <=> header[:start]) >= 0 && (position <=> header[:finish]) <= 0 }
126
+ raise ArgumentError, 'Ambiguous lambda header'
127
+ end
128
+ unless token_positions(node[2]).all? { |position| (position <=> header[:finish]) >= 0 }
129
+ raise ArgumentError, 'Ambiguous lambda body'
130
+ end
131
+ end
132
+ walk(node[1], inner)
133
+ walk(node[2], inner)
134
+ when :var_field
135
+ current.assigned << node[1][1].to_sym if node[1] && node[1][0] == :@ident
136
+ node.each { |child| walk(child, current) }
137
+ when :BEGIN, :END
138
+ raise ArgumentError, 'Unsupported execution scope'
139
+ else
140
+ node.each { |child| walk(child, current) }
141
+ end
142
+ end
143
+
144
+ def parameters(node, current)
145
+ return unless node.is_a?(Array)
146
+
147
+ case node[0]
148
+ when :paren
149
+ parameters(node[1], current)
150
+ when :block_var
151
+ parameters(node[1], current)
152
+ declare(node[2], current)
153
+ when :params
154
+ declare(node[1], current)
155
+ (node[2] || []).each { |pair| declare(pair[0], current) }
156
+ declare(node[3], current)
157
+ declare(node[4], current)
158
+ (node[5] || []).each { |pair| declare(pair[0], current) }
159
+ declare(node[6], current)
160
+ declare(node[7], current)
161
+ end
162
+ end
163
+
164
+ def declare(node, current)
165
+ return unless node.is_a?(Array)
166
+
167
+ if node[0] == :@ident || node[0] == :@label
168
+ current.declared << node[1].delete_suffix(':').to_sym
169
+ else
170
+ node.each { |child| declare(child, current) }
171
+ end
172
+ end
173
+
174
+ def token_positions(node)
175
+ return [] unless node.is_a?(Array)
176
+ return [node[2]] if node[0].is_a?(Symbol) && node[0].to_s.start_with?('@')
177
+
178
+ node.flat_map { |child| token_positions(child) }
179
+ end
180
+
181
+ def lambda_headers(source)
182
+ tokens = Ripper.lex(source)
183
+ raise ArgumentError, 'Token limit' if tokens.length > MAX_NODES
184
+
185
+ headers = []
186
+ work = 0
187
+ tokens.each_with_index do |token, index|
188
+ next unless token[1] == :on_tlambda
189
+
190
+ header = { start: token[0], finish: token[0], locals: [] }
191
+ depth = 0
192
+ shadow = false
193
+ cursor = index + 1
194
+ while cursor < tokens.length
195
+ work += 1
196
+ raise ArgumentError, 'Lambda header limit' if work > MAX_NODES
197
+ position, type, text = tokens[cursor]
198
+ cursor += 1
199
+ header[:finish] = position
200
+ break if depth.zero? && (type == :on_tlambeg || (type == :on_kw && text == 'do'))
201
+
202
+ if shadow
203
+ if type == :on_rparen && depth == 1
204
+ shadow = false
205
+ elsif !%i[on_ident on_comma on_sp on_nl on_ignored_nl on_comment].include?(type)
206
+ raise ArgumentError, 'Ambiguous lambda shadow declaration'
207
+ end
208
+ header[:locals] << text.to_sym if type == :on_ident
209
+ end
210
+ depth += 1 if type == :on_lparen
211
+ depth -= 1 if type == :on_rparen
212
+ shadow = true if depth == 1 && type == :on_semicolon
213
+ end
214
+ headers << header
215
+ end
216
+ headers
217
+ end
218
+
219
+ private_class_method :new
220
+ end
221
+ end
222
+ end
@@ -98,26 +98,27 @@ module HyperProbe
98
98
  def warn(message)
99
99
  timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
100
100
  formatted = "#{timestamp} \e[33m[HyperProbe WARN]\e[0m [#{@namespace}] #{message}"
101
- $stdout.puts(formatted)
102
- $stdout.flush rescue nil
101
+ emit($stdout, formatted)
103
102
  end
104
103
 
105
104
  def error(message, exception = nil)
106
105
  timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
107
106
  formatted = "#{timestamp} \e[31m[HyperProbe ERROR]\e[0m [#{@namespace}] #{message}"
108
- $stderr.puts(formatted)
107
+ emit($stderr, formatted)
109
108
  if exception
110
- $stderr.puts(" #{exception.class}: #{exception.message}")
111
- $stderr.puts(exception.backtrace.first(5).map { |l| " at #{l}" }.join("\n")) if exception.backtrace
109
+ type = Kernel.instance_method(:class).bind(exception).call
110
+ emit($stderr, " #{Module.instance_method(:name).bind(type).call}")
112
111
  end
113
- $stderr.flush rescue nil
112
+ rescue SignalException, SystemExit
113
+ raise
114
+ rescue Exception
115
+ nil
114
116
  end
115
117
 
116
118
  def force_info(message)
117
119
  timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
118
120
  formatted = "#{timestamp} \e[34mHyperProbe\e[0m -- [#{@namespace}] #{message}"
119
- $stdout.puts(formatted)
120
- $stdout.flush rescue nil
121
+ emit($stdout, formatted)
121
122
  end
122
123
 
123
124
  alias forceInfo force_info
@@ -134,10 +135,13 @@ module HyperProbe
134
135
  now = monotonic_ms
135
136
  delta_str = ''
136
137
 
137
- @mutex.synchronize do
138
+ return unless @mutex.try_lock
139
+ begin
138
140
  delta = now - @last_log_time_ms
139
141
  delta_str = " \e[#{@color}m+#{delta}ms\e[0m" if delta > 0
140
142
  @last_log_time_ms = now
143
+ ensure
144
+ @mutex.unlock
141
145
  end
142
146
 
143
147
  prefix = if colors_enabled?(stream)
@@ -146,8 +150,25 @@ module HyperProbe
146
150
  @namespace
147
151
  end
148
152
 
149
- stream.puts(" #{prefix} \e[90m#{message}\e[0m#{delta_str}")
150
- stream.flush rescue nil
153
+ emit(stream, " #{prefix} \e[90m#{message}\e[0m#{delta_str}")
154
+ rescue SignalException, SystemExit
155
+ raise
156
+ rescue Exception
157
+ nil
158
+ end
159
+
160
+ def emit(stream, message)
161
+ line = "#{message}\n".byteslice(0, 4096)
162
+ if IO === stream
163
+ IO.instance_method(:write_nonblock).bind(stream).call(line, exception: false)
164
+ elsif defined?(StringIO) && StringIO === stream
165
+ StringIO.instance_method(:write).bind(stream).call(line)
166
+ end
167
+ rescue SignalException, SystemExit
168
+ raise
169
+ rescue Exception
170
+ # Never block or call arbitrary application output adapters.
171
+ nil
151
172
  end
152
173
 
153
174
  def select_namespace_color(ns)
@@ -164,7 +185,7 @@ module HyperProbe
164
185
  return !DISABLED_COLOR_VALUES.include?(configured.strip.downcase)
165
186
  end
166
187
 
167
- stream.respond_to?(:tty?) && stream.tty?
188
+ IO === stream && IO.instance_method(:tty?).bind(stream).call
168
189
  rescue StandardError
169
190
  false
170
191
  end