mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat(defect-identity): count distinct defects across stored runs
a property reports at most once per run, so a run-level count is just the number of properties violated. a defect is identified across runs by the property, the action attributed as the origin of the failed obligation and the screen the witness observed.
This commit is contained in:
1 parent
11fca22d36
commit
a0a8c9c710
3 files changed
+475
No files matched your search
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
"github.com/priyanshujain/sanderling/internal/tracecorpus"
|
||||
)
|
||||
|
||||
// Instance is one defect as the draft identifies it across runs: the property
|
||||
// that reported, the action attributed as the origin of the failed obligation,
|
||||
// and the screen the witness observed. Two reports sharing all three are the
|
||||
// same defect seen twice; a run-level count of violated properties cannot say
|
||||
// that.
|
||||
type Instance struct {
|
||||
Property string `json:"property"`
|
||||
OriginAction string `json:"origin_action"`
|
||||
WitnessScreen string `json:"witness_screen"`
|
||||
Runs []string `json:"runs"`
|
||||
Seeds []int64 `json:"seeds"`
|
||||
// Reports counts violations folded into this instance, which exceeds the
|
||||
// run count only if one run reported the same property twice, and the
|
||||
// latch says it cannot.
|
||||
Reports int `json:"reports"`
|
||||
}
|
||||
|
||||
// Unattributed is a violation that carries no origin, so the identity rule
|
||||
// cannot be applied to it. It is reported rather than counted, because
|
||||
// dropping it understates the defect count and guessing an origin invents one.
|
||||
type Unattributed struct {
|
||||
Property string `json:"property"`
|
||||
Run string `json:"run"`
|
||||
Step int `json:"step"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// actionKeyMode selects how much of an action two reports must share to be the
|
||||
// same origin. The draft names the origin action and does not say which of its
|
||||
// fields identify it, so the strict reading is available beside the default.
|
||||
type actionKeyMode string
|
||||
|
||||
const (
|
||||
// bySelector keys an action by what it did and the name of what it did it
|
||||
// to. Coordinates and generated text differ between two runs that took the
|
||||
// same action against the same control.
|
||||
bySelector actionKeyMode = "selector"
|
||||
// byFullAction adds the text typed and the coordinates dispatched.
|
||||
byFullAction actionKeyMode = "full"
|
||||
)
|
||||
|
||||
type identityKey struct {
|
||||
property string
|
||||
action string
|
||||
screen string
|
||||
}
|
||||
|
||||
// Corpus is what one invocation read: the instances, the violations it could
|
||||
// not attribute, and the counts the report needs.
|
||||
type Corpus struct {
|
||||
Runs int `json:"runs"`
|
||||
Instances []Instance `json:"instances"`
|
||||
Unattributed []Unattributed `json:"unattributed,omitempty"`
|
||||
UnnamedScreen int `json:"unnamed_witness_screens,omitempty"`
|
||||
}
|
||||
|
||||
// Singletons counts instances that appeared in exactly one run, the number the
|
||||
// evaluation reports beside the defect count.
|
||||
func (c Corpus) Singletons() int {
|
||||
count := 0
|
||||
for _, instance := range c.Instances {
|
||||
if len(instance.Runs) == 1 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func identify(runs []tracecorpus.Run, mode actionKeyMode) (Corpus, error) {
|
||||
corpus := Corpus{Runs: len(runs)}
|
||||
byKey := map[identityKey]*Instance{}
|
||||
var order []identityKey
|
||||
for _, run := range runs {
|
||||
steps := index(run.Steps)
|
||||
for _, step := range run.Steps {
|
||||
for _, property := range step.Violations {
|
||||
witness, ok := step.Witnesses[property]
|
||||
if !ok || witness.Step == 0 {
|
||||
corpus.Unattributed = append(corpus.Unattributed, Unattributed{
|
||||
Property: property,
|
||||
Run: run.Directory,
|
||||
Step: step.Index,
|
||||
Reason: attributionGap(ok),
|
||||
})
|
||||
continue
|
||||
}
|
||||
origin, ok := steps[witness.Step]
|
||||
if !ok {
|
||||
return Corpus{}, fmt.Errorf(
|
||||
"%s: %s names origin step %d, which the trace does not hold",
|
||||
run.Directory, property, witness.Step,
|
||||
)
|
||||
}
|
||||
detected, ok := steps[witness.DetectedStep]
|
||||
if !ok {
|
||||
return Corpus{}, fmt.Errorf(
|
||||
"%s: %s names detection step %d, which the trace does not hold",
|
||||
run.Directory, property, witness.DetectedStep,
|
||||
)
|
||||
}
|
||||
if detected.Screen == "" {
|
||||
corpus.UnnamedScreen++
|
||||
}
|
||||
key := identityKey{
|
||||
property: property,
|
||||
action: actionKey(origin, mode),
|
||||
screen: detected.Screen,
|
||||
}
|
||||
instance, seen := byKey[key]
|
||||
if !seen {
|
||||
instance = &Instance{
|
||||
Property: property,
|
||||
OriginAction: key.action,
|
||||
WitnessScreen: key.screen,
|
||||
}
|
||||
byKey[key] = instance
|
||||
order = append(order, key)
|
||||
}
|
||||
instance.Reports++
|
||||
if len(instance.Runs) == 0 ||
|
||||
instance.Runs[len(instance.Runs)-1] != run.Directory {
|
||||
instance.Runs = append(instance.Runs, run.Directory)
|
||||
instance.Seeds = append(instance.Seeds, run.Meta.Seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range order {
|
||||
corpus.Instances = append(corpus.Instances, *byKey[key])
|
||||
}
|
||||
sort.SliceStable(corpus.Instances, func(i, j int) bool {
|
||||
if corpus.Instances[i].Property != corpus.Instances[j].Property {
|
||||
return corpus.Instances[i].Property < corpus.Instances[j].Property
|
||||
}
|
||||
return corpus.Instances[i].OriginAction < corpus.Instances[j].OriginAction
|
||||
})
|
||||
return corpus, nil
|
||||
}
|
||||
|
||||
func attributionGap(hasWitness bool) string {
|
||||
if !hasWitness {
|
||||
return "no witness recorded"
|
||||
}
|
||||
return "witness records no origin step"
|
||||
}
|
||||
|
||||
func index(steps []trace.Step) map[int]trace.Step {
|
||||
byIndex := make(map[int]trace.Step, len(steps))
|
||||
for _, step := range steps {
|
||||
byIndex[step.Index] = step
|
||||
}
|
||||
return byIndex
|
||||
}
|
||||
|
||||
// actionKey renders the action the origin step chose. The action recorded on a
|
||||
// line is the one applied after observing it, which is the alignment that
|
||||
// makes an origin index name an action at all.
|
||||
func actionKey(origin trace.Step, mode actionKeyMode) string {
|
||||
if origin.NextAction == nil {
|
||||
return "none"
|
||||
}
|
||||
if origin.ActionSkipped != "" {
|
||||
return "none (" + origin.ActionSkipped + ")"
|
||||
}
|
||||
action := *origin.NextAction
|
||||
key := action.Kind
|
||||
switch {
|
||||
case action.Selector != "":
|
||||
key += " " + action.Selector
|
||||
case action.Key != "":
|
||||
key += " " + action.Key
|
||||
case action.X != 0 || action.Y != 0 || action.ToX != 0 || action.ToY != 0:
|
||||
key += fmt.Sprintf(" (%d,%d)", action.X, action.Y)
|
||||
}
|
||||
if mode != byFullAction {
|
||||
return key
|
||||
}
|
||||
return fmt.Sprintf("%s text=%q at=(%d,%d)->(%d,%d)",
|
||||
key, action.Text, action.X, action.Y, action.ToX, action.ToY)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
"github.com/priyanshujain/sanderling/internal/tracecorpus"
|
||||
)
|
||||
|
||||
// TestOneDefectSeenTwiceIsOneInstance: two runs report the same property from
|
||||
// the same origin action on the same screen, which is one defect found twice
|
||||
// and two properties violated.
|
||||
func TestOneDefectSeenTwiceIsOneInstance(t *testing.T) {
|
||||
first := run(t, 3,
|
||||
step(1, "/ledger", tap("id:add-txn")),
|
||||
violating(2, "/accounts/7", tap("id:save"), "balanceMatches", 2, 2),
|
||||
)
|
||||
second := run(t, 5,
|
||||
step(1, "/ledger", tap("id:add-txn")),
|
||||
violating(2, "/accounts/7", tap("id:save"), "balanceMatches", 2, 2),
|
||||
)
|
||||
|
||||
corpus := identified(t, bySelector, first, second)
|
||||
if len(corpus.Instances) != 1 {
|
||||
t.Fatalf("instances = %d, want 1: %+v", len(corpus.Instances), corpus.Instances)
|
||||
}
|
||||
instance := corpus.Instances[0]
|
||||
if len(instance.Runs) != 2 || instance.Reports != 2 {
|
||||
t.Fatalf("instance = %+v, want two runs reporting it", instance)
|
||||
}
|
||||
if corpus.Singletons() != 0 {
|
||||
t.Fatalf("singletons = %d, want 0", corpus.Singletons())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSamePropertyFromTwoOriginsIsTwoDefects(t *testing.T) {
|
||||
first := run(t, 3, violating(1, "/ledger", tap("id:save"), "balanceMatches", 1, 1))
|
||||
second := run(t, 5, violating(1, "/ledger", tap("id:delete"), "balanceMatches", 1, 1))
|
||||
|
||||
corpus := identified(t, bySelector, first, second)
|
||||
if len(corpus.Instances) != 2 {
|
||||
t.Fatalf("instances = %d, want 2: %+v", len(corpus.Instances), corpus.Instances)
|
||||
}
|
||||
if corpus.Singletons() != 2 {
|
||||
t.Fatalf("singletons = %d, want 2", corpus.Singletons())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSamePropertyOnTwoScreensIsTwoDefects(t *testing.T) {
|
||||
first := run(t, 3, violating(1, "/ledger", tap("id:save"), "balanceMatches", 1, 1))
|
||||
second := run(t, 5, violating(1, "/home", tap("id:save"), "balanceMatches", 1, 1))
|
||||
|
||||
corpus := identified(t, bySelector, first, second)
|
||||
if len(corpus.Instances) != 2 {
|
||||
t.Fatalf("instances = %d, want 2: %+v", len(corpus.Instances), corpus.Instances)
|
||||
}
|
||||
}
|
||||
|
||||
// TestADeferredViolationTakesTheActionOnItsOriginLine holds the alignment the
|
||||
// draft states: the action recorded on line k is the one applied after
|
||||
// observing k, so an obligation armed at k is attributed to that action and
|
||||
// witnessed on the screen the detection step observed.
|
||||
func TestADeferredViolationTakesTheActionOnItsOriginLine(t *testing.T) {
|
||||
only := run(t, 3,
|
||||
step(1, "/login", tap("id:login-submit")),
|
||||
step(2, "/home", tap("id:open-ledger")),
|
||||
violating(3, "/ledger", tap("id:add-txn"), "landsOnLedger", 2, 3),
|
||||
)
|
||||
|
||||
corpus := identified(t, bySelector, only)
|
||||
instance := corpus.Instances[0]
|
||||
if instance.OriginAction != "Tap id:open-ledger" {
|
||||
t.Fatalf("origin action = %q, want the action on the origin line", instance.OriginAction)
|
||||
}
|
||||
if instance.WitnessScreen != "/ledger" {
|
||||
t.Fatalf("witness screen = %q, want the detection step's screen", instance.WitnessScreen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAViolationWithNoWitnessIsReportedNotCounted(t *testing.T) {
|
||||
unattributed := trace.Step{
|
||||
Index: 1,
|
||||
Screen: "/ledger",
|
||||
Violations: []string{"balanceMatches"},
|
||||
}
|
||||
corpus := identified(t, bySelector, run(t, 3, unattributed))
|
||||
|
||||
if len(corpus.Instances) != 0 {
|
||||
t.Fatalf("instances = %d, want 0: %+v", len(corpus.Instances), corpus.Instances)
|
||||
}
|
||||
if len(corpus.Unattributed) != 1 ||
|
||||
corpus.Unattributed[0].Property != "balanceMatches" {
|
||||
t.Fatalf("unattributed = %+v, want the one violation with no origin", corpus.Unattributed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheStrictActionKeySplitsWhatTheSelectorKeyMerges quantifies the reading
|
||||
// the draft leaves open: two runs that typed different text into the same
|
||||
// field are one defect by selector and two by the whole action.
|
||||
func TestTheStrictActionKeySplitsWhatTheSelectorKeyMerges(t *testing.T) {
|
||||
first := run(t, 3, violating(1, "/ledger", typing("id:amount", "12"), "balanceMatches", 1, 1))
|
||||
second := run(t, 5, violating(1, "/ledger", typing("id:amount", "9000"), "balanceMatches", 1, 1))
|
||||
|
||||
if got := identified(t, bySelector, first, second); len(got.Instances) != 1 {
|
||||
t.Fatalf("by selector: instances = %d, want 1", len(got.Instances))
|
||||
}
|
||||
if got := identified(t, byFullAction, first, second); len(got.Instances) != 2 {
|
||||
t.Fatalf("by full action: instances = %d, want 2", len(got.Instances))
|
||||
}
|
||||
}
|
||||
|
||||
func identified(t *testing.T, mode actionKeyMode, runs ...tracecorpus.Run) Corpus {
|
||||
t.Helper()
|
||||
corpus, err := identify(runs, mode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return corpus
|
||||
}
|
||||
|
||||
func run(t *testing.T, seed int64, steps ...trace.Step) tracecorpus.Run {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
writer, err := trace.NewWriter(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.WriteMeta(trace.Meta{Seed: seed, Platform: "web"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, step := range steps {
|
||||
if err := writer.WriteStep(step); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := tracecorpus.Load(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
func step(index int, screen string, action *trace.Action) trace.Step {
|
||||
return trace.Step{Index: index, Screen: screen, NextAction: action}
|
||||
}
|
||||
|
||||
func violating(
|
||||
index int,
|
||||
screen string,
|
||||
action *trace.Action,
|
||||
property string,
|
||||
origin int,
|
||||
detected int,
|
||||
) trace.Step {
|
||||
violated := step(index, screen, action)
|
||||
violated.Violations = []string{property}
|
||||
violated.Witnesses = map[string]trace.Witness{
|
||||
property: {Reason: "predicate false", Step: origin, DetectedStep: detected},
|
||||
}
|
||||
return violated
|
||||
}
|
||||
|
||||
func tap(selector string) *trace.Action {
|
||||
return &trace.Action{Kind: "Tap", Selector: selector, X: 10, Y: 20}
|
||||
}
|
||||
|
||||
func typing(selector string, text string) *trace.Action {
|
||||
return &trace.Action{Kind: "InputText", Selector: selector, Text: text, X: 10, Y: 20}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Command defect-identity counts distinct defects across stored runs. A
|
||||
// property reports at most once per run, so a run-level count is the number of
|
||||
// properties violated; a defect is identified across runs by the property, the
|
||||
// action attributed as the origin of the failed obligation, and the screen the
|
||||
// witness observed.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/tracecorpus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
jsonOut := flag.Bool("json", false, "emit JSON instead of a table")
|
||||
mode := flag.String(
|
||||
"action-key",
|
||||
string(bySelector),
|
||||
"how much of the origin action identifies it: selector or full",
|
||||
)
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintln(
|
||||
os.Stderr,
|
||||
"usage: defect-identity [--json] [--action-key selector|full] <run directory> ...",
|
||||
)
|
||||
}
|
||||
flag.Parse()
|
||||
if flag.NArg() == 0 {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if *mode != string(bySelector) && *mode != string(byFullAction) {
|
||||
fmt.Fprintf(os.Stderr, "unknown --action-key %q\n", *mode)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
runs, err := loadAll(flag.Args())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "no run directory found under the given paths")
|
||||
os.Exit(1)
|
||||
}
|
||||
corpus, err := identify(runs, actionKeyMode(*mode))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *jsonOut {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(corpus); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
render(os.Stdout, corpus)
|
||||
}
|
||||
|
||||
func loadAll(paths []string) ([]tracecorpus.Run, error) {
|
||||
var runs []tracecorpus.Run
|
||||
for _, path := range paths {
|
||||
directories, err := tracecorpus.Discover(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, directory := range directories {
|
||||
run, err := tracecorpus.Load(directory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", directory, err)
|
||||
}
|
||||
runs = append(runs, run)
|
||||
}
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
func render(out io.Writer, corpus Corpus) {
|
||||
writer := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(writer, "property\torigin action\twitness screen\truns\tseeds")
|
||||
for _, instance := range corpus.Instances {
|
||||
screen := instance.WitnessScreen
|
||||
if screen == "" {
|
||||
screen = "(unnamed)"
|
||||
}
|
||||
fmt.Fprintf(writer, "%s\t%s\t%s\t%d\t%v\n",
|
||||
instance.Property, instance.OriginAction, screen,
|
||||
len(instance.Runs), instance.Seeds)
|
||||
}
|
||||
writer.Flush()
|
||||
|
||||
fmt.Fprintf(out, "\n%d distinct defect(s) over %d run(s); %d seen in exactly one run\n",
|
||||
len(corpus.Instances), corpus.Runs, corpus.Singletons())
|
||||
if corpus.UnnamedScreen > 0 {
|
||||
fmt.Fprintf(out,
|
||||
"%d violation(s) witnessed on a screen the app does not name, "+
|
||||
"so identity rests on property and origin action alone for those\n",
|
||||
corpus.UnnamedScreen)
|
||||
}
|
||||
for _, gap := range corpus.Unattributed {
|
||||
fmt.Fprintf(out, "unattributed: %s at step %d of %s (%s)\n",
|
||||
gap.Property, gap.Step, gap.Run, gap.Reason)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user