Module: Hecks::Bluebook::Expression::Resolver

Defined in:
lib/hecks/bluebook/expression/resolver.rb,
lib/hecks/bluebook/expression/resolver/block_predicates.rb

Defined Under Namespace

Classes: Addition, ArrayLiteral, BlockPredicate, BoolLiteral, Empty, EndsWith, Find, First, FloatLiteral, IntegerLiteral, Last, Lookup, MatchesRegex, Modulo, Presence, SignTest, Size, Split, StartsWith, StringLiteral, ToS

Constant Summary collapse

SIGN_TESTS =
Hecks::Vocabulary.fetch("SignTest")
SIGN_TEST_OPERATORS =

Which Comparison operator each sign test is sugar for, against the literal 0 — declared the same way in Vocabulary::SignTest's compares_via (language/bluebook/vocabulary.bluebook) ; spec/vocabulary_conformance_spec holds the two tables equal.

{
  "positive?" => ">",
  "negative?" => "<",
  "zero?"     => "=="
}.freeze
NilLiteral =

A plain class, not Struct.new(keyword_init: true) — every sibling leaf node here carries at least one field, but this one carries none by nature (a nil literal has no data to hold), and Struct.new with zero member names ahead of keyword_init: is real, live Ruby-version-dependent behavior: works on 3.3, raises "wrong number of arguments (given 0, expected 1+)" on 3.2 (caught deploying to Lambda's own ruby3.2 runtime). .new/case ... when NilLiteral below are the only two things this type is ever used for, and a bare class answers both identically.

Class.new
SIZED_TYPES =

Declared the same way in Vocabulary::SizedType (language/bluebook/vocabulary.bluebook) — spec/vocabulary_conformance_spec holds this equal to the language. Shared by .size and .empty?, which admit the same set for the same reason.

Hecks::Vocabulary.fetch("SizedType")
TO_STRING_TYPES =

Declared the same way in Vocabulary::ToStringType (language/bluebook/vocabulary.bluebook) — spec/vocabulary_conformance_spec holds this equal to the language.

Hecks::Vocabulary.fetch("ToStringType")
BLOCK_PREDICATE_MODES =

Which Array method each block-predicate suffix maps to, and which Ruby Enumerable method decides the aggregate result -- declared as data, not a three-way case, the same shape SIGN_TEST_OPERATORS above already uses for its own suffix family.

{
  "all?"  => :all,
  "any?"  => :any,
  "none?" => :none
}.freeze
BLOCK_OPENER_SUFFIXES =

Every suffix that opens a { |x| ... } block, .find included -- shared by parse_block_opener below, and by nothing else (this is NOT BLOCK_PREDICATE_MODES.find isn't a mode evaluate_block_predicate aggregates through, it builds a Find node instead, see below).

(BLOCK_PREDICATE_MODES.keys + ["find"]).freeze

Class Method Summary collapse

Class Method Details

.add(left, right) ⇒ Object



445
446
447
# File 'lib/hecks/bluebook/expression/resolver.rb', line 445

def add(left, right)
  require_number(left, "addition") + require_number(right, "addition")
end

.apply_modulo(receiver_value, divisor_value) ⇒ Object

Both operands are coerced to a real Integer/Float BEFORE the zero-check, and the check reads the COERCED divisor — not the raw divisor_value (which might not even respond to .zero?, a String for instance) and not a .to_i-truncated stand-in for it either. The old order checked a truncated divisor.to_i AFTER already validating the untruncated value wasn't zero, so a divisor merely small (0.3, truncating to 0) sailed past the guard and then blew up Integer#% with a raw ZeroDivisionError the moment it reached zero anyway.

The modulo itself is plain % on the coerced values, matching add's own no-truncation precedent just above — Ruby's native % already handles every Integer/Float combination correctly (promoting to Float when either side is one), so rounding both operands down to Integer first was pure data loss with no purpose: 7.5.modulo(2.5) silently became 7 % 2 (1) instead of the real 0.0.

Raises:



657
658
659
660
661
662
663
# File 'lib/hecks/bluebook/expression/resolver.rb', line 657

def apply_modulo(receiver_value, divisor_value)
  receiver = require_number(receiver_value, "modulo")
  divisor  = require_number(divisor_value, "modulo")
  raise EvaluationError, "divided by 0" if divisor.zero?

  receiver % divisor
end

.apply_sign_test(node, value) ⇒ Object

Raises:



553
554
555
556
557
558
# File 'lib/hecks/bluebook/expression/resolver.rb', line 553

def apply_sign_test(node, value)
  number = numeric(value)
  raise EvaluationError, "#{node.test} expects a number, got #{describe(value)}" unless number

  Evaluator.apply(node.operator, number, 0)
end

.array_elements(expr) ⇒ Object

The elements of a bracketed literal, or nil if this isn't one. Splits on TOP-LEVEL commas only — quote-aware and depth-aware, the same discipline split_addition already applies, so a nested array or a comma inside a string element stays whole.



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/hecks/bluebook/expression/resolver.rb', line 370

def array_elements(expr)
  return nil unless expr.start_with?("[") && expr.end_with?("]")

  inner = expr[1..-2].strip
  return [] if inner.empty?

  elements = []
  depth = 0
  quote = nil
  current = +""
  inner.each_char do |char|
    if quote
      quote = nil if char == quote
      current << char
      next
    end
    case char
    when '"', "'" then quote = char
    when "[", "(" then depth += 1
    when "]", ")" then depth -= 1
    end
    if char == "," && depth.zero?
      elements << current.strip
      current = +""
    else
      current << char
    end
  end
  elements << current.strip
  elements.reject(&:empty?)
end

.blank?(value) ⇒ Boolean

.present?/.blank? -- vendored addition, see the Presence struct's own comment above. nil and false are blank ; a String/Array/Hash is blank when EMPTY, not merely falsy -- a VO-wrapped field that IS assigned ({value: "x"}, Value#to_h'd first) is present regardless of what its own inner value holds, matching how every VO-typed field in this corpus is actually shaped once set at all.

Returns:

  • (Boolean)


316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/hecks/bluebook/expression/resolver.rb', line 316

def blank?(value)
  return true if value.nil? || value == false

  # Duck-typed, not `value.is_a?(Runtime::Value)` -- this module
  # is `Bluebook::Expression`, a different namespace tree from
  # `Runtime::Value` entirely, and reaching across for a single
  # class check is the exact cross-module coupling that already
  # broke `Modulo`'s own `match_call` reference elsewhere in
  # this file (found live while building this fix, not assumed).
  value = value.to_h if value.respond_to?(:to_h) && !value.is_a?(Hash)
  case value
  when String, Array, Hash then value.empty?
  else false
  end
end

.describe(value) ⇒ Object



802
# File 'lib/hecks/bluebook/expression/resolver.rb', line 802

def describe(value) = Rendering.describe(value)

.emptiness_of(value) ⇒ Object

Raises:



468
469
470
471
472
# File 'lib/hecks/bluebook/expression/resolver.rb', line 468

def emptiness_of(value)
  return value.empty? if value.is_a?(Array) || value.is_a?(String) || value.is_a?(Hash)

  raise EvaluationError, "empty? expects a list or string, got #{describe(value)}"
end

.ends_with?(value, substring) ⇒ Boolean

.end_with?("suffix") -- vendored addition, see the EndsWith struct's own comment above. Same String-only reasoning as start_with? immediately above.

Returns:

  • (Boolean)

Raises:



523
524
525
526
527
# File 'lib/hecks/bluebook/expression/resolver.rb', line 523

def ends_with?(value, substring)
  raise EvaluationError, "end_with? expects a string, got #{describe(value)}" unless value.is_a?(String)

  value.end_with?(substring)
end

.evaluate_block_predicate(node, collection, state, attrs) ⇒ Object

.all?/.any?/.none? -- vendored addition, see the BlockPredicate struct's own comment above. collection is already-interpreted (a real Array, produced by whatever receiver expression came before it -- typically Split's output), so this only has to run the per-element predicate and aggregate. interpret_with_element is the "smallest correct thing" the migration plan asked for : no persistent iteration- variable concept added anywhere else in Resolver's state model, just attrs extended with the bound name for the span of that one predicate evaluation, discarded immediately after.



