[Dexter] Add support for aggregate expects in the debugger (#202545)

Allows aggregate expects to be written in Dexter scripts, in the form:

```
!value expect:
  member1: 0
  member2:
    submember1: 1
    submember2: 2
```

This uses the "variables" feature provided in the DAP interface to
recursively fetch sub-variables from each evaluated variable, and
modifies the DebuggerExpectMatch class to also be recursive, containing
submatches for each expected aggregate member value.

Some key concepts that are affected by this are the "match_result",
which has changed from a bool to a 3-way result ("FALSE", "TRUE",
"PARTIAL"), where "PARTIAL" means that at least one submatch was TRUE
and at least one was FALSE. We also add a `match_distance`, which is
used as a measure of correctness. The match distance ranges from 0.0 to
1.0: a total match has a distance of 0.0, a total mismatch has 1.0, and
a partial match's distance is equal to the average distance of its
immediate children.
This commit is contained in:
Stephen Tozer 2026-06-16 16:25:46 +01:00 committed by GitHub
parent b6f0fb6c24
commit 4cb6eee15d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 472 additions and 25 deletions

View file

@ -18,7 +18,7 @@ import sys
import threading
import time
from enum import Enum
from typing import List, Optional
from typing import Dict, List, Optional, Union
from dex.debugger.DebuggerBase import DebuggerBase, watch_is_active
from dex.dextIR import FrameIR, LocIR, StepIR, StopReason, ValueIR
@ -551,6 +551,22 @@ class DAP(DebuggerBase, metaclass=abc.ABCMeta):
time.sleep(0.001)
return self._debugger_state.get_response(seq)
# Helper method that sends the request defined by "command" + "arguments", awaits the response, and returns the
# response when it arrives. An optional timeout for the response may be passed.
# If allow_failure is passed, then the result may instead be a str containing the fail reason if the request failed.
def _communicate_request(
self, command: str, arguments=None, timeout: float = 60.0, allow_failure=False
) -> Union[Dict, str]:
req_id = self.send_message(self.make_request(command, arguments))
response = self._await_response(req_id, timeout)
if not response["success"]:
if not allow_failure:
raise DebuggerException(
f"received failure response for command {command}"
)
return response["message"]
return response["body"]
## End of DAP communication methods
############################################################################
@ -1018,6 +1034,36 @@ class DAP(DebuggerBase, metaclass=abc.ABCMeta):
def _evaluate_result_value(expression: str, result_string: str) -> ValueIR:
"""For the result of an "evaluate" message, return a ValueIR. Implementation must be debugger-specific."""
# For the given `value` and associated `variables_reference`, recursively requests "variables" information for all
# child variables and adds them as sub_values to `value`.
def _evaluate_subvariables(self, value: ValueIR, variables_reference: int):
if variables_reference == 0:
return
# DFS subvariables recursively, adding them as sub_values to their parent ValueIRs.
variables_irs = {variables_reference: value}
search_vars = [variables_reference]
while search_vars:
next_var = search_vars[0]
search_vars = search_vars[1:]
# The ValueIR for the variable/subvariable whose children we are examining.
variable_ir: ValueIR = variables_irs[next_var]
result_vars: Dict = self._communicate_request(
"variables", {"variablesReference": next_var, "filter": "named"}
)
for var in result_vars["variables"]:
new_ir = self._evaluate_result_value(
var["name"], var["value"], var.get("type")
)
variable_ir.sub_values.append(new_ir)
new_ref = var.get("variablesReference", 0)
if (
new_ir.could_evaluate
and not new_ir.is_irretrievable
and new_ref != 0
):
variables_irs[new_ref] = new_ir
search_vars.append(new_ref)
def evaluate_expression(self, expression, frame_idx=0) -> ValueIR:
# The frame_idx passed in here needs to be translated to the debug adapter's internal frame ID.
dap_frame_id = self._debugger_state.frame_map[frame_idx]
@ -1040,8 +1086,13 @@ class DAP(DebuggerBase, metaclass=abc.ABCMeta):
result = eval_response["message"]
else:
result = "<unable to evaluate expression>"
variables_ref = 0
else:
result = eval_response["body"]["result"]
variables_ref = eval_response["body"].get("variablesReference", 0)
type_str = eval_response["body"].get("type")
return self._evaluate_result_value(expression, result, type_str)
value_ir = self._evaluate_result_value(expression, result, type_str)
self._evaluate_subvariables(value_ir, variables_ref)
return value_ir

View file

@ -128,5 +128,8 @@ class StepIR:
if self.watches:
lines.append(f"Variables:")
for value in sorted(self.watches.values(), key=lambda v: v.expression):
lines.append(f" {value}")
if value.sub_values:
value.dump_nested(lines, 1)
else:
lines.append(f" {value}")
return lines

View file

@ -6,6 +6,9 @@
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
from typing import List, Optional
class ValueIR:
"""Data class to store the result of an expression evaluation."""
@ -26,6 +29,7 @@ class ValueIR:
self.error_string = error_string
self.is_optimized_away = is_optimized_away
self.is_irretrievable = is_irretrievable
self.sub_values: list[ValueIR] = []
def __str__(self):
prefix = '"{}": '.format(self.expression)
@ -39,3 +43,9 @@ class ValueIR:
self.could_evaluate, self.is_irretrievable, self.is_optimized_away
)
)
def dump_nested(self, lines: List[str], indent: int = 0):
indent_str = " " * indent
lines.append(f"{indent_str}{self}")
for v in self.sub_values:
v.dump_nested(lines, indent + 1)

