tracekit 0.2.2 → 0.2.4
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 +17 -0
- data/README.md +101 -1
- data/lib/tracekit/config.rb +6 -2
- data/lib/tracekit/evaluator.rb +604 -0
- data/lib/tracekit/llm/anthropic_instrumentation.rb +218 -0
- data/lib/tracekit/llm/common.rb +118 -0
- data/lib/tracekit/llm/openai_instrumentation.rb +201 -0
- data/lib/tracekit/sdk.rb +29 -0
- data/lib/tracekit/snapshots/client.rb +119 -46
- data/lib/tracekit/snapshots/models.rb +6 -0
- data/lib/tracekit/version.rb +1 -1
- data/lib/tracekit.rb +10 -0
- metadata +10 -6
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
# Expression evaluator for the TraceKit portable expression subset.
|
|
5
|
+
# Evaluates breakpoint conditions locally to avoid server round-trips.
|
|
6
|
+
#
|
|
7
|
+
# Uses a custom recursive-descent parser -- never eval()/instance_eval.
|
|
8
|
+
# Supports: comparison, logical, arithmetic, string concat, property access,
|
|
9
|
+
# bracket notation, membership (in), null safety, and grouping.
|
|
10
|
+
module Evaluator
|
|
11
|
+
class UnsupportedExpressionError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Returns true if the expression can be evaluated locally by the SDK.
|
|
14
|
+
# Returns false for expressions containing function calls, regex operators,
|
|
15
|
+
# assignment, array indexing, ternary, range, template literals, or bitwise operators.
|
|
16
|
+
def self.sdk_evaluable?(expression)
|
|
17
|
+
return true if expression.nil? || expression.strip.empty?
|
|
18
|
+
|
|
19
|
+
# Function calls: word followed by opening paren
|
|
20
|
+
return false if expression.match?(/\b[a-zA-Z_]\w*\s*\(/)
|
|
21
|
+
|
|
22
|
+
# Regex match keyword
|
|
23
|
+
return false if expression.match?(/\bmatches\b/)
|
|
24
|
+
|
|
25
|
+
# Regex operator =~
|
|
26
|
+
return false if expression.include?("=~")
|
|
27
|
+
|
|
28
|
+
# Bitwise NOT ~ (but not inside =~, already handled above)
|
|
29
|
+
expression.each_char.with_index do |ch, i|
|
|
30
|
+
if ch == "~" && (i == 0 || expression[i - 1] != "=")
|
|
31
|
+
return false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Bitwise AND: single & not part of &&
|
|
36
|
+
i = 0
|
|
37
|
+
while i < expression.length
|
|
38
|
+
if expression[i] == "&"
|
|
39
|
+
if i + 1 < expression.length && expression[i + 1] == "&"
|
|
40
|
+
i += 2
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
return false
|
|
44
|
+
end
|
|
45
|
+
i += 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Bitwise OR: single | not part of ||
|
|
49
|
+
i = 0
|
|
50
|
+
while i < expression.length
|
|
51
|
+
if expression[i] == "|"
|
|
52
|
+
if i + 1 < expression.length && expression[i + 1] == "|"
|
|
53
|
+
i += 2
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
return false
|
|
57
|
+
end
|
|
58
|
+
i += 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Bit shift
|
|
62
|
+
return false if expression.include?("<<") || expression.include?(">>")
|
|
63
|
+
|
|
64
|
+
# Template literals
|
|
65
|
+
return false if expression.include?("${")
|
|
66
|
+
|
|
67
|
+
# Range operator
|
|
68
|
+
return false if expression.include?("..")
|
|
69
|
+
|
|
70
|
+
# Ternary
|
|
71
|
+
return false if expression.include?("?")
|
|
72
|
+
|
|
73
|
+
# Array indexing [N]
|
|
74
|
+
return false if expression.match?(/\[\d/)
|
|
75
|
+
|
|
76
|
+
# Compound assignment
|
|
77
|
+
return false if expression.match?(/[+\-*\/]=/)
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Evaluates an expression string against the given environment
|
|
83
|
+
# and returns a boolean result. Empty expressions return true.
|
|
84
|
+
# Raises UnsupportedExpressionError for server-only expressions.
|
|
85
|
+
def self.evaluate_condition(expression, env)
|
|
86
|
+
return true if expression.nil? || expression.strip.empty?
|
|
87
|
+
|
|
88
|
+
unless sdk_evaluable?(expression)
|
|
89
|
+
raise UnsupportedExpressionError, "unsupported expression: requires server-side evaluation"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result = evaluate_expression(expression, env)
|
|
93
|
+
|
|
94
|
+
case result
|
|
95
|
+
when true, false
|
|
96
|
+
result
|
|
97
|
+
when nil
|
|
98
|
+
false
|
|
99
|
+
else
|
|
100
|
+
# Non-boolean result from a condition -- treat as truthy
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Evaluates an expression and returns the raw result value.
|
|
106
|
+
# Raises UnsupportedExpressionError for server-only expressions.
|
|
107
|
+
def self.evaluate_expression(expression, env)
|
|
108
|
+
return nil if expression.nil? || expression.strip.empty?
|
|
109
|
+
|
|
110
|
+
unless sdk_evaluable?(expression)
|
|
111
|
+
raise UnsupportedExpressionError, "unsupported expression: requires server-side evaluation"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
tokens = Lexer.tokenize(expression)
|
|
115
|
+
parser = Parser.new(tokens, env)
|
|
116
|
+
parser.parse_expression
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Evaluates multiple expressions against the given environment.
|
|
120
|
+
# Results are keyed by expression string. On error, nil is stored.
|
|
121
|
+
def self.evaluate_expressions(expressions, env)
|
|
122
|
+
results = {}
|
|
123
|
+
expressions.each do |expr|
|
|
124
|
+
results[expr] = evaluate_expression(expr, env)
|
|
125
|
+
rescue StandardError
|
|
126
|
+
results[expr] = nil
|
|
127
|
+
end
|
|
128
|
+
results
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Token types for the lexer
|
|
132
|
+
module TokenType
|
|
133
|
+
NUMBER = :number
|
|
134
|
+
STRING = :string
|
|
135
|
+
BOOL = :bool
|
|
136
|
+
NIL = :nil
|
|
137
|
+
IDENT = :ident
|
|
138
|
+
DOT = :dot
|
|
139
|
+
LBRACKET = :lbracket
|
|
140
|
+
RBRACKET = :rbracket
|
|
141
|
+
LPAREN = :lparen
|
|
142
|
+
RPAREN = :rparen
|
|
143
|
+
PLUS = :plus
|
|
144
|
+
MINUS = :minus
|
|
145
|
+
STAR = :star
|
|
146
|
+
SLASH = :slash
|
|
147
|
+
EQ = :eq
|
|
148
|
+
NEQ = :neq
|
|
149
|
+
LT = :lt
|
|
150
|
+
GT = :gt
|
|
151
|
+
LTE = :lte
|
|
152
|
+
GTE = :gte
|
|
153
|
+
AND = :and
|
|
154
|
+
OR = :or
|
|
155
|
+
NOT = :not
|
|
156
|
+
IN = :in
|
|
157
|
+
EOF = :eof
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Token = Struct.new(:type, :value, keyword_init: true)
|
|
161
|
+
|
|
162
|
+
# Lexer: converts expression string into tokens.
|
|
163
|
+
module Lexer
|
|
164
|
+
def self.tokenize(input)
|
|
165
|
+
tokens = []
|
|
166
|
+
i = 0
|
|
167
|
+
while i < input.length
|
|
168
|
+
ch = input[i]
|
|
169
|
+
|
|
170
|
+
# Skip whitespace
|
|
171
|
+
if ch =~ /\s/
|
|
172
|
+
i += 1
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Two-character operators
|
|
177
|
+
if i + 1 < input.length
|
|
178
|
+
two = input[i, 2]
|
|
179
|
+
case two
|
|
180
|
+
when "=="
|
|
181
|
+
tokens << Token.new(type: TokenType::EQ, value: "==")
|
|
182
|
+
i += 2
|
|
183
|
+
next
|
|
184
|
+
when "!="
|
|
185
|
+
tokens << Token.new(type: TokenType::NEQ, value: "!=")
|
|
186
|
+
i += 2
|
|
187
|
+
next
|
|
188
|
+
when "<="
|
|
189
|
+
tokens << Token.new(type: TokenType::LTE, value: "<=")
|
|
190
|
+
i += 2
|
|
191
|
+
next
|
|
192
|
+
when ">="
|
|
193
|
+
tokens << Token.new(type: TokenType::GTE, value: ">=")
|
|
194
|
+
i += 2
|
|
195
|
+
next
|
|
196
|
+
when "&&"
|
|
197
|
+
tokens << Token.new(type: TokenType::AND, value: "&&")
|
|
198
|
+
i += 2
|
|
199
|
+
next
|
|
200
|
+
when "||"
|
|
201
|
+
tokens << Token.new(type: TokenType::OR, value: "||")
|
|
202
|
+
i += 2
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Single-character tokens
|
|
208
|
+
case ch
|
|
209
|
+
when "."
|
|
210
|
+
tokens << Token.new(type: TokenType::DOT, value: ".")
|
|
211
|
+
i += 1
|
|
212
|
+
next
|
|
213
|
+
when "["
|
|
214
|
+
tokens << Token.new(type: TokenType::LBRACKET, value: "[")
|
|
215
|
+
i += 1
|
|
216
|
+
next
|
|
217
|
+
when "]"
|
|
218
|
+
tokens << Token.new(type: TokenType::RBRACKET, value: "]")
|
|
219
|
+
i += 1
|
|
220
|
+
next
|
|
221
|
+
when "("
|
|
222
|
+
tokens << Token.new(type: TokenType::LPAREN, value: "(")
|
|
223
|
+
i += 1
|
|
224
|
+
next
|
|
225
|
+
when ")"
|
|
226
|
+
tokens << Token.new(type: TokenType::RPAREN, value: ")")
|
|
227
|
+
i += 1
|
|
228
|
+
next
|
|
229
|
+
when "+"
|
|
230
|
+
tokens << Token.new(type: TokenType::PLUS, value: "+")
|
|
231
|
+
i += 1
|
|
232
|
+
next
|
|
233
|
+
when "-"
|
|
234
|
+
# Check if this is a negative number (unary minus before digits)
|
|
235
|
+
if (tokens.empty? || [:plus, :minus, :star, :slash, :eq, :neq, :lt, :gt, :lte, :gte, :and, :or, :not, :lparen].include?(tokens.last&.type)) &&
|
|
236
|
+
i + 1 < input.length && input[i + 1] =~ /\d/
|
|
237
|
+
# Parse as negative number
|
|
238
|
+
start = i
|
|
239
|
+
i += 1
|
|
240
|
+
i += 1 while i < input.length && (input[i] =~ /\d/ || input[i] == ".")
|
|
241
|
+
num_str = input[start...i]
|
|
242
|
+
value = num_str.include?(".") ? num_str.to_f : num_str.to_i
|
|
243
|
+
tokens << Token.new(type: TokenType::NUMBER, value: value)
|
|
244
|
+
next
|
|
245
|
+
end
|
|
246
|
+
tokens << Token.new(type: TokenType::MINUS, value: "-")
|
|
247
|
+
i += 1
|
|
248
|
+
next
|
|
249
|
+
when "*"
|
|
250
|
+
tokens << Token.new(type: TokenType::STAR, value: "*")
|
|
251
|
+
i += 1
|
|
252
|
+
next
|
|
253
|
+
when "/"
|
|
254
|
+
tokens << Token.new(type: TokenType::SLASH, value: "/")
|
|
255
|
+
i += 1
|
|
256
|
+
next
|
|
257
|
+
when "<"
|
|
258
|
+
tokens << Token.new(type: TokenType::LT, value: "<")
|
|
259
|
+
i += 1
|
|
260
|
+
next
|
|
261
|
+
when ">"
|
|
262
|
+
tokens << Token.new(type: TokenType::GT, value: ">")
|
|
263
|
+
i += 1
|
|
264
|
+
next
|
|
265
|
+
when "!"
|
|
266
|
+
tokens << Token.new(type: TokenType::NOT, value: "!")
|
|
267
|
+
i += 1
|
|
268
|
+
next
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# String literals (double or single quoted)
|
|
272
|
+
if ch == '"' || ch == "'"
|
|
273
|
+
quote = ch
|
|
274
|
+
i += 1
|
|
275
|
+
start = i
|
|
276
|
+
while i < input.length && input[i] != quote
|
|
277
|
+
i += 1 if input[i] == "\\" # skip escaped char
|
|
278
|
+
i += 1
|
|
279
|
+
end
|
|
280
|
+
tokens << Token.new(type: TokenType::STRING, value: input[start...i])
|
|
281
|
+
i += 1 # skip closing quote
|
|
282
|
+
next
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Numbers
|
|
286
|
+
if ch =~ /\d/
|
|
287
|
+
start = i
|
|
288
|
+
i += 1 while i < input.length && (input[i] =~ /\d/ || input[i] == ".")
|
|
289
|
+
num_str = input[start...i]
|
|
290
|
+
value = num_str.include?(".") ? num_str.to_f : num_str.to_i
|
|
291
|
+
tokens << Token.new(type: TokenType::NUMBER, value: value)
|
|
292
|
+
next
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Identifiers and keywords
|
|
296
|
+
if ch =~ /[a-zA-Z_]/
|
|
297
|
+
start = i
|
|
298
|
+
i += 1 while i < input.length && input[i] =~ /[a-zA-Z0-9_]/
|
|
299
|
+
word = input[start...i]
|
|
300
|
+
case word
|
|
301
|
+
when "true"
|
|
302
|
+
tokens << Token.new(type: TokenType::BOOL, value: true)
|
|
303
|
+
when "false"
|
|
304
|
+
tokens << Token.new(type: TokenType::BOOL, value: false)
|
|
305
|
+
when "nil", "null", "None"
|
|
306
|
+
tokens << Token.new(type: TokenType::NIL, value: nil)
|
|
307
|
+
when "in"
|
|
308
|
+
tokens << Token.new(type: TokenType::IN, value: "in")
|
|
309
|
+
else
|
|
310
|
+
tokens << Token.new(type: TokenType::IDENT, value: word)
|
|
311
|
+
end
|
|
312
|
+
next
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
raise "unexpected character: #{ch}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
tokens << Token.new(type: TokenType::EOF, value: nil)
|
|
319
|
+
tokens
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Recursive-descent parser and evaluator.
|
|
324
|
+
# Operator precedence (lowest to highest):
|
|
325
|
+
# || -> && -> == != -> < > <= >= -> + - -> * / -> ! (unary) -> primary
|
|
326
|
+
class Parser
|
|
327
|
+
def initialize(tokens, env)
|
|
328
|
+
@tokens = tokens
|
|
329
|
+
@env = env
|
|
330
|
+
@pos = 0
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def parse_expression
|
|
334
|
+
parse_or
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
private
|
|
338
|
+
|
|
339
|
+
def current
|
|
340
|
+
@tokens[@pos]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def advance
|
|
344
|
+
t = @tokens[@pos]
|
|
345
|
+
@pos += 1
|
|
346
|
+
t
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def match(*types)
|
|
350
|
+
if types.include?(current.type)
|
|
351
|
+
advance
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def expect(type)
|
|
356
|
+
if current.type == type
|
|
357
|
+
advance
|
|
358
|
+
else
|
|
359
|
+
raise "expected #{type}, got #{current.type} (#{current.value})"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# OR: expr || expr
|
|
364
|
+
def parse_or
|
|
365
|
+
left = parse_and
|
|
366
|
+
while current.type == TokenType::OR
|
|
367
|
+
advance
|
|
368
|
+
right = parse_and
|
|
369
|
+
left = left || right
|
|
370
|
+
end
|
|
371
|
+
left
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# AND: expr && expr
|
|
375
|
+
def parse_and
|
|
376
|
+
left = parse_equality
|
|
377
|
+
while current.type == TokenType::AND
|
|
378
|
+
advance
|
|
379
|
+
right = parse_equality
|
|
380
|
+
left = left && right
|
|
381
|
+
end
|
|
382
|
+
left
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Equality: == !=
|
|
386
|
+
def parse_equality
|
|
387
|
+
left = parse_comparison
|
|
388
|
+
while (op = match(TokenType::EQ, TokenType::NEQ))
|
|
389
|
+
right = parse_comparison
|
|
390
|
+
left = case op.type
|
|
391
|
+
when TokenType::EQ then safe_equal?(left, right)
|
|
392
|
+
when TokenType::NEQ then !safe_equal?(left, right)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
left
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Comparison: < > <= >=
|
|
399
|
+
def parse_comparison
|
|
400
|
+
left = parse_membership
|
|
401
|
+
while (op = match(TokenType::LT, TokenType::GT, TokenType::LTE, TokenType::GTE))
|
|
402
|
+
right = parse_membership
|
|
403
|
+
left = safe_compare(op.type, left, right)
|
|
404
|
+
end
|
|
405
|
+
left
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Membership: "key" in map
|
|
409
|
+
def parse_membership
|
|
410
|
+
left = parse_addition
|
|
411
|
+
if current.type == TokenType::IN
|
|
412
|
+
advance
|
|
413
|
+
right = parse_addition
|
|
414
|
+
return membership_check(left, right)
|
|
415
|
+
end
|
|
416
|
+
left
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Addition/subtraction/string concatenation
|
|
420
|
+
def parse_addition
|
|
421
|
+
left = parse_multiplication
|
|
422
|
+
while (op = match(TokenType::PLUS, TokenType::MINUS))
|
|
423
|
+
right = parse_multiplication
|
|
424
|
+
if op.type == TokenType::PLUS
|
|
425
|
+
if left.is_a?(String) || right.is_a?(String)
|
|
426
|
+
left = "#{left}#{right}"
|
|
427
|
+
else
|
|
428
|
+
left = numeric_val(left) + numeric_val(right)
|
|
429
|
+
end
|
|
430
|
+
else
|
|
431
|
+
left = numeric_val(left) - numeric_val(right)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
left
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Multiplication/division
|
|
438
|
+
def parse_multiplication
|
|
439
|
+
left = parse_unary
|
|
440
|
+
while (op = match(TokenType::STAR, TokenType::SLASH))
|
|
441
|
+
right = parse_unary
|
|
442
|
+
if op.type == TokenType::STAR
|
|
443
|
+
left = numeric_val(left) * numeric_val(right)
|
|
444
|
+
else
|
|
445
|
+
divisor = numeric_val(right)
|
|
446
|
+
return nil if divisor == 0
|
|
447
|
+
left = numeric_val(left) / divisor
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
left
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Unary: ! (NOT)
|
|
454
|
+
def parse_unary
|
|
455
|
+
if current.type == TokenType::NOT
|
|
456
|
+
advance
|
|
457
|
+
val = parse_unary
|
|
458
|
+
return !val
|
|
459
|
+
end
|
|
460
|
+
parse_primary
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Primary: literals, identifiers (with property access), grouping
|
|
464
|
+
def parse_primary
|
|
465
|
+
tok = current
|
|
466
|
+
|
|
467
|
+
case tok.type
|
|
468
|
+
when TokenType::NUMBER
|
|
469
|
+
advance
|
|
470
|
+
resolve_postfix(tok.value)
|
|
471
|
+
when TokenType::STRING
|
|
472
|
+
advance
|
|
473
|
+
resolve_postfix(tok.value)
|
|
474
|
+
when TokenType::BOOL
|
|
475
|
+
advance
|
|
476
|
+
resolve_postfix(tok.value)
|
|
477
|
+
when TokenType::NIL
|
|
478
|
+
advance
|
|
479
|
+
resolve_postfix(nil)
|
|
480
|
+
when TokenType::IDENT
|
|
481
|
+
advance
|
|
482
|
+
value = resolve_identifier(tok.value)
|
|
483
|
+
resolve_postfix(value)
|
|
484
|
+
when TokenType::LPAREN
|
|
485
|
+
advance
|
|
486
|
+
result = parse_expression
|
|
487
|
+
expect(TokenType::RPAREN)
|
|
488
|
+
resolve_postfix(result)
|
|
489
|
+
else
|
|
490
|
+
raise "unexpected token: #{tok.type} (#{tok.value})"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Handle dot notation and bracket notation after a value
|
|
495
|
+
def resolve_postfix(value)
|
|
496
|
+
loop do
|
|
497
|
+
if current.type == TokenType::DOT
|
|
498
|
+
advance
|
|
499
|
+
if current.type == TokenType::IDENT
|
|
500
|
+
key = advance.value
|
|
501
|
+
value = safe_access(value, key)
|
|
502
|
+
else
|
|
503
|
+
raise "expected identifier after dot"
|
|
504
|
+
end
|
|
505
|
+
elsif current.type == TokenType::LBRACKET
|
|
506
|
+
advance
|
|
507
|
+
key = parse_expression
|
|
508
|
+
expect(TokenType::RBRACKET)
|
|
509
|
+
value = safe_access(value, key.to_s)
|
|
510
|
+
else
|
|
511
|
+
break
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
value
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Resolve a top-level identifier from the environment
|
|
518
|
+
def resolve_identifier(name)
|
|
519
|
+
if @env.key?(name)
|
|
520
|
+
@env[name]
|
|
521
|
+
elsif @env.key?(name.to_sym)
|
|
522
|
+
@env[name.to_sym]
|
|
523
|
+
else
|
|
524
|
+
nil
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Null-safe property access: accessing a key on nil returns nil
|
|
529
|
+
def safe_access(obj, key)
|
|
530
|
+
return nil if obj.nil?
|
|
531
|
+
|
|
532
|
+
if obj.is_a?(Hash)
|
|
533
|
+
if obj.key?(key)
|
|
534
|
+
obj[key]
|
|
535
|
+
elsif obj.key?(key.to_sym)
|
|
536
|
+
obj[key.to_sym]
|
|
537
|
+
else
|
|
538
|
+
nil
|
|
539
|
+
end
|
|
540
|
+
else
|
|
541
|
+
nil
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Safe equality with type coercion rules:
|
|
546
|
+
# - Integer/Float promotion allowed
|
|
547
|
+
# - No other implicit coercion
|
|
548
|
+
def safe_equal?(a, b)
|
|
549
|
+
# nil handling
|
|
550
|
+
return true if a.nil? && b.nil?
|
|
551
|
+
return false if a.nil? || b.nil?
|
|
552
|
+
|
|
553
|
+
# Integer/Float promotion
|
|
554
|
+
if numeric?(a) && numeric?(b)
|
|
555
|
+
return a.to_f == b.to_f
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
a == b
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Safe comparison for < > <= >=
|
|
562
|
+
# Returns false for incompatible types
|
|
563
|
+
def safe_compare(op, a, b)
|
|
564
|
+
return false if a.nil? || b.nil?
|
|
565
|
+
|
|
566
|
+
# Both numeric
|
|
567
|
+
if numeric?(a) && numeric?(b)
|
|
568
|
+
a_val = a.to_f
|
|
569
|
+
b_val = b.to_f
|
|
570
|
+
case op
|
|
571
|
+
when TokenType::LT then a_val < b_val
|
|
572
|
+
when TokenType::GT then a_val > b_val
|
|
573
|
+
when TokenType::LTE then a_val <= b_val
|
|
574
|
+
when TokenType::GTE then a_val >= b_val
|
|
575
|
+
end
|
|
576
|
+
elsif a.is_a?(String) && b.is_a?(String)
|
|
577
|
+
case op
|
|
578
|
+
when TokenType::LT then a < b
|
|
579
|
+
when TokenType::GT then a > b
|
|
580
|
+
when TokenType::LTE then a <= b
|
|
581
|
+
when TokenType::GTE then a >= b
|
|
582
|
+
end
|
|
583
|
+
else
|
|
584
|
+
false
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Check if "key" in map
|
|
589
|
+
def membership_check(key, map)
|
|
590
|
+
return false unless map.is_a?(Hash)
|
|
591
|
+
map.key?(key) || map.key?(key.to_s) || map.key?(key.to_sym)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def numeric?(val)
|
|
595
|
+
val.is_a?(Integer) || val.is_a?(Float)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def numeric_val(val)
|
|
599
|
+
return 0 if val.nil?
|
|
600
|
+
val
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
end
|