186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 186

def evaluate_block_predicate(node, collection, state, attrs)
  unless collection.is_a?(Array)
    raise EvaluationError, "#{node.mode}? expects a list, got #{describe(collection)}"
  end

  outcomes = collection.map { |element| interpret_with_element(node, element, state, attrs) }

  case node.mode
  when :all  then outcomes.all?
  when :any  then outcomes.any?
  when :none then outcomes.none?
  end
end

.fetch(name, state, attrs) ⇒ Object

Raises:



779
780
781
782
783
784
785
# File 'lib/hecks/bluebook/expression/resolver.rb', line 779

def fetch(name, state, attrs)
  key = name.to_sym
  return attrs[key] if attrs.key?(key)
  return state[key] if known?(state, key)

  raise EvaluationError, "cannot resolve #{name.inspect} — no such attribute or argument"
end

.first_of(value) ⇒ Object

.first -- see the First struct's own comment above. last_of with the one method swapped, same duck-typed reasoning.

Raises:



503
504
505
506
507
# File 'lib/hecks/bluebook/expression/resolver.rb', line 503

def first_of(value)
  return value.first if value.respond_to?(:first)

  raise EvaluationError, "first expects a list, got #{describe(value)}"
end

.found_of(node, collection, state, attrs) ⇒ Object