View file

@ -6,33 +6,207 @@
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""Utilities for matching debugger output to script expected values."""
from typing import Any, Dict, List, Union
from collections import Counter, OrderedDict
from enum import Enum, IntEnum
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from dex.dextIR import ValueIR
from dex.test_script.Nodes import Expect, Value
def get_expected_value_set(
expected, prepend_tuple: Tuple = ()
) -> Dict[Tuple[str], int]:
"""For the given "expected" taken directly from the script YAML, returns the set of all actual expected
values, using tuples to represent nested expected values, and mapping each result to the number of times that it
appears in the set (giving a count). For example, the expected values of:
```
!value foo:
- 4
- 6
- x: 5
y: 10
- x: 5
y: 20
```
Would be represented by the dict:
```
{
(4,): 1,
(6,): 1,
("x", 5): 2,
("y", 10): 1,
("y", 20): 1,
}
```
"""
result: Dict[Tuple, int] = Counter()
if isinstance(expected, list):
for ev in expected:
result.update(get_expected_value_set(ev, prepend_tuple))
elif isinstance(expected, dict):
for sub_expect, sub_expected in expected.items():
next_prepend = prepend_tuple + (str(sub_expect),)
result.update(get_expected_value_set(sub_expected, next_prepend))
else:
result[prepend_tuple + (str(expected),)] += 1
return result
class MatchResult(IntEnum):
FALSE = 0
PARTIAL = 1
TRUE = 2
class DebuggerExpectMatch:
"""Class that represents the match between a particular expected value for an Expect node and the actual debugger
output corresponding to the watched value for that node."""
output corresponding to the watched value for that node.
The value of `expected` may be either a string-convertible scalar (non-collection) value, representing a
non-aggregate expected value, or it may be a dict of strings to other expected values.
`actual_result` is None if either `actual` or `expect.get_variable_result(actual)` is None,
Otherwise, if `expected` is a dict, then `actual_result` is a dict[str, DebuggerExpectMatch],
Otherwise, `actual_result` is a str.
"""
def __init__(self, expect: Expect, expected, actual: ValueIR):
def __init__(self, expect: Expect, expected, actual: Optional[ValueIR]):
self.expect = expect
self.expected = expected
self.actual = actual
self.actual_result = self.expect.get_variable_result(self.actual)
self.match_result = (
self.expected is not None and str(self.expected) == self.actual_result
self.actual_result, self.match_result = self._get_actual_result()
self.match_distance = self._get_match_distance()
def _get_actual_result(
self,
) -> Tuple[Union[str, Dict[str, "DebuggerExpectMatch"], None], MatchResult]:
if isinstance(self.expected, dict):
sub_expect_results: Dict[str, DebuggerExpectMatch] = OrderedDict()
for sub_expect, sub_expected in self.expected.items():
# If the value of `actual` is None, we still want this match to reflect the structure of the expected
# value, so if we have an expected value: `!value foo: {a: 0, b: 1}`, and `actual == None`, then we
# should produce a match `foo: {'a': None, 'b': None}`, rather than `foo: None`, so we unconditionally
# traverse the expected value here tree even if we have a None result.
value = (
None
if self.actual is None
else next(
(
sub_value
for sub_value in self.actual.sub_values
if sub_value.expression == sub_expect
),
None,
)
)
sub_expect_results[sub_expect] = DebuggerExpectMatch(
self.expect, sub_expected, value
)
if all(
result.match_result == MatchResult.TRUE
for result in sub_expect_results.values()
):
match_result = MatchResult.TRUE
elif all(
result.match_result == MatchResult.FALSE
for result in sub_expect_results.values()
):
match_result = MatchResult.FALSE
else:
match_result = MatchResult.PARTIAL
return sub_expect_results, match_result
actual_result = (
self.expect.get_variable_result(self.actual)
if self.actual is not None
else None
)
match_result = (
MatchResult.TRUE
if (self.expected is not None and str(self.expected) == actual_result)
else MatchResult.FALSE
)
return actual_result, match_result
def _get_match_distance(self) -> float:
if self.match_result == MatchResult.TRUE:
return 0.0
if self.match_result == MatchResult.FALSE:
return 1.0
assert (
isinstance(self.actual_result, Dict) and self.actual_result
), "Partial match without submatches."
dists = [m.match_distance for m in self.actual_result.values()]
return sum(dists) / len(dists)
def get_expression(self) -> Optional[str]:
return self.actual.expression if self.actual else None
def get_all_matched_values(self, prepend_tuple: Tuple = ()) -> Set[Tuple]:
"""Similar to `get_expected_value_set` above, but returns the set of expected values that were successfully
matched in this DebuggerExpectMatch, and returns just a set (no counts)."""
if self.match_result == MatchResult.FALSE:
return set()
assert (
self.actual_result is not None and self.actual is not None
), "Non-false match with no actual result."
if isinstance(self.actual_result, str):
assert (
self.match_result == MatchResult.TRUE
), "Partial match without submatches."
return {prepend_tuple + (str(self.expected),)}
result = set()
for sub_expr, sub_result in self.actual_result.items():
result = result.union(
sub_result.get_all_matched_values(prepend_tuple + (sub_expr,))
)
return result
def short_str(self, use_color=True) -> str:
def colorize(input: str, match_result: MatchResult) -> str:
if not use_color:
return input
if match_result == MatchResult.TRUE:
return f"<g>{input}</>"
if match_result == MatchResult.PARTIAL:
return f"<y>{input}</>"
return f"<r>{input}</>"
if self.actual is None:
return colorize("<Missing>", self.match_result)
if self.actual_result is None:
if self.actual.is_optimized_away:
return colorize("<OptimizedOut>", self.match_result)
if self.actual.is_irretrievable:
return colorize("<Irretrievable>", self.match_result)
return colorize("<EvaluateFailed>", self.match_result)
if isinstance(self.actual_result, str):
return colorize(self.actual_result, self.match_result)
assert isinstance(self.expected, dict)
sub_values = [
colorize(f'"{sub_expr}": ', sub_result.match_result)
+ sub_result.short_str()
for sub_expr, sub_result in self.actual_result.items()
]
return f"{{ {', '.join(sub_values)} }}"
def get_expect_match(expect: Expect, expected_values, actual: ValueIR):
"""Given one or more expected values for an Expect node and an actual ValueIR, returns a match for the first
matching expected values, or for None if there are no matching expected values."""
"""Given one or more expected values for an Expect node and an actual ValueIR, returns a match for the best
matching expected value, which is either the first exact match, or the match with the lowest distance (see
`DebuggerExpectMatch._get_match_distance` above), or returns a match for None if there are no expected values with
a match distance less than 1.0 (i.e. all expected values have no overlap with the actual value).
"""
if not isinstance(expected_values, list):
expected_values = [expected_values]
best_partial_match = DebuggerExpectMatch(expect, None, actual)
best_partial_match_dist = 1.0
for expected_value in expected_values:
expect_match = DebuggerExpectMatch(expect, expected_value, actual)
if expect_match.match_result:
if expect_match.match_result == MatchResult.TRUE:
return expect_match
return DebuggerExpectMatch(expect, None, actual)
# A "FALSE" match will have a match distance of 1.0, and therefore will never be considered a "best match".
if expect_match.match_distance < best_partial_match_dist:
best_partial_match = expect_match
best_partial_match_dist = expect_match.match_distance
return best_partial_match

View file

@ -9,7 +9,11 @@
from typing import Any, Dict, List, Union
from dex.evaluation.ExpectMatch import DebuggerExpectMatch
from dex.evaluation.ExpectMatch import (
DebuggerExpectMatch,
MatchResult,
get_expected_value_set,
)
from dex.test_script.Nodes import Expect, Value
@ -52,6 +56,8 @@ class ScalarMetric(Metric):
return ScalarMetric(self.value + other.value, self.improves_asc)
def __repr__(self):
if isinstance(self.value, float):
return f"{self.value:.4}"
return f"{self.value}"
@ -98,19 +104,31 @@ def get_variable_metrics(
if not isinstance(expected_values, list):
expected_values = [expected_values]
num_total_steps = len(matches)
all_expected_values = get_expected_value_set(expected_values)
seen_expected_values = set()
num_correct_steps = 0
num_missing_var_steps = 0
num_unexpected_value_steps = 0
partial_step_correctness = 0.0
for match in matches:
if match.match_result:
seen_expected_values.add(match.expected)
partial_step_correctness += 1.0 - match.match_distance
seen_expected_values = seen_expected_values.union(
match.get_all_matched_values()
)
if match.match_result == MatchResult.TRUE:
num_correct_steps += 1
elif match.actual_result is None:
num_missing_var_steps += 1
else:
num_unexpected_value_steps += 1
num_seen_values = sum(ev in seen_expected_values for ev in expected_values)
assert all(
ev in all_expected_values for ev in seen_expected_values
), "Saw expected values that weren't expected?"
num_seen_values = sum(all_expected_values[ev] for ev in seen_expected_values)
num_missing_values = sum(
0 if ev in seen_expected_values else count
for ev, count in all_expected_values.items()
)
# And finally produce the metrics map and add the new result to the list.
metrics = {
# The number of steps. Though this is not a useful metric in itself, it may be useful to see in tandem with
@ -122,6 +140,8 @@ def get_variable_metrics(
"incorrect_steps": ScalarMetric(
num_total_steps - num_correct_steps, improves_asc=False
),
# The sum of the 0.0-1.0 "correctness value" of matches across each step.
"partial_step_correctness": ScalarMetric(partial_step_correctness),
# The number of steps where the watched variable/expression was not available in the debugger.
"missing_var_steps": ScalarMetric(num_missing_var_steps, improves_asc=False),
# The number of steps where the watched variable/expression had a value not in the set of expected values.
@ -133,8 +153,6 @@ def get_variable_metrics(
# The number of expected values that were observed at least once.
"seen_values": ScalarMetric(num_seen_values),
# The number of expected values that were not observed.
"missing_values": ScalarMetric(
len(expected_values) - num_seen_values, improves_asc=False
),
"missing_values": ScalarMetric(num_missing_values, improves_asc=False),
}
return metrics

View file

@ -14,7 +14,11 @@ from collections import defaultdict
from typing import Any, Dict, List, Tuple
from dex.dextIR import DextIR, StepIR
from dex.evaluation.ExpectMatch import DebuggerExpectMatch, get_expect_match
from dex.evaluation.ExpectMatch import (
DebuggerExpectMatch,
MatchResult,
get_expect_match,
)
from dex.evaluation.Metrics import (
Metric,
get_variable_metrics,
@ -132,17 +136,17 @@ class DebuggerRunMatch(object):
matching_expects = [
(expect, match)
for expect, match in step_match.expect_matches.items()
if match.match_result
if match.match_result == MatchResult.TRUE
]
non_matching_expects = [
(expect, match)
for expect, match in step_match.expect_matches.items()
if not match.match_result
if match.match_result != MatchResult.TRUE
]
if matching_expects:
result += f" Matching nodes: [{', '.join(f'{expect}={match.actual_result}' for expect, match in matching_expects)}]\n"
result += f" Matching nodes: [{', '.join(f'{expect}={match.short_str()}' for expect, match in matching_expects)}]\n"
if non_matching_expects:
result += f" Non-matching nodes: [{', '.join(f'{expect}={match.actual_result}' for expect, match in non_matching_expects)}]\n"
result += f" Non-matching nodes: [{', '.join(f'{expect}={match.short_str()}' for expect, match in non_matching_expects)}]\n"
return result
def get_metric_output(self):

View file

@ -0,0 +1,75 @@
// RUN: %dexter_regression_test_cxx_build %s -o %t
// RUN: %dexter_regression_test_run --use-script --skip-evaluate --binary %t \
// RUN: -- %s | FileCheck %s
/// Check that the debugger is able to fetch the components of aggregate values.
struct Point {
int X;
int Y;
int Z;
};
struct Rect {
Point TopLeft;
Point BottomRight;
};
int main() {
Point P{1, 2, 3};
int *I = &P.X;
Rect R{{1, 1, 1}, {2, 2, 2}};
int L[] = {0, 1, 2, 3, 4};
return 0; // !dex_label ret
}
// CHECK: Frame 0:
// CHECK-NEXT: main
// CHECK: "I": (int *)
// CHECK-NEXT: "*I": (int) 1
// CHECK-NEXT: "L": (int[5])
// CHECK-NEXT: "[0]": (int) 0
// CHECK-NEXT: "[1]": (int) 1
// CHECK-NEXT: "[2]": (int) 2
// CHECK-NEXT: "[3]": (int) 3
// CHECK-NEXT: "[4]": (int) 4
// CHECK-NEXT: "P": (Point)
// CHECK-NEXT: "X": (int) 1
// CHECK-NEXT: "Y": (int) 2
// CHECK-NEXT: "Z": (int) 3
// CHECK-NEXT: "R": (Rect)
// CHECK-NEXT: "TopLeft": (Point)
// CHECK-NEXT: "X": (int) 1
// CHECK-NEXT: "Y": (int) 1
// CHECK-NEXT: "Z": (int) 1
// CHECK-NEXT: "BottomRight": (Point)
// CHECK-NEXT: "X": (int) 2
// CHECK-NEXT: "Y": (int) 2
// CHECK-NEXT: "Z": (int) 2
/*
---
!where {lines: !label ret}:
!value P:
X: 1
Y: 2
Z: 3
!value I:
"*I": 1
!value R:
TopLeft:
X: 1
Y: 1
Z: 1
BottomRight:
X: 2
Y: 2
Z: 2
!value L:
"[0]": 0
"[1]": 1
"[2]": 2
"[3]": 3
"[4]": 4
...
*/

View file

@ -0,0 +1,62 @@
// RUN: %dexter_regression_test_cxx_build %s -o %t
// RUN: %dexter_regression_test_run --use-script --binary %t -- %s \
// RUN: | FileCheck %s
/// Check that the debugger is able to evaluate the components of aggregate
/// values.
struct Point {
int X;
int Y;
int Z;
};
struct Rect {
Point TopLeft;
Point BottomRight;
};
int main() {
Point P{1, 2, 3};
int *I = &P.X;
Rect R{{1, 1, 1}, {2, 2, 2}};
int L[] = {0, 1, 2, 3, 4};
return 0; // !dex_label ret
}
// CHECK: total_watched_steps: 4
// CHECK: correct_steps: 3
// CHECK: incorrect_steps: 1
// CHECK: partial_step_correctness: 3.333
// CHECK: missing_var_steps: 0
// CHECK: unexpected_value_steps: 1
// CHECK: seen_values: 13
// CHECK: missing_values: 2
/*
---
!where {lines: !label ret}:
!value P:
X: 1 # Correct
Y: 0 # Incorrect
# Missing "Z"
W: 8 # Not present
!value I:
"*I": 1
!value R:
TopLeft:
X: 1
Y: 1
Z: 1
BottomRight:
X: 2
Y: 2
Z: 2
!value L:
"[0]": 0
"[1]": 1
"[2]": 2
"[3]": 3
"[4]": 4
...
*/

View file

@ -0,0 +1,50 @@
// RUN: %dexter_regression_test_cxx_build %s -o %t
// RUN: %dexter_regression_test_run --use-script --binary %t -- %s \
// RUN: | FileCheck %s
/// Check that the debugger is able to correctly evaluate a list of expected
/// aggregate values.
struct Point {
int X;
int Y;
};
int main() {
Point P{1, 2};
P.X = 3; // !dex_label start
P.Y = 0;
P.X = 1;
P.Y = 2;
P = {0, 0};
return 0; // !dex_label end
}
// CHECK: total_watched_steps: 6
// CHECK: correct_steps: 6
// CHECK: incorrect_steps: 0
// CHECK: partial_step_correctness: 6.0
// CHECK: missing_var_steps: 0
// CHECK: unexpected_value_steps: 0
// CHECK: correct_step_coverage: 100.0% (6/6)
// CHECK: seen_values: 12
// CHECK: missing_values: 0
/*
---
!where {lines: !range [!label start, !label end]}:
!value P:
- X: 1
Y: 2
- X: 3
Y: 2
- X: 3
Y: 0
- X: 1
Y: 0
- X: 1
Y: 2
- X: 0
Y: 0
...
*/