diff --git a/internal/verifier/bindings.go b/internal/verifier/bindings.go index 57121d7..61fb605 100644 --- a/internal/verifier/bindings.go +++ b/internal/verifier/bindings.go @@ -10,8 +10,10 @@ import ( type extractorState struct { getter goja.Callable - handle *goja.Object name string + // currentValue/previousValue back the handle's current/previous accessors. + currentValue goja.Value + previousValue goja.Value // prev/curr cache the JSON-encoded extractor values from the prior and // current PushSnapshot, used by ChangedExtractors to surface per-step // diffs in the trace. @@ -170,14 +172,43 @@ func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value { name = fmt.Sprintf("extractor_%d", len(v.extractors)) } - handle := v.runtime.NewObject() - _ = handle.Set("current", goja.Undefined()) - _ = handle.Set("previous", goja.Undefined()) + state := &extractorState{ + getter: getter, + name: name, + currentValue: goja.Undefined(), + previousValue: goja.Undefined(), + } - v.extractors = append(v.extractors, &extractorState{getter: getter, handle: handle, name: name}) + handle := v.runtime.NewObject() + _ = handle.DefineAccessorProperty("current", v.runtime.ToValue(func(goja.FunctionCall) goja.Value { + v.checkNotExtracting("current") + return state.currentValue + }), nil, goja.FLAG_FALSE, goja.FLAG_TRUE) + _ = handle.DefineAccessorProperty("previous", v.runtime.ToValue(func(goja.FunctionCall) goja.Value { + v.checkNotExtracting("previous") + return state.previousValue + }), nil, goja.FLAG_FALSE, goja.FLAG_TRUE) + _ = handle.Set("named", func(call goja.FunctionCall) goja.Value { + state.name = call.Argument(0).String() + return handle + }) + + v.extractors = append(v.extractors, state) return handle } +// checkNotExtracting panics with a JS error when an extractor getter tries to +// read another extractor handle's current/previous. The message is identical to +// the web runtime's so authors see one diagnostic across engines. +func (v *Verifier) checkNotExtracting(slot string) { + if v.extracting { + panic(v.runtime.NewGoError(fmt.Errorf( + "reading .%s of an extractor inside another extractor is not allowed; extractor getters may read only from the state argument", + slot, + ))) + } +} + // bindAlways accepts either a predicate function (legacy shape) or a formula // handle (new shape). Both produce a formula handle tagged with // __sanderlingFormulaSpec. diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 6127101..56a1084 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -49,6 +49,11 @@ type Verifier struct { // order, so the runner can surface them in the run report. unsupported []string unsupportedSeen map[string]bool + + // extracting is true only while an extractor getter is running. The handle's + // current/previous accessors consult it so a getter that reaches into + // another extractor's handle throws instead of reading a stale value. + extracting bool } // UnsupportedVerbs returns the verbs the picker requested that this platform @@ -276,19 +281,27 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error { // Predicate thunks read these slots but never trigger advancement, so // invoking a thunk multiple times between snapshots is value-stable. for index, extractor := range v.extractors { - previous := extractor.handle.Get("current") - _ = extractor.handle.Set("previous", previous) - newValue, err := extractor.getter(goja.Undefined(), state) + extractor.previousValue = extractor.currentValue + newValue, err := v.runExtractor(extractor, state) if err != nil { return fmt.Errorf("extractor %d: %w", index, err) } - _ = extractor.handle.Set("current", newValue) + extractor.currentValue = newValue extractor.prev = extractor.curr extractor.curr = encodeExtractorValue(newValue) } return nil } +// runExtractor invokes an extractor's getter with the extracting flag set, so a +// getter that reads another extractor's current/previous throws. The flag is +// cleared even if the getter panics. +func (v *Verifier) runExtractor(extractor *extractorState, state goja.Value) (goja.Value, error) { + v.extracting = true + defer func() { v.extracting = false }() + return extractor.getter(goja.Undefined(), state) +} + // encodeExtractorValue produces a stable JSON encoding of an extractor's // current value for diff comparison. goja values that don't survive Export // (e.g. wrapped host functions) yield nil; callers treat nil as "unknown" and @@ -358,7 +371,7 @@ func (v *Verifier) OverrideExtractorValues(overrides map[int]json.RawMessage) (s if conversionErr != nil { return skipped, fmt.Errorf("extractor override %d: %w", index, conversionErr) } - _ = v.extractors[index].handle.Set("current", value) + v.extractors[index].currentValue = value } return skipped, nil }