feat(verifier): track extractor value transitions

Cache each extractor's prior and current JSON-encoded value during
PushSnapshot; expose ChangedExtractors to surface per-step diffs the
runner can emit into the trace. The first observation flushes every
non-null extractor as a change so the inspect UI shows initial state
breadcrumbs alongside later transitions.
This commit is contained in:
pj committed 2026-05-30 21:57:59 +05:30
1 parent 07c292913c
commit 419a2d564e
2 files changed
+54

No files matched your search

+8
View File
@@ -45,3 +45,11 @@ type Exception struct {
StackTrace string
UnixMillis int64
}
// ExtractorChange records a single extractor's value transition across one
// step. Used to surface "what changed at this step" breadcrumbs at violation
// markers in the inspect UI.
type ExtractorChange struct {
Prev []byte
Curr []byte
}
+46
View File
@@ -1,6 +1,7 @@
package verifier
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -242,10 +243,55 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error {
return fmt.Errorf("extractor %d: %w", index, err)
}
_ = extractor.handle.Set("current", newValue)
extractor.prev = extractor.curr
extractor.curr = encodeExtractorValue(newValue)
}
return nil
}
// 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
// emit no diff entry.
func encodeExtractorValue(value goja.Value) []byte {
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return []byte("null")
}
exported := value.Export()
body, err := json.Marshal(exported)
if err != nil {
return nil
}
return body
}
// ChangedExtractors returns the named extractors whose value changed between
// the prior PushSnapshot and the current one. The map is keyed by extractor
// name; unnamed extractors (extractor_N fallback) are included so the inspect
// UI can still display them under a numeric label. The very first snapshot
// emits every non-null extractor as a change (Prev=null, Curr=current) since
// the runner can otherwise misread "no diff yet" as "nothing initialized".
func (v *Verifier) ChangedExtractors() map[string]ExtractorChange {
changes := map[string]ExtractorChange{}
for _, extractor := range v.extractors {
if extractor.curr == nil {
continue
}
prev := extractor.prev
if prev == nil {
prev = []byte("null")
}
if bytes.Equal(prev, extractor.curr) {
continue
}
changes[extractor.name] = ExtractorChange{
Prev: append([]byte(nil), prev...),
Curr: append([]byte(nil), extractor.curr...),
}
}
return changes
}
// OverrideExtractorValues replaces each extractor's `current` slot with a
// caller-supplied value, keyed by registration index. Used by the web tick
// path so extractor bodies that ran in V8 (against the real DOM) drive the