mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat(verifier): goja runtime hosting the spec API
Installs globalThis.__uatu__ with extract, always, actions, weighted, tap, inputText, and stub taps/swipes. Load runs the bundled spec, then pulls properties + actions out of globalThis. PushSnapshot rebuilds state.snapshots and refreshes every extractor handle's current/previous in registration order so chained extractors observe up-to-date values. Properties are wired through internal/ltl as Always(Thunk(...)), so verdicts latch to violated as soon as a predicate returns false. NextAction resolves actions/weighted recursively with a seedable rand source for reproducible runs.
This commit is contained in:
1 parent
29c6ea4842
commit
72175bb13c
5 files changed
+501
No files matched your search
@@ -3,12 +3,16 @@ module github.com/priyanshujain/uatu
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||||
github.com/evanw/esbuild v0.28.0
|
github.com/evanw/esbuild v0.28.0
|
||||||
google.golang.org/grpc v1.80.0
|
google.golang.org/grpc v1.80.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||||
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
|
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
|
||||||
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
@@ -39,3 +49,5 @@ google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
|||||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionKindTap ActionKind = "Tap"
|
||||||
|
ActionKindInputText ActionKind = "InputText"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Action struct {
|
||||||
|
Kind ActionKind
|
||||||
|
On string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type extractorState struct {
|
||||||
|
getter goja.Callable
|
||||||
|
handle *goja.Object
|
||||||
|
}
|
||||||
|
|
||||||
|
type formulaState struct {
|
||||||
|
predicate goja.Callable
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tagFormula = "__uatuFormula"
|
||||||
|
tagActionGenerator = "__uatuActionGenerator"
|
||||||
|
tagInternalKind = "__uatuKind"
|
||||||
|
internalKindActions = "actions"
|
||||||
|
internalKindWeighted = "weighted"
|
||||||
|
internalKindBuiltinTaps = "taps"
|
||||||
|
internalKindBuiltinSwipes = "swipes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// installRuntimeBindings exposes globalThis.__uatu__ to the loaded spec.
|
||||||
|
func (v *Verifier) installRuntimeBindings() error {
|
||||||
|
uatu := v.runtime.NewObject()
|
||||||
|
|
||||||
|
if err := uatu.Set("extract", v.bindExtract); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uatu.Set("always", v.bindAlways); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uatu.Set("actions", v.bindActions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uatu.Set("weighted", v.bindWeighted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uatu.Set("tap", v.bindTap); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uatu.Set("inputText", v.bindInputText); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uatu.Set("taps", v.builtinGenerator(internalKindBuiltinTaps)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uatu.Set("swipes", v.builtinGenerator(internalKindBuiltinSwipes)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.runtime.GlobalObject().Set("__uatu__", uatu)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) != 1 {
|
||||||
|
panic(v.runtime.NewTypeError("extract requires exactly one argument"))
|
||||||
|
}
|
||||||
|
getter, ok := goja.AssertFunction(call.Arguments[0])
|
||||||
|
if !ok {
|
||||||
|
panic(v.runtime.NewTypeError("extract argument must be a function"))
|
||||||
|
}
|
||||||
|
|
||||||
|
handle := v.runtime.NewObject()
|
||||||
|
_ = handle.Set("current", goja.Undefined())
|
||||||
|
_ = handle.Set("previous", goja.Undefined())
|
||||||
|
|
||||||
|
v.extractors = append(v.extractors, &extractorState{getter: getter, handle: handle})
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) bindAlways(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) != 1 {
|
||||||
|
panic(v.runtime.NewTypeError("always requires exactly one argument"))
|
||||||
|
}
|
||||||
|
predicate, ok := goja.AssertFunction(call.Arguments[0])
|
||||||
|
if !ok {
|
||||||
|
panic(v.runtime.NewTypeError("always argument must be a function"))
|
||||||
|
}
|
||||||
|
|
||||||
|
formula := &formulaState{predicate: predicate}
|
||||||
|
v.formulas = append(v.formulas, formula)
|
||||||
|
formulaIndex := len(v.formulas) - 1
|
||||||
|
|
||||||
|
handle := v.runtime.NewObject()
|
||||||
|
_ = handle.Set(tagFormula, true)
|
||||||
|
_ = handle.Set("__uatuIndex", formulaIndex)
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) bindActions(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) != 1 {
|
||||||
|
panic(v.runtime.NewTypeError("actions requires a single generator argument"))
|
||||||
|
}
|
||||||
|
if _, ok := goja.AssertFunction(call.Arguments[0]); !ok {
|
||||||
|
panic(v.runtime.NewTypeError("actions argument must be a function"))
|
||||||
|
}
|
||||||
|
handle := v.runtime.NewObject()
|
||||||
|
_ = handle.Set(tagActionGenerator, true)
|
||||||
|
_ = handle.Set(tagInternalKind, internalKindActions)
|
||||||
|
_ = handle.Set("generate", call.Arguments[0])
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) bindWeighted(call goja.FunctionCall) goja.Value {
|
||||||
|
entries := v.runtime.NewArray()
|
||||||
|
for index, argument := range call.Arguments {
|
||||||
|
object := argument.ToObject(v.runtime)
|
||||||
|
if object == nil {
|
||||||
|
panic(v.runtime.NewTypeError(fmt.Sprintf("weighted entry %d must be a [number, generator] tuple", index)))
|
||||||
|
}
|
||||||
|
if err := entries.Set(fmt.Sprintf("%d", index), object); err != nil {
|
||||||
|
panic(v.runtime.NewGoError(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle := v.runtime.NewObject()
|
||||||
|
_ = handle.Set(tagActionGenerator, true)
|
||||||
|
_ = handle.Set(tagInternalKind, internalKindWeighted)
|
||||||
|
_ = handle.Set("entries", entries)
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) bindTap(call goja.FunctionCall) goja.Value {
|
||||||
|
parameters := call.Argument(0).ToObject(v.runtime)
|
||||||
|
if parameters == nil {
|
||||||
|
panic(v.runtime.NewTypeError("Tap requires {on}"))
|
||||||
|
}
|
||||||
|
handle := v.runtime.NewObject()
|
||||||
|
_ = handle.Set("kind", "Tap")
|
||||||
|
_ = handle.Set("on", parameters.Get("on"))
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) bindInputText(call goja.FunctionCall) goja.Value {
|
||||||
|
parameters := call.Argument(0).ToObject(v.runtime)
|
||||||
|
if parameters == nil {
|
||||||
|
panic(v.runtime.NewTypeError("InputText requires {into, text}"))
|
||||||
|
}
|
||||||
|
handle := v.runtime.NewObject()
|
||||||
|
_ = handle.Set("kind", "InputText")
|
||||||
|
_ = handle.Set("into", parameters.Get("into"))
|
||||||
|
_ = handle.Set("text", parameters.Get("text"))
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) builtinGenerator(kind string) *goja.Object {
|
||||||
|
handle := v.runtime.NewObject()
|
||||||
|
_ = handle.Set(tagActionGenerator, true)
|
||||||
|
_ = handle.Set(tagInternalKind, kind)
|
||||||
|
return handle
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshots is the per-step extractor output forwarded by the SDK.
|
||||||
|
type Snapshots map[string]json.RawMessage
|
||||||
|
|
||||||
|
// stateObject builds a JS-side `{ snapshots, ax }` matching the State type
|
||||||
|
// from pkg/spec-api. ax is currently a stub returning nothing.
|
||||||
|
func stateObject(runtime *goja.Runtime, snapshots Snapshots) (*goja.Object, error) {
|
||||||
|
state := runtime.NewObject()
|
||||||
|
snapshotsObject := runtime.NewObject()
|
||||||
|
for key, raw := range snapshots {
|
||||||
|
value, err := jsonToJSValue(runtime, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot %q: %w", key, err)
|
||||||
|
}
|
||||||
|
if err := snapshotsObject.Set(key, value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := state.Set("snapshots", snapshotsObject); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessibility := runtime.NewObject()
|
||||||
|
if err := accessibility.Set("find", runtime.ToValue(func(string) goja.Value { return goja.Undefined() })); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := accessibility.Set("findAll", runtime.ToValue(func(string) []goja.Value { return nil })); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := state.Set("ax", accessibility); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return goja.Undefined(), nil
|
||||||
|
}
|
||||||
|
var generic any
|
||||||
|
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode JSON: %w", err)
|
||||||
|
}
|
||||||
|
return runtime.ToValue(generic), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsValueToAction converts a JS-side {kind, on?, into?, text?} into a Go Action.
|
||||||
|
func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
|
||||||
|
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
||||||
|
return Action{}, fmt.Errorf("nil action")
|
||||||
|
}
|
||||||
|
object := value.ToObject(runtime)
|
||||||
|
kindValue := object.Get("kind")
|
||||||
|
if kindValue == nil {
|
||||||
|
return Action{}, fmt.Errorf("action missing kind")
|
||||||
|
}
|
||||||
|
kind := kindValue.String()
|
||||||
|
switch kind {
|
||||||
|
case "Tap":
|
||||||
|
on := object.Get("on")
|
||||||
|
return Action{Kind: ActionKindTap, On: stringOf(on)}, nil
|
||||||
|
case "InputText":
|
||||||
|
into := object.Get("into")
|
||||||
|
text := object.Get("text")
|
||||||
|
return Action{Kind: ActionKindInputText, On: stringOf(into), Text: stringOf(text)}, nil
|
||||||
|
default:
|
||||||
|
return Action{}, fmt.Errorf("unknown action kind %q", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringOf(value goja.Value) string {
|
||||||
|
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/uatu/internal/ltl"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Verifier struct {
|
||||||
|
runtime *goja.Runtime
|
||||||
|
extractors []*extractorState
|
||||||
|
formulas []*formulaState
|
||||||
|
|
||||||
|
properties map[string]int // property name -> formula index
|
||||||
|
actionGenerator goja.Value
|
||||||
|
|
||||||
|
evaluators map[string]*ltl.Evaluator
|
||||||
|
|
||||||
|
rng *rand.Rand
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Verifier)
|
||||||
|
|
||||||
|
func WithRand(rng *rand.Rand) Option {
|
||||||
|
return func(v *Verifier) { v.rng = rng }
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(options ...Option) (*Verifier, error) {
|
||||||
|
verifier := &Verifier{
|
||||||
|
runtime: goja.New(),
|
||||||
|
properties: map[string]int{},
|
||||||
|
evaluators: map[string]*ltl.Evaluator{},
|
||||||
|
rng: rand.New(rand.NewPCG(0, 0)),
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
option(verifier)
|
||||||
|
}
|
||||||
|
if err := verifier.installRuntimeBindings(); err != nil {
|
||||||
|
return nil, fmt.Errorf("install bindings: %w", err)
|
||||||
|
}
|
||||||
|
return verifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load executes the bundled spec source. The spec is expected to assign its
|
||||||
|
// property formulas to globalThis.properties and its root action generator
|
||||||
|
// to globalThis.actions.
|
||||||
|
func (v *Verifier) Load(source string) error {
|
||||||
|
if _, err := v.runtime.RunString(source); err != nil {
|
||||||
|
return fmt.Errorf("run spec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
propertiesValue := v.runtime.GlobalObject().Get("properties")
|
||||||
|
if propertiesValue != nil && !goja.IsUndefined(propertiesValue) && !goja.IsNull(propertiesValue) {
|
||||||
|
propertiesObject := propertiesValue.ToObject(v.runtime)
|
||||||
|
for _, name := range propertiesObject.Keys() {
|
||||||
|
handle := propertiesObject.Get(name).ToObject(v.runtime)
|
||||||
|
if handle == nil {
|
||||||
|
return fmt.Errorf("property %q is not an object", name)
|
||||||
|
}
|
||||||
|
indexValue := handle.Get("__uatuIndex")
|
||||||
|
if indexValue == nil {
|
||||||
|
return fmt.Errorf("property %q was not produced by always()", name)
|
||||||
|
}
|
||||||
|
index := int(indexValue.ToInteger())
|
||||||
|
v.properties[name] = index
|
||||||
|
v.evaluators[name] = ltl.NewEvaluator(ltl.Always(ltl.Thunk(v.formulaThunk(index))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if actionsValue := v.runtime.GlobalObject().Get("actions"); actionsValue != nil && !goja.IsUndefined(actionsValue) && !goja.IsNull(actionsValue) {
|
||||||
|
v.actionGenerator = actionsValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushSnapshot updates the JS-side state and refreshes every extractor's
|
||||||
|
// current/previous values in registration order.
|
||||||
|
func (v *Verifier) PushSnapshot(snapshots Snapshots) error {
|
||||||
|
state, err := stateObject(v.runtime, snapshots)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build state: %w", err)
|
||||||
|
}
|
||||||
|
if err := v.runtime.GlobalObject().Set("state", state); err != nil {
|
||||||
|
return fmt.Errorf("set state: %w", err)
|
||||||
|
}
|
||||||
|
for index, extractor := range v.extractors {
|
||||||
|
previous := extractor.handle.Get("current")
|
||||||
|
_ = extractor.handle.Set("previous", previous)
|
||||||
|
newValue, err := extractor.getter(goja.Undefined(), state)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("extractor %d: %w", index, err)
|
||||||
|
}
|
||||||
|
_ = extractor.handle.Set("current", newValue)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateProperties returns each registered property's running verdict
|
||||||
|
// after the most recent PushSnapshot.
|
||||||
|
func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
|
||||||
|
verdicts := map[string]ltl.Verdict{}
|
||||||
|
for name, evaluator := range v.evaluators {
|
||||||
|
verdicts[name] = evaluator.Observe()
|
||||||
|
}
|
||||||
|
return verdicts
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextAction resolves the root action generator into a single Action.
|
||||||
|
// Returns ErrNoAction when the generator yields nothing actionable.
|
||||||
|
func (v *Verifier) NextAction() (Action, error) {
|
||||||
|
if v.actionGenerator == nil {
|
||||||
|
return Action{}, ErrNoAction
|
||||||
|
}
|
||||||
|
return v.resolveGenerator(v.actionGenerator)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNoAction = errors.New("verifier: no action available")
|
||||||
|
|
||||||
|
func (v *Verifier) formulaThunk(index int) func() bool {
|
||||||
|
return func() bool {
|
||||||
|
formula := v.formulas[index]
|
||||||
|
result, err := formula.predicate(goja.Undefined())
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("predicate panic: %w", err))
|
||||||
|
}
|
||||||
|
return result.ToBoolean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) {
|
||||||
|
object := generator.ToObject(v.runtime)
|
||||||
|
if object == nil {
|
||||||
|
return Action{}, fmt.Errorf("generator is not an object")
|
||||||
|
}
|
||||||
|
kindValue := object.Get(tagInternalKind)
|
||||||
|
if kindValue == nil {
|
||||||
|
return Action{}, fmt.Errorf("generator missing internal kind tag")
|
||||||
|
}
|
||||||
|
switch kindValue.String() {
|
||||||
|
case internalKindActions:
|
||||||
|
generateValue := object.Get("generate")
|
||||||
|
generate, ok := goja.AssertFunction(generateValue)
|
||||||
|
if !ok {
|
||||||
|
return Action{}, fmt.Errorf("actions handle missing generate function")
|
||||||
|
}
|
||||||
|
result, err := generate(goja.Undefined())
|
||||||
|
if err != nil {
|
||||||
|
return Action{}, fmt.Errorf("generate: %w", err)
|
||||||
|
}
|
||||||
|
return v.pickFromResult(result)
|
||||||
|
case internalKindWeighted:
|
||||||
|
entries := object.Get("entries").ToObject(v.runtime)
|
||||||
|
if entries == nil {
|
||||||
|
return Action{}, fmt.Errorf("weighted handle missing entries")
|
||||||
|
}
|
||||||
|
picked, err := v.pickWeighted(entries)
|
||||||
|
if err != nil {
|
||||||
|
return Action{}, err
|
||||||
|
}
|
||||||
|
return v.resolveGenerator(picked)
|
||||||
|
case internalKindBuiltinTaps, internalKindBuiltinSwipes:
|
||||||
|
return Action{}, ErrNoAction
|
||||||
|
default:
|
||||||
|
return Action{}, fmt.Errorf("unknown generator kind %q", kindValue.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) pickFromResult(result goja.Value) (Action, error) {
|
||||||
|
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||||
|
return Action{}, ErrNoAction
|
||||||
|
}
|
||||||
|
object := result.ToObject(v.runtime)
|
||||||
|
if object == nil {
|
||||||
|
return Action{}, ErrNoAction
|
||||||
|
}
|
||||||
|
lengthValue := object.Get("length")
|
||||||
|
if lengthValue == nil {
|
||||||
|
return jsValueToAction(v.runtime, result)
|
||||||
|
}
|
||||||
|
length := int(lengthValue.ToInteger())
|
||||||
|
if length == 0 {
|
||||||
|
return Action{}, ErrNoAction
|
||||||
|
}
|
||||||
|
pick := v.rng.IntN(length)
|
||||||
|
return jsValueToAction(v.runtime, object.Get(fmt.Sprintf("%d", pick)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) pickWeighted(entries *goja.Object) (goja.Value, error) {
|
||||||
|
lengthValue := entries.Get("length")
|
||||||
|
if lengthValue == nil {
|
||||||
|
return nil, fmt.Errorf("weighted entries missing length")
|
||||||
|
}
|
||||||
|
length := int(lengthValue.ToInteger())
|
||||||
|
if length == 0 {
|
||||||
|
return nil, ErrNoAction
|
||||||
|
}
|
||||||
|
|
||||||
|
weights := make([]float64, length)
|
||||||
|
generators := make([]goja.Value, length)
|
||||||
|
totalWeight := 0.0
|
||||||
|
for index := range length {
|
||||||
|
entry := entries.Get(fmt.Sprintf("%d", index)).ToObject(v.runtime)
|
||||||
|
if entry == nil {
|
||||||
|
return nil, fmt.Errorf("weighted entry %d not an array", index)
|
||||||
|
}
|
||||||
|
weight := entry.Get("0").ToFloat()
|
||||||
|
generator := entry.Get("1")
|
||||||
|
if weight < 0 {
|
||||||
|
weight = 0
|
||||||
|
}
|
||||||
|
weights[index] = weight
|
||||||
|
generators[index] = generator
|
||||||
|
totalWeight += weight
|
||||||
|
}
|
||||||
|
if totalWeight == 0 {
|
||||||
|
return nil, ErrNoAction
|
||||||
|
}
|
||||||
|
pick := v.rng.Float64() * totalWeight
|
||||||
|
cumulative := 0.0
|
||||||
|
for index := range length {
|
||||||
|
cumulative += weights[index]
|
||||||
|
if pick < cumulative {
|
||||||
|
return generators[index], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return generators[length-1], nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user