.find { |x| PREDICATE } -- see the Find struct's own comment above. Reuses interpret_with_element unchanged (below, shared with BlockPredicate — both bind node.param to one element and interpret node.predicate against it) to find the FIRST element the predicate accepts, then projects node.path through it via walk_path, the same dotted- segment walk lookup uses for a plain attribute path. nil (no matching element, or a path segment that doesn't resolve) flows through rather than raising — the same "a dispatch-time given just refuses" shape every other missing- value case in this grammar already has, and the one a re- routing check like "is there a leg after this one" needs : not finding one is a normal outcome, not an error.

Raises:



221
222
223
224
225
226
227
228
229
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 221

def found_of(node, collection, state, attrs)
  raise EvaluationError, "find expects a list, got #{describe(collection)}" unless collection.is_a?(Array)

  found = collection.find { |element| interpret_with_element(node, element, state, attrs) }
  return unwrap_scalar(found) if node.path.empty?
  return nil if found.nil?

  unwrap_scalar(walk_path(found, node.path))
end

.interpret(node, state, attrs) ⇒ Object



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/hecks/bluebook/expression/resolver.rb', line 255

def interpret(node, state, attrs)
  case node
  when IntegerLiteral, FloatLiteral, StringLiteral, BoolLiteral then node.value
  when ArrayLiteral then node.elements.map { |element| interpret(element, state, attrs) }
  when NilLiteral then nil
  when Addition
    add(interpret(node.left, state, attrs), interpret(node.right, state, attrs))
  when SignTest
    apply_sign_test(node, interpret(node.receiver, state, attrs))
  when Empty
    emptiness_of(interpret(node.receiver, state, attrs))
  when ToS
    string_of(interpret(node.receiver, state, attrs))
  when Modulo
    apply_modulo(interpret(node.receiver, state, attrs), interpret(node.divisor, state, attrs))
  when Size
    size_of(interpret(node.receiver, state, attrs))
  when MatchesRegex
    matches_regex?(interpret(node.receiver, state, attrs), node.pattern, node.flags)
  when Presence
    present = !blank?(interpret(node.receiver, state, attrs))
    node.negated ? !present : present
  when Split
    split_value(interpret(node.receiver, state, attrs), node.separator)
  when Last
    last_of(interpret(node.receiver, state, attrs))
  when First
    first_of(interpret(node.receiver, state, attrs))
  when Find
    found_of(node, interpret(node.receiver, state, attrs), state, attrs)
  when StartsWith
    starts_with?(interpret(node.receiver, state, attrs), node.substring)
  when EndsWith
    ends_with?(interpret(node.receiver, state, attrs), node.substring)
  when BlockPredicate
    evaluate_block_predicate(node, interpret(node.receiver, state, attrs), state, attrs)
  when Lookup
    lookup(node.path, state, attrs)
  else
    # Every leaf node `parse` can produce has a `when` above —
    # a backstop against the day this grammar grows a new leaf
    # type (this file's own history: MatchesRegex/Presence/
    # Split/Last/First/Find/StartsWith/EndsWith/BlockPredicate
    # were each added exactly this way, and each one — before it had an
    # `interpret` arm — fell all the way through to the
    # `Lookup` catch-all in `parse` and crashed downstream with
    # an opaque type error, never here). A missing arm here
    # would instead return bare `nil` silently, the one wrong-
    # answer shape this leaf grammar has otherwise never
    # allowed.
    raise EvaluationError, "no interpreter handles #{node.class} — add a case before parse can produce it"
  end
end

.interpret_with_element(node, element, state, attrs) ⇒ Object

Binds the block parameter for exactly one element's predicate evaluation -- attrs wins over state in fetch (see below), so the bound name shadows any same-named state/attrs field for the span of this one call only ; nothing persists past it.



204
205
206
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 204

def interpret_with_element(node, element, state, attrs)
  Evaluator.interpret(node.predicate, state, attrs.merge(node.param.to_sym => element))
end

.known?(state, key) ⇒ Boolean

Returns:

  • (Boolean)


787
788
789
790
791
# File 'lib/hecks/bluebook/expression/resolver.rb', line 787

def known?(state, key)
  return state.key?(key) if state.respond_to?(:key?)

  !state[key].nil?
end

.last_of(value) ⇒ Object

