[Dexter] Add !step node for testing stepping behaviour (#203844)

This patch adds a node for generating metrics based on lines stepped on.
The new node has 3 versions: !step exactly, !step order, and !step
never, which check an expected list of line numbers against the actual
line numbers seen while the expect is active. "exactly" and "order" both
check that the list of line numbers appear in-order in the debugger
while the expect is in scope; "exactly" treats any seen line numbers
outside of this list as incorrect, while "order" ignores them. "never"
does not have an order, but treats any line numbers from the list that
are seen in the debugger as being incorrect.
This commit is contained in:
Stephen Tozer 2026-07-01 11:14:46 +01:00 committed by GitHub
parent 6e4eebc91c
commit f9ddebc5c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 351 additions and 33 deletions

View file

@ -231,9 +231,10 @@ class ScriptExpectRewriter:
assert expected_value is None
self.scope_expect_rewrites[expect] = []
return
if expected_value is not None:
return
assert isinstance(expect, Value), "Non-Value expects currently unsupported"
if expected_value is None:
self.unknown_expect_rewrites[expect] = []
self.unknown_expect_rewrites[expect] = []
script.visit_script(visit_expect=collect_expects_to_rewrite)

View file

@ -7,14 +7,14 @@
"""Produce metric results from the results of a comparison of a DexterScript and debugger output.
"""
from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Optional, Union
from dex.evaluation.ExpectMatch import (
DebuggerExpectMatch,
MatchResult,
get_expected_value_set,
)
from dex.test_script.Nodes import Expect, Value
from dex.test_script.Nodes import Expect, Step, Value
class Metric:
@ -156,3 +156,78 @@ def get_variable_metrics(
"missing_values": ScalarMetric(num_missing_values, improves_asc=False),
}
return metrics
def lcs_len(a: List[int], b: List[int]) -> int:
"""Returns the length of the longest common subsequence between a and b."""
lcs_table: List[List[int]] = [
[0 for _ in range(len(b) + 1)] for _ in range(len(a) + 1)
]
for a_idx in range(len(a)):
for b_idx in range(len(b)):
if a[a_idx] == b[b_idx]:
lcs_table[a_idx + 1][b_idx + 1] = 1 + lcs_table[a_idx][b_idx]
else:
lcs_table[a_idx + 1][b_idx + 1] = max(
lcs_table[a_idx + 1][b_idx], lcs_table[a_idx][b_idx + 1]
)
return lcs_table[-1][-1]
def get_step_metrics(
expect: Step, expected_lines: List[int], step_lines: List[int]
) -> Dict[str, Metric]:
"""Given an Expect node with its expected values and a list of all matches for that Expect in a debugger session,
returns the computed metrics for that Expect node."""
expected_line_set = set(expected_lines)
actual_line_set = set(step_lines)
total_line_steps = len(step_lines)
if expect.kind == "exactly" or expect.kind == "at_least":
num_matching_steps = lcs_len(expected_lines, step_lines)
# Inefficient, but not to the point that we care!
num_matching_steps_ignoring_order = lcs_len(
sorted(expected_lines), sorted(step_lines)
)
max_possible_correct_line_steps = len(expected_lines)
correct_line_steps = num_matching_steps
misordered_line_steps = num_matching_steps_ignoring_order - num_matching_steps
missing_lines = len(expected_line_set - actual_line_set)
if expect.kind == "exactly":
incorrect_line_steps = total_line_steps - correct_line_steps
unexpected_lines = len(actual_line_set - expected_line_set)
else:
# For `!step at_least` there are no "incorrect" or "unexpected" lines, since we explicitly ignore seen lines
# outside of the expected lines.
incorrect_line_steps = 0
unexpected_lines = 0
else:
assert expect.kind == "never"
max_possible_correct_line_steps = total_line_steps
correct_line_steps = sum(
1 for line in step_lines if line not in expected_line_set
)
incorrect_line_steps = total_line_steps - correct_line_steps
unexpected_lines = len(actual_line_set.intersection(expected_line_set))
# For `!step never` there are no "missing" or "misordered" lines, since we only declare lines we *don't* want to
# see.
missing_lines = 0
misordered_line_steps = 0
metrics: Dict[str, Metric] = {
"total_line_steps": ScalarMetric(total_line_steps),
"correct_line_steps": ScalarMetric(correct_line_steps),
"correct_line_score": FractionMetric(
correct_line_steps, max_possible_correct_line_steps
),
"misordered_line_steps": ScalarMetric(
misordered_line_steps, improves_asc=False
),
"missing_lines": ScalarMetric(missing_lines, improves_asc=False),
"incorrect_line_steps": ScalarMetric(incorrect_line_steps, improves_asc=False),
"unexpected_lines": ScalarMetric(unexpected_lines, improves_asc=False),
}
return metrics

View file

@ -11,7 +11,7 @@
# aggregated, and each individual metric can be expressed in a scalar form that is considered "better" as it either
# ascends or descends.
from collections import defaultdict
from typing import Any, Dict, List, Tuple
from typing import Any, Dict, List, Optional, Tuple
from dex.dextIR import DextIR, StepIR
from dex.evaluation.ExpectMatch import (
@ -22,12 +22,13 @@ from dex.evaluation.ExpectMatch import (
)
from dex.evaluation.Metrics import (
Metric,
get_step_metrics,
get_variable_metrics,
serialize_metric_to_json,
)
from dex.evaluation.StateMatch import StateMatchContext, get_active_where_matches
from dex.test_script import DexterScript, Scope
from dex.test_script.Nodes import Expect, Value
from dex.test_script.Nodes import Expect, Line, Step, Value
class DebuggerStepMatch:
"""Class used to record the match between a DexterScript and a StepIR, including the state match, determining which
@ -50,18 +51,25 @@ class DebuggerStepMatch:
for where_match in self.state_match.values()
for expect in where_match.active_expects
}
self.expect_matches: Dict[Expect, DebuggerExpectMatch] = {}
self.var_expect_matches: Dict[Expect, DebuggerExpectMatch] = {}
self.step_expect_matches: Dict[Step, int] = {}
def add_expected_values(expect: Expect, expected_value: Any, scope: Scope):
assert isinstance(expect, Value), "Non-Value expects currently unsupported"
if expect in expects_to_match:
expect_frame_idx = expects_to_match[expect]
self.expect_matches[expect] = get_expect_match(
expect,
expected_value,
step.frames[expect_frame_idx].watches[expect.get_watched_expr()],
self.match_context,
)
if expect not in expects_to_match:
return
expect_frame_idx = expects_to_match[expect]
if isinstance(expect, Step):
self.step_expect_matches[expect] = step.frames[
expect_frame_idx
].loc.lineno
return
assert isinstance(expect, Value), f"Unexpected expect node kind {expect}"
self.var_expect_matches[expect] = get_expect_match(
expect,
expected_value,
step.frames[expect_frame_idx].watches[expect.get_watched_expr()],
self.match_context,
)
script.visit_script(visit_expect=add_expected_values)
@ -79,20 +87,24 @@ class DebuggerRunMatch(object):
self.dext_ir = dext_ir
self.metrics: Dict[str, Metric] = {}
self.step_matches: List[DebuggerStepMatch] = []
self.per_expect_results: Dict[
self.per_var_expect_results: Dict[
Expect, list[Tuple[int, DebuggerExpectMatch]]
] = {}
self.per_step_expect_results: Dict[Step, list[Tuple[int, int]]] = {}
script = self.dext_ir.script
assert script is not None, "Trying to evaluate DextIR without attached script?"
# Gather the expected values for each Expect.
expected_values = {}
self.expected_values = {}
def add_expected_values(expect: Expect, expected_value: Any, scope: Scope):
assert isinstance(expect, Value), "Non-Value expects currently unsupported"
expected_values[expect] = expected_value
self.per_expect_results[expect] = []
self.expected_values[expect] = expected_value
if isinstance(expect, Value):
self.per_var_expect_results[expect] = []
return
assert isinstance(expect, Step), f"Unexpected expect node kind {expect}"
self.per_step_expect_results[expect] = []
script.visit_script(visit_expect=add_expected_values)
@ -105,16 +117,39 @@ class DebuggerRunMatch(object):
# Then, for each expect, produce the list of results for just that variable.
for step_match in self.step_matches:
for expect, expect_match in step_match.expect_matches.items():
self.per_expect_results[expect].append(
for step_expect, line in step_match.step_expect_matches.items():
self.per_step_expect_results[step_expect].append(
(step_match.step.step_index, line)
)
for expect, expect_match in step_match.var_expect_matches.items():
self.per_var_expect_results[expect].append(
(step_match.step.step_index, expect_match)
)
# For !steps, once we know the file that they are in, we apply any labels.
for step_expect, step_results in self.per_step_expect_results.items():
if not step_results:
# We may not be able to resolve any !labels in the expected value list if the Expect was never active;
# as a workaround, just set any integers here - the result will be the same.
self.expected_values[step_expect] = [
0 for l in self.expected_values[step_expect]
]
continue
active_path = self.dext_ir.steps[0].current_location.path
assert all(
self.dext_ir.steps[step_index].current_location.path == active_path
for step_index, line in step_results[1:]
), "!step node unexpectedly active over multiple files"
path_labels = script.get_labels(active_path)
self.expected_values[step_expect] = [
Line(l).to_line(path_labels) for l in self.expected_values[step_expect]
]
# Finally, compare the match results against the expected values to produce the metrics.
for expect, expect_results in self.per_expect_results.items():
for expect, expect_results in self.per_var_expect_results.items():
expect_matches = [match for step, match in expect_results]
expect_metrics = get_variable_metrics(
expect, expected_values[expect], expect_matches
expect, self.expected_values[expect], expect_matches
)
for metric_name, metric in expect_metrics.items():
if metric_name not in self.metrics:
@ -123,6 +158,18 @@ class DebuggerRunMatch(object):
self.metrics[metric_name] = self.metrics[metric_name].aggregate(
metric
)
for expect, lines in self.per_step_expect_results.items():
actual_lines = [lines for step, lines in lines]
step_metrics = get_step_metrics(
expect, self.expected_values[expect], actual_lines
)
for metric_name, metric in step_metrics.items():
if metric_name not in self.metrics:
self.metrics[metric_name] = metric
else:
self.metrics[metric_name] = self.metrics[metric_name].aggregate(
metric
)
def dump_step_results(self) -> str:
result = ""
@ -145,23 +192,41 @@ class DebuggerRunMatch(object):
result += f" Active !where nodes:\n"
for frame_idx, wheres in frame_active_wheres_list:
result += f" Frame {frame_idx}: [{', '.join(wheres)}]\n"
if not step_match.expect_matches:
if not step_match.var_expect_matches and not step_match.step_expect_matches:
continue
result += f" Active !expect nodes:\n"
matching_expects = [
(expect, match)
for expect, match in step_match.expect_matches.items()
(expect, match.short_str())
for expect, match in step_match.var_expect_matches.items()
if match.match_result == MatchResult.TRUE
]
non_matching_expects = [
(expect, match)
for expect, match in step_match.expect_matches.items()
(expect, match.short_str())
for expect, match in step_match.var_expect_matches.items()
if match.match_result != MatchResult.TRUE
]
def step_expect_matches(expect: Step, step_line: int) -> bool:
expected_lines = self.expected_values[expect]
assert isinstance(expected_lines, list) and all(
isinstance(l, int) for l in expected_lines
)
step_line_in_expected_list = step_line in expected_lines
if expect.kind == "never":
return not step_line_in_expected_list
return step_line_in_expected_list
for step_expect, step_line in step_match.step_expect_matches.items():
list_to_append = (
matching_expects
if step_expect_matches(step_expect, step_line)
else non_matching_expects
)
list_to_append.append((step_expect, str(step_line)))
if matching_expects:
result += f" Matching nodes: [{', '.join(f'{expect}={match.short_str()}' for expect, match in matching_expects)}]\n"
result += f" Matching nodes: [{', '.join(f'{expect}={match}' for expect, match in matching_expects)}]\n"
if non_matching_expects:
result += f" Non-matching nodes: [{', '.join(f'{expect}={match.short_str()}' for expect, match in non_matching_expects)}]\n"
result += f" Non-matching nodes: [{', '.join(f'{expect}={match}' for expect, match in non_matching_expects)}]\n"
return result
def get_metric_output(self):

View file

@ -18,7 +18,7 @@ from dex.utils.Exceptions import Error
def setup_yaml_parser(loader):
reg_classes = [Where, Value, DexRange, Label, Then, Address, ValueAll]
reg_classes = [Where, Value, DexRange, Label, Then, Address, ValueAll, Step]
for c in reg_classes:
c.register_yaml(loader)
@ -231,6 +231,37 @@ class ValueAll(Expect):
yaml.add_representer(ValueAll, ValueAll.representer)
class Step(Expect):
"""Sets an expectation for stepping behaviour, with the expected value being a list of integer lines:
- !step exactly: while this !expect is active, we expect see exactly the expected lines in-order as many times as
they appear in the expected lines list.
- !step at_least: while this !expect is active, we expect to see each of the expected lines in-order at least as many
times as they appear in the expected list, ignoring excess lines and lines not in the expected lines list.
- !step never: while this !expect is active, we expect to not see any of the lines in the expected lines list.
"""
def __init__(self, kind: str):
self.kind = kind
if kind not in ["exactly", "at_least", "never"]:
raise DexterNodeError(self, f'invalid !step kind "{self.kind}"')
def __repr__(self):
return f"Step({self.kind})"
@staticmethod
def constructor(loader: yaml.Loader, node):
return Step(loader.construct_scalar(node))
@staticmethod
def representer(dumper, data):
return dumper.represent_scalar("!step", data.kind)
@staticmethod
def register_yaml(loader):
yaml.add_constructor("!step", Step.constructor, loader)
yaml.add_representer(Step, Step.representer)
##############
## Execution Nodes: Can appear as leaf nodes directly under a state node to perform debugger actions when they become
## active, to advance the debugger state.

View file

@ -19,9 +19,11 @@ import yaml
from dex.test_script.Nodes import (
Expect,
FileLabels,
Label,
Where,
Then,
ValueAll,
Step,
setup_yaml_parser,
)
@ -186,6 +188,16 @@ class DexterScript:
raise DexterScriptError(
f"!expect/all node {expect} should not have an expected value."
)
if isinstance(expect, Step):
if expected_value is None:
raise DexterScriptError(f"rewriting !step nodes not yet supported.")
if not (
isinstance(expected_value, list)
and all(isinstance(l, (int, Label)) for l in expected_value)
):
raise DexterScriptError(
f"Expected value for !step node {expect} must be list of integers"
)
def validate_where(where: Where, scope: Scope):
if where.is_and and not scope.where:

View file

@ -0,0 +1,69 @@
// RUN: %dexter_regression_test_cxx_build %s -o %t
// RUN: %dexter_regression_test_run --use-script --binary %t -- %s \
// RUN: | FileCheck %s
// Test evaluation of !step nodes in Dexter.
// CHECK: total_line_steps: 15
// CHECK: correct_line_steps: 11
// CHECK: correct_line_score: 73.3% (11/15)
// CHECK: misordered_line_steps: 2
// CHECK: missing_lines: 1
// CHECK: incorrect_line_steps: 2
// CHECK: unexpected_lines: 2
// We want some janky formatting for the sake of this test.
// clang-format off
int doublify(int N) { return N * 2; }
int stepBackwards(int N) {
return doublify( // !dex_label rbegin
doublify(
doublify(N))); // !dex_label rend
}
void reportError() {}
void pleasePassTrue(bool ShouldDefinitelyBeTrue = true) {
if (!ShouldDefinitelyBeTrue) // !dex_label error_check
reportError();
}
int factorial(int N) {
int Result = 1; // !dex_label fac_start
for (int I = N; I-- > 0;) {
Result *= I;
}
if (Result > 0)
return Result;
return 0; // !dex_label fac_end
}
int main() {
stepBackwards(10);
pleasePassTrue(false);
factorial(3);
return 0;
}
// clang-format on
/*
---
!where {lines: !range [!label rbegin, !label rend]}:
# Actual stepping order is reversed.
# 3 steps, 1/3 correct, 2 misordered.
!step at_least: [!label rbegin, !label rbegin + 1, !label rbegin + 2]
!where {lines: !range [!label error_check, !label error_check + 1]}:
# "Never" line is stepped on.
# 2 steps, 1/2 correct, 1 incorrect, 1 unexpected
!step never: [!label error_check + 1]
!where {lines: !range [!label fac_start, !label fac_end]}:
# Loop iterates 4 times instead of 3, and exits on line 39 instead of 38.
# 10 steps, 9/10 correct, 1 incorrect, 1 unexpected, 1 missing
!step exactly: [!label fac_start,
!label fac_start + 1, !label fac_start + 2,
!label fac_start + 1, !label fac_start + 2,
!label fac_start + 1, !label fac_start + 2,
!label fac_start + 1, !label fac_start + 4, !label fac_start + 5]
...
*/

View file

@ -0,0 +1,46 @@
// RUN: %dexter_regression_test_cxx_build %s -o %t
// RUN: %dexter_regression_test_run --use-script --binary %t -- %s \
// RUN: | FileCheck %s
// Test evaluation of !step nodes in Dexter.
// CHECK: total_line_steps: 150
// CHECK: correct_line_steps: 105
// CHECK: correct_line_score: 100.0% (105/105)
// CHECK: misordered_line_steps: 0
// CHECK: missing_lines: 0
// CHECK: incorrect_line_steps: 0
// CHECK: unexpected_lines: 0
void fizz() {}
void buzz() {}
void fizzbuzz() {}
void doFizzbuzz(int N) {
for (int I = 1; I <= N; ++I) {
if (I % 3 == 0) {
if (I % 5 == 0)
fizzbuzz();
else
fizz();
} else if (I % 5 == 0) {
buzz();
}
}
}
int main() {
doFizzbuzz(10);
return 0;
}
/*
---
!where {function: doFizzbuzz}:
!step exactly: [20, 21, 26, 29, 20, 21, 26, 29, 20, 21, 22, 25, 26, 29, 20,
21, 26, 29, 20, 21, 26, 27, 29, 20, 21, 22, 25, 26, 29, 20, 21, 26, 29, 20,
21, 26, 29, 20, 21, 22, 25, 26, 29, 20, 21, 26, 27, 29, 20, 30]
!step at_least: [25, 27, 25, 25, 27]
!step never: [23]
...
*/

View file

@ -0,0 +1,19 @@
RUN: not %dexter_regression_test_run --binary %s --use-script --skip-run -- %s 2>&1 | FileCheck %s
Tests that !step expected values must not be anything other than a list of integers.
CHECK: No valid Dexter script found in file
CHECK: Script starting line [[# @LINE + 2]]:
CHECK: Expected value for !step node Step(at_least) must be list of integers
---
!where {function: foo}:
!step at_least: 12
...
CHECK: Script starting line [[# @LINE + 2]]:
CHECK: Expected value for !step node Step(at_least) must be list of integers
---
!where {function: foo}:
!step at_least: ["0", "1"]
...