.last -- vendored addition, see the Last struct's own comment above. Duck-typed on respond_to?(:last) rather than hard-coding Array -- the one corpus usage found this pass (Query::Phrase's .split("::").last) always receives a Split-produced Array, but nothing about .last itself is Array-specific, and this matches Empty/Size's own duck-typed-over-a-known-set precedent without inventing a narrower rule than the method needs.

Raises:



494
495
496
497
498
# File 'lib/hecks/bluebook/expression/resolver.rb', line 494

def last_of(value)
  return value.last if value.respond_to?(:last)

  raise EvaluationError, "last expects a list, got #{describe(value)}"
end

.lookup(expr, state, attrs) ⇒ Object



665
666
667
668
669
670
# File 'lib/hecks/bluebook/expression/resolver.rb', line 665

def lookup(expr, state, attrs)
  return unwrap_scalar(fetch(expr, state, attrs)) unless expr.include?(".")

  head, *rest = expr.split(".")
  unwrap_scalar(walk_path(fetch(head, state, attrs), rest))
end

.match_call(expr, marker) ⇒ Object

FOUND LIVE via the type-directed bounded-exhaustive expression generator (Phase 7, equivalence-gap plan — spec/ bounded_exhaustive_expression_spec.rb): .modulo('s own argument position accepts any numeric sub-expression, including ANOTHER .modulo(...) call — 0.modulo(num_b.modulo(-1)) is perfectly well-typed — but expr.rindex(marker) finds the RIGHTMOST (innermost) .modulo( in the whole string, not the OUTERMOST one a nested call needs split at. For that expression it found the INNER .modulo( (inside num_b.modulo(-1)) and split there, producing a receiver of "0.modulo(num_b" and a divisor of "-1)" — both garbage, both re-parsed as bogus Lookup paths, both then refusing with "cannot resolve" — a SILENT MISPARSE that happened to fail safe into a real EvaluationError rather than a raw crash, which is exactly why this had gone unnoticed: nothing before this generator existed ever fed .modulo a nested .modulo call, random fuzzing essentially never manufactures that specific shape by chance, and the resulting refusal LOOKS like an ordinary, correct one unless you already know every name this generator's own synthetic state declares (real corpus authors would see this as a mysterious "cannot resolve" on text they never wrote).

Fixed the same way split_addition/Evaluator.top_level_index already handle nested (/{ elsewhere in this exact file: find the FIRST (leftmost, outermost) occurrence of the marker, then track paren/quote depth from there to find ITS OWN matching close — not just strip the string's own trailing ) and hope it belongs to this call. Not just the FIRST occurrence, either — .modulo also CHAINS (x.modulo(a).modulo(b), the receiver of the OUTER call itself ending in a .modulo(...) call), a second real shape the leftmost-occurrence-only version of this fix still mis-parsed: the first .modulo('s own matching close paren lands mid- string (right after a), before the second .modulo(b)), so it correctly fails the "reaches the end" check below and must be tried again at the NEXT occurrence rather than giving up. Trying occurrences strictly left to right and taking the FIRST one whose matching close reaches the string's last character handles both shapes with the same rule: for NESTING (.modulo(x.modulo(y))), the leftmost (outer) occurrence's own paren-depth tracking already walks straight through the inner call to the true final ); for CHAINING, the leftmost occurrence's close lands short and is rejected, so the next occurrence (the true outermost call) is tried instead.



604
605
606
607
608
609
610
611
612
613
# File 'lib/hecks/bluebook/expression/resolver.rb', line 604

def match_call(expr, marker)
  start = 0
  while (index = expr.index(marker, start))
    close = matching_paren(expr, index + marker.length)
    return [expr[0...index], expr[(index + marker.length)...close]] if close == expr.length - 1

    start = index + 1
  end
  nil
end

.match_suffix(expr, suffixes) ⇒ Object



545
546
547
548
549
550
551
# File 'lib/hecks/bluebook/expression/resolver.rb', line 545

def match_suffix(expr, suffixes)
  suffixes.each do |suffix|
    marker = ".#{suffix}"
    return [expr[0...-marker.length], suffix] if expr.end_with?(marker)
  end
  nil
end

.matches_regex?(receiver_value, pattern, flags) ⇒ Boolean

receiver.match?(/pattern/) -- vendored addition, see the MatchesRegex struct's own comment above. receiver_value is coerced to a plain String first -- inlined here rather than calling Evaluator#string_of (a DIFFERENT module_function module; not actually in scope from inside Resolver despite Modulo's own parse rule above calling a same-named match_call that has the identical cross-module problem -- found live while building this, not assumed).

Returns:

  • (Boolean)


340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/hecks/bluebook/expression/resolver.rb', line 340

def matches_regex?(receiver_value, pattern, flags)
  text = case receiver_value
         when String, Symbol then receiver_value.to_s
         when Integer, Float then receiver_value.to_s
         when NilClass then ""
         else
           raise EvaluationError, "match? expects a scalar, got #{receiver_value.class}"
         end

  options = 0
  options |= Regexp::IGNORECASE if flags.include?("i")
  options |= Regexp::MULTILINE  if flags.include?("m")
  options |= Regexp::EXTENDED   if flags.include?("x")

  Regexp.new(pattern, options).match?(text)
rescue RegexpError => e
  # M9: a malformed pattern between the slashes (an unclosed
  # character class, say) is a defect in the EXPRESSION TEXT
  # itself, exactly the same category of author mistake an
  # unresolvable attribute name already refuses for — `Regexp.new`
  # raising a raw `RegexpError` crossed this sublanguage's own
  # refusal boundary the same way the `ZeroDivisionError`/
  # `TypeError` cases elsewhere in this file did.
  raise EvaluationError, "match? given an invalid pattern #{pattern.inspect}#{e.message}"
end

.matching_brace(expr, start) ⇒ Object

The index of the } that closes the {` implicitly opened just before `start` (the caller's own header match already consumed that opening brace, so depth begins at 1) -- nil if the string runs out before depth returns to 0 (a caller-error shape, not a valid expression). Quote-aware so a `} inside a quoted substring (.start_with?("}")) never miscounts, the same discipline split_addition/array_elements already apply for their own depth tracking.



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 155

def matching_brace(expr, start)
  depth = 1
  quote = nil
  index = start
  while index < expr.length
    char = expr[index]
    if quote
      quote = nil if char == quote
    elsif ['"', "'"].include?(char)
      quote = char
    elsif char == "{"
      depth += 1
    elsif char == "}"
      depth -= 1
      return index if depth.zero?
    end
    index += 1
  end
  nil
end

.matching_paren(expr, start) ⇒ Object

matching_brace (resolver/block_predicates.rb)'s own twin, one bracket pair over: start is the index just past the OPENING ( already consumed by the caller (depth starts at 1, not 0, for the same reason).



619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
# File 'lib/hecks/bluebook/expression/resolver.rb', line 619

def matching_paren(expr, start)
  depth = 1
  quote = nil
  index = start
  while index < expr.length
    char = expr[index]
    if quote
      quote = nil if char == quote
    elsif ['"', "'"].include?(char)
      quote = char
    elsif char == "("
      depth += 1
    elsif char == ")"
      depth -= 1
      return index if depth.zero?
    end
    index += 1
  end
  nil
end

.numeric(value) ⇒ Object



793
794
795
# File 'lib/hecks/bluebook/expression/resolver.rb', line 793

def numeric(value)
  value if value.is_a?(Integer) || value.is_a?(Float)
end

.parse(expr) ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/hecks/bluebook/expression/resolver.rb', line 189

def parse(expr)
  expr = expr.to_s.strip

  return Size.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.length\z/

  return IntegerLiteral.new(value: Integer(expr, 10)) if expr.match?(/\A-?\d+\z/)
  return FloatLiteral.new(value: Float(expr))         if expr.match?(/\A-?\d*\.\d+\z/)
  return StringLiteral.new(value: expr[1..-2])        if quoted?(expr)
  return BoolLiteral.new(value: true)                 if expr == "true"
  return BoolLiteral.new(value: false)                if expr == "false"
  return NilLiteral.new if expr == "nil"

  elements = array_elements(expr)
  return ArrayLiteral.new(elements: elements.map { |element| parse(element) }) if elements

  arithmetic = split_addition(expr)
  return Addition.new(left: parse(arithmetic[0]), right: parse(arithmetic[1])) if arithmetic

  sign = match_suffix(expr, SIGN_TESTS)
  return sign_test_node(sign) if sign

  return Empty.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.empty\?\z/
  return ToS.new(receiver: parse(Regexp.last_match(1)))   if expr =~ /\A(.+)\.to_s\z/

  modulo = match_call(expr, ".modulo(")
  return Modulo.new(receiver: parse(modulo[0]), divisor: parse(modulo[1])) if modulo

  return Size.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.size\z/

  if expr =~ /\A(.+)\.match\?\(\/(.*)\/([a-z]*)\)\z/m
    return MatchesRegex.new(receiver: parse(Regexp.last_match(1)),
                            pattern:  Regexp.last_match(2),
                            flags:    Regexp.last_match(3))
  end

  return Presence.new(receiver: parse(Regexp.last_match(1)), negated: false) if expr =~ /\A(.+)\.present\?\z/
  return Presence.new(receiver: parse(Regexp.last_match(1)), negated: true)  if expr =~ /\A(.+)\.blank\?\z/

  if expr =~ /\A(.+)\.split\("([^"]*)"\)\z/
    return Split.new(receiver: parse(Regexp.last_match(1)), separator: Regexp.last_match(2))
  end

  return First.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.first\z/
  return Last.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.last\z/

  if expr =~ /\A(.+)\.start_with\?\("([^"]*)"\)\z/
    return StartsWith.new(receiver: parse(Regexp.last_match(1)), substring: Regexp.last_match(2))
  end

  if expr =~ /\A(.+)\.end_with\?\("([^"]*)"\)\z/
    return EndsWith.new(receiver: parse(Regexp.last_match(1)), substring: Regexp.last_match(2))
  end

  block_opener = parse_block_opener(expr)
  return block_opener if block_opener

  Lookup.new(path: expr)
end

.parse_block_opener(expr) ⇒ Object

.all?/.any?/.none?/.find -- vendored addition, see the BlockPredicate/Find structs' own comments above. Matched last among the suffix rules (right before the Lookup catch-all) since a block's own predicate text can itself contain almost anything a leaf expression can, including ANOTHER block-opening suffix -- letting every more specific rule above try first avoids this one accidentally swallowing a receiver another rule was meant to parse.

ONE combined header regex over ALL FOUR suffixes together, not .find and .all?/any?/none? scanned separately (that was this file's own first cut, and it broke the moment a block predicate's own predicate text contained a DIFFERENT kind of block-opener than the one being scanned for -- legs.any? { |leg| ... legs.find { |o| ... } ... } : scanning for .find FIRST found the INNER .find, not the outer .any?, because a non-greedy receiver capture only guarantees the FIRST occurrence of ITS OWN suffix, not the first occurrence of ANY block-opening suffix -- confirmed live via the shipping domain's own re-routing rules, the same "no implicit conversion of Symbol into Integer" signature the original nested-.any? bug had, not inferred). Scanning for all four AT ONCE and letting the regex engine's own leftmost match win fixes both directions (.find nested in .any? OR .any? nested in .find) with the SAME one rule, since the true receiver never itself contains ANY of these four words followed by {.

matching_brace walks forward counting {`/`} depth from there, the same quote-aware, depth-aware discipline split_addition/array_elements already apply, so a } inside a nested block (or a quoted start_with? substring) never miscounts. .find's own trailing dotted path (path) is captured from whatever follows the closing brace ; every other suffix instead requires nothing follow it at all (the BlockPredicate shape, unchanged from before this rewrite).



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 119

def parse_block_opener(expr)
  pattern = /\A(.+?)\.(#{BLOCK_OPENER_SUFFIXES.map { |suffix| Regexp.escape(suffix) }.join('|')})\s*\{\s*\|(\w+)\|\s*/m
  header = expr.match(pattern)
  return nil unless header

  receiver_text = header[1]
  suffix = header[2]
  param = header[3]
  body_start = header.end(0)
  body_end = matching_brace(expr, body_start)
  return nil unless body_end

  predicate = Evaluator.parse(expr[body_start...body_end].strip)

  if suffix == "find"
    trailing = expr[(body_end + 1)..].strip
    return nil unless trailing.empty? || trailing.start_with?(".")

    Find.new(receiver: parse(receiver_text), param: param, predicate: predicate,
             path: trailing.empty? ? [] : trailing[1..].split("."))
  else
    return nil unless expr[(body_end + 1)..].strip.empty?

    BlockPredicate.new(mode: BLOCK_PREDICATE_MODES.fetch(suffix), receiver: parse(receiver_text),
                       param: param, predicate: predicate)
  end
end

.quoted?(expr) ⇒ Boolean

Returns:

  • (Boolean)


449
450
451
452
453
454
# File 'lib/hecks/bluebook/expression/resolver.rb', line 449

def quoted?(expr)
  return false if expr.length < 2

  (expr.start_with?('"') && expr.end_with?('"')) ||
    (expr.start_with?("'") && expr.end_with?("'"))
end

.require_number(value, operation) ⇒ Object



797
798
799
800
# File 'lib/hecks/bluebook/expression/resolver.rb', line 797

def require_number(value, operation)
  numeric(value) ||
    raise(EvaluationError, "#{operation} expects a number, got #{describe(value)}")
end

.resolve(expr, state, attrs) ⇒ Object



185
186
187
# File 'lib/hecks/bluebook/expression/resolver.rb', line 185

def resolve(expr, state, attrs)
  interpret(parse(expr), state, attrs)
end

.sign_test_node(parts) ⇒ Object



248
249
250
251
252
253
# File 'lib/hecks/bluebook/expression/resolver.rb', line 248

def sign_test_node(parts)
  receiver, test = parts
  symbol   = SIGN_TEST_OPERATORS.fetch(test)
  operator = Evaluator::OPERATORS.find { |candidate| candidate.symbol == symbol }
  SignTest.new(operator: operator, test: test, receiver: parse(receiver))
end

.size_of(value) ⇒ Object

Raises:



462
463
464
465
466
# File 'lib/hecks/bluebook/expression/resolver.rb', line 462

def size_of(value)
  return value.size if value.is_a?(Array) || value.is_a?(String) || value.is_a?(Hash)

  raise EvaluationError, "size expects a list or string, got #{describe(value)}"
end

.split_addition(expr) ⇒ Object

BRACES COUNT TOWARD DEPTH, exactly as parens do — a + inside a block predicate's own { |x| ... } body is not this expression's own addition. parse tries addition BEFORE parse_block_opener, so a paren-only depth count split kings.any? { |k| k.square.file == to.file + 1 && ... } at that inner +, turning the whole expression into a nonsense Addition whose left operand then walked "any? { |k| k" into an Array as an attribute path — "TypeError: no implicit conversion of Symbol into Integer", the same signature every unsupported construct raises, which is what let this hide. Found live in a downstream chess domain's castling given; the evaluator's own top_level_index has counted braces since its own version of this exact lesson.



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
# File 'lib/hecks/bluebook/expression/resolver.rb', line 415

def split_addition(expr)
  depth = 0
  quote = nil

  expr.each_char.with_index do |char, index|
    if quote
      quote = nil if char == quote
    elsif ['"', "'"].include?(char)
      quote = char
    # `[`/`]` -- the identical lesson this method's own `(`/`{`
    # comment already names, a third time (found live via the
    # type-directed bounded-exhaustive expression generator,
    # Phase 7 of the equivalence-gap plan): `ArrayLiteral` can
    # appear as a general sub-expression now, not only as
    # `.include?`'s own haystack, so an array element containing
    # its own top-level `+` (`[0, 0 + 0]`) used to read as THIS
    # expression's own addition split point -- the whole
    # receiver before `.all?`/`.any?`/etc. torn in half before
    # `parse_block_opener` ever saw it as one atomic leaf.
    elsif ["(", "{", "["].include?(char)
      depth += 1
    elsif [")", "}", "]"].include?(char)
      depth -= 1
    elsif char == "+" && depth.zero?
      return [expr[0...index].strip, expr[(index + 1)..].strip]
    end
  end
  nil
end

.split_value(value, separator) ⇒ Object

.split("SEP") -- vendored addition, see the Split struct's own comment above. Only a String receiver makes sense to split -- unlike .length/.size/.empty?, which are already meaningful over Array/Hash too, .split is a String-only method in the corpus's own usage (every occurrence found this pass splits a Phrase's own string value).

Raises:



480
481
482
483
484
# File 'lib/hecks/bluebook/expression/resolver.rb', line 480

def split_value(value, separator)
  raise EvaluationError, "split expects a string, got #{describe(value)}" unless value.is_a?(String)

  value.split(separator)
end

.starts_with?(value, substring) ⇒ Boolean

.start_with?("prefix") -- vendored addition, see the StartsWith struct's own comment above. String-only, same reasoning as .split above -- every corpus usage found this pass (Params's own JSON-object-shape invariant) receives a plain String field.

Returns:

  • (Boolean)

Raises:



514
515
516
517
518
# File 'lib/hecks/bluebook/expression/resolver.rb', line 514

def starts_with?(value, substring)
  raise EvaluationError, "start_with? expects a string, got #{describe(value)}" unless value.is_a?(String)

  value.start_with?(substring)
end

.string_of(value) ⇒ Object



534
535
536
537
538
539
540
541
542
543
# File 'lib/hecks/bluebook/expression/resolver.rb', line 534

def string_of(value)
  case value
  when String                then value
  when Integer, Float        then value.to_s
  when TrueClass, FalseClass then value.to_s
  when NilClass              then ""
  else
    raise EvaluationError, "to_s expects a scalar, got #{describe(value)}"
  end
end

.unwrap_scalar(value) ⇒ Object

field == "literal" -- vendored addition, not (yet) upstream hecks (migration plan task 8): the third-most pervasive dispatch-time gap this pass found, same family as .match?/ .present? above -- a lookup of a single-field scalar-convenience value object (the exact shape Value. from_identifier/Value::Coercion#fields_for's own single- field auto-unwrap already treats as "this VO IS its scalar" everywhere else in this runtime) came back as the Value wrapper itself, never unwrapped for READING -- so Value#== (which only ever equals another Value instance) silently refused every guarantees "..." do status == "active" end / expects "..." do trash_day.present? end-shaped bare comparison against a raw literal. Confirmed corpus-wide, not one file's mistake: bin-buddy alone has this exact field == "literal" shape in plan.bluebook, service_task.bluebook, route.bluebook, and subscription.bluebook, all equally silent until a real dispatch (never validate) exercised the predicate. Scoped narrowly to the single-field {value: X} shape only.

UPDATE 2026-08-18: originally scoped to unwrap ONLY the bare (undotted) case, on the belief that a dotted lookup only ever reaches into a VO's OWN field (field.value, field.sub_ field) and so should keep walking #[] untouched. That belief held for the single-hop case but not for the general one: a dotted lookup that NAVIGATES THROUGH an entity/list element to a nested field (leg.voyage, where voyage is itself a single-field VO) landed on the very same unwrapped- Value shape the bare case fixed, and hit the identical silent Value#== failure -- comparing it against a raw literal or another unwrapped VO returned false for everything, no error. The terminal value of a dotted walk deserves the same "this VO IS its scalar" treatment as a bare lookup's result; only the INTERMEDIATE hops need raw #[] addressing to keep navigating. unwrap_scalar is idempotent on an already-raw scalar (a String/Integer doesn't respond to #to_h), so this is safe for the existing field.value- shaped dotted lookups too -- they already returned a raw scalar and are unaffected. UPDATE (single-element value objects strictly answer .value): the unwrap used to gate on the sole key being literally NAMED :value — correct for the shorthand/closed-set shapes that motivated it, but a lie of omission for Money{amount} and every other single-field value object whose author picked a domain name for the field: the SAME "this VO IS its scalar"

reading ([[feedback_name_the_scalar_field]], `Behaviour

ValueObject#sole_attribute) applies regardless of what the sole field happens to be called, and the name gate made a bare balance > 0work for aBalancevaluewhile silently comparing a whole VO for aBalanceamount. Now the COUNT is the gate, never the name. A declared Runtime::Valuereads its ownsole_attribute(the declaration's answer, not the stored hash's); any OTHER to_h-able (a Struct, a bespoke wrapper with no declaration to consult) keeps the originalX-only unwrap, so nothing that never was a value object gains a surprise unwrapping. rust/src/kernel/json.rs's impl Fielded for Json` mirrors the count-only reading on the Rust side — change them in lockstep or rust_conformance diverges.



767
768
769
770
771
772
773
774
775
776
777
# File 'lib/hecks/bluebook/expression/resolver.rb', line 767

def unwrap_scalar(value)
  return value unless value.respond_to?(:to_h) && !value.is_a?(Hash) && !value.is_a?(Array)

  if value.respond_to?(:value_object)
    sole = value.value_object.sole_attribute
    return sole ? value[sole.name] : value
  end

  hash = value.to_h
  hash.size == 1 && hash.key?(:value) ? hash[:value] : value
end

.walk_path(value, segments) ⇒ Object

Walks a list of dotted segments through an already-resolved Hash-like value — extracted from lookup's own reduce so found_of (the Find node's own path projection) can walk a .find { ... }-produced element the identical way lookup walks a plain attribute path, rather than duplicating the symbol-or-string key step twice in this file. key? decides which spelling answers — a bare || between the two would treat a genuinely-held false the same as an absent key and fall through to the other spelling, landing on nil.



681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
# File 'lib/hecks/bluebook/expression/resolver.rb', line 681

def walk_path(value, segments)
  segments.reduce(value) do |current, segment|
    break nil unless current.respond_to?(:[])

    if current.is_a?(Hash)
      sym = segment.to_sym
      current.key?(sym) ? current[sym] : current[segment]
    else
      begin
        current[segment]
      rescue TypeError
        # M9 (docs/audits/2026-08-10-main-bug-audit.md): a dotted
        # path can walk onto an Array (e.g. the result of `.split`,
        # or a `list_of` attribute) — Array#[] demands an
        # Integer/Range and raises a raw TypeError for a String
        # segment ("no implicit conversion of String into
        # Integer"), which used to cross straight past this
        # sublanguage's own refusal boundary and crash the
        # runtime instead of reading as "this predicate doesn't
        # apply here."
        raise EvaluationError,
              "cannot read #{segment.inspect} from #{describe(current)}"
      end
    end
  end
end