This codebase builds a KLEE-style symbolic executor for jq in Go. It interprets gojq bytecode over JSON Schemas instead of concrete JSON values, producing output schemas that soundly overapproximate all possible jq outputs.
The goal is to answer: "Given an input JSON Schema, what schema describes all possible outputs of this JQ program?"
- Execute compiled gojq bytecode with a multi-state worklist VM that models jq's backtracking semantics
- Evaluate builtins over JSON Schemas (not concrete values)
- Represent nondeterminism with union (anyOf)
- Prefer over-approximation (Top/union) to under-approximation
- When unsure, widen
- The output schema must correctly represent ALL possible concrete values that could result from the JQ program
- Constant folding for enum-singleton values
- Merge enum strings/integers and deduplicate unions structurally
- Top DOMINATES unions: if any branch (or merged property branch) is fully unconstrained, the union is Top — filtering Top out to "preserve precision" would discard possible outputs and break soundness
Logging infrastructure for debug tracing.
Provides comprehensive execution tracing at different log levels:
- Logger interface: Simple abstraction with Debugf/Infof/Warnf/Errorf methods
- State tracking: Each execution state has unique ID and lineage path for fork tracking
- Schema summaries: Compact representations like
object{name,age},array[string],integer(enum:(1,2,3)) - Helper functions:
schemaTypeSummary(),schemaDelta()for readable trace output
Usage:
opts := DefaultOptions()
opts.LogLevel = "debug" // Enable debug tracing
result, err := RunSchema(ctx, query, inputSchema, opts)The core multi-state VM and bytecode interpreter.
Key components:
- Multi-state execution: Maintains a worklist of execution states to handle JQ's fork/backtrack semantics
- Accumulator-based arrays: KLEE-style pointer tracking for array construction (lines 296-306)
- Path mode: Special mode for del/setpath/getpath operations (lines 437-448)
- Object construction: Sets required keys for all object literal keys (lines 1008-1016)
Important operations:
execIterMulti(lines 940-978): Iteration over arrays/objectsexecIndexMulti(lines 887-938): Property access and array indexingexecObjectMulti(lines 980-1027): Object literal constructionexecAppendMulti(lines 1029-1154): Array element accumulationexecFork(lines 1156-1173): Fork operation creating parallel execution paths
Schema-level implementations of JQ builtins.
Each builtin has signature:
func(input *oas3.Schema, args []*oas3.Schema, env *schemaEnv) ([]*oas3.Schema, error)Key builtins:
- Introspection:
type,length,keys,values,has(lines 124-268) - Type conversions:
tonumber,tostring,toarray(lines 274-291) - Array operations:
add,reverse,sort,unique,min,max(lines 297-378) - Object operations:
to_entries,from_entries,with_entries(lines 384-450) - Arithmetic:
_plus,_minus,_multiply,_divide,_modulo(lines 1504-1679) - Comparison:
_equal,_less,_greater, etc. (lines 1240-1286) - String operations:
split,join,startswith,ascii_downcase, etc. (lines 456-651) - Path operations:
delpaths,getpath,setpath(lines 1058-1165)
Core schema algebra and manipulation.
Key operations:
- Schema constructors:
Top(),Bottom(),ConstString(),NumberType(), etc. (lines 17-141) - Union (lines 192-277): Creates anyOf, with deduplication and widening
- GetProperty (lines 149-188): Property access from objects
- BuildObject (lines 771-784): Construct object schemas
- Deduplication (lines 433-469): Merges identical schemas, especially string/integer enums
- Subsumption checking (lines 1152-1221): Determines if one schema is a subset of another
- Widening (lines 693-767): Applied when unions exceed limits
Important for precision:
- String enum merging (lines 544-616): Combines multiple const strings into single enum
- Top filtering during property merge (lines 371-383): Preserves concrete schemas over unconstrained ones
- Object merging with identical required sets (lines 325-401)
Error types for the symbolic executor.
- When type/shape is unknown, yield
Topor broader unions - Never discard possible outputs - it's better to be imprecise than incorrect
- If you can't prove something won't happen, assume it can
Untyped schemas are typed by structural inference (impliedTypeOf in
schemaops.go, consulted by getType/mightBeType):
Schema structure (no explicit type, no allOf/anyOf/oneOf/not/if/then/else) |
Implied type |
|---|---|
enum present |
string |
const present (non-null) |
the const's scalar type |
const: null |
unknown (mirrors the generator) |
properties (non-empty) |
object |
additionalProperties present |
object |
items present |
array |
| otherwise | unknown (Top-like) |
Under raw JSON Schema semantics an untyped schema with properties still
admits strings, numbers, etc., so this inference technically narrows the
concretization. It is a deliberate, documented deviation: this library
targets Speakeasy-processed OpenAPI documents, and the contract is
equivalence with the structural inference Speakeasy's SDK/CLI generators
apply to untyped schemas. Real-world documents routinely omit
type: object on schemas with properties; without inference every
navigation of such schemas widens to Top and the executor is useless on
exactly the documents it targets. The generator suppresses inference for
allOf/anyOf/oneOf, but ignores not/if/then/else. The executor
conservatively suppresses inference for those additional keywords too,
widening rather than asserting a type in their presence.
The mode is selectable via SchemaExecOptions.Semantics:
SchemaSemanticsSpeakeasy (default) applies the inference at navigation
dispatch and treats objects without additionalProperties as CLOSED
(undeclared property access yields null — this is what makes typo'd leaves
provably broken); SchemaSemanticsRaw requires explicit types at dispatch
and treats absent additionalProperties as OPEN per JSON Schema, so a
missing property is never provably broken without an explicit
additionalProperties: false.
Reference resolution state lives on the JSONSchema wrapper (via
GetResolvedSchema()), not on the inline Left schema — for a $ref,
Left is a bare shell with only Ref set. Any code that reads schema
children MUST look through the resolved schema first (resolvedLeft in
execute_schema.go), and any code that rebuilds child wrappers with
NewJSONSchemaFromSchema MUST pass the resolved schema, or the wrapper's
resolution caches are silently discarded and downstream navigation sees an
untyped shell (widening to Top — or worse, misreading a referenced property
as "definitely missing", which is unsound). derefJSONSchema treats an
unresolved ref shell as a failed dereference so callers widen to Top.
- Union-first, then materialize accumulators (to avoid "who writes last" effects)
- Deduplicate and merge wherever safe (enums, identical structure)
- Tests run symbolic execution twice to check schema stabilization
- Apply when anyOf exceeds
AnyOfLimit(default: depends on config) WideningLevel=1: Keep per-type base schemas (e.g., separate number, string, array branches)WideningLevel=2: Collapse everything to Top
- The VM marks all keys in object literals as required
- This reflects JQ semantics:
{a: .x, b: .y}always produces both keys
func builtinExample(input *oas3.Schema, args []*oas3.Schema, env *schemaEnv) ([]*oas3.Schema, error) {
// 1. Check if inputs are concrete (enum with single value)
if val, ok := extractConstValue(input); ok {
// Perform concrete computation
return []*oas3.Schema{ConstString(result)}, nil
}
// 2. Check type compatibility
if !MightBeString(input) {
return []*oas3.Schema{Bottom()}, nil
}
// 3. Return safe overapproximation
return []*oas3.Schema{StringType()}, nil
}Key points:
- Implement const folding for enum-singleton values
- Check type compatibility with
MightBeX()helpers - Return the safest overapproximation for symbolic inputs
- Can return multiple schemas to represent branching (VM forks states)
JQ's , operator and many builtins can produce multiple outputs:
1, 2 # produces 1, then 2
.[] # produces each array elementThe VM handles this with:
opFork: Creates two execution states (lines 1156-1173)opBacktrack: Terminates current path (line 352)- Results from all paths are merged via
Unionat the end
KLEE-style identity tracking:
- Arrays stored in variables get an
allocID(lines 296-306) opAppendmutates the canonical array's items by unioning with new item type (lines 1105-1146)- After execution,
materializeArraysresolves array references (lines 1549-1620)
This ensures all paths that append to the same array variable produce a single output schema with union of all appended types.
For del(), setpath(), getpath():
- VM enters path-collection mode (
opPathBegin, line 437) - Index operations collect path segments instead of navigating (lines 890-905)
opPathEndbuilds a path tuple (array with prefixItems) (line 443-448)- Path tuples are preserved distinctly in accumulators (lines 1111-1136)
- Builtins check for const inputs (enum with single value)
- Perform concrete computation on const values
- Multiple const results get merged by Union into enum sets
- Example:
if .x then "a" else "b" end→{type: string, enum: ["a", "b"]}
When merging object properties from multiple execution paths, the property
schemas are UNIONED — including unconstrained (Top) branches. If any path
leaves a property unconstrained, the merged property is Top: merging
{id: {type: string}} with {id: {}} yields {id: {}} (Top dominates).
Filtering Top out would silently narrow the output and break the
over-approximation contract (and the Analyze API's Proven verdict).
Tests in pkg/playground/transform_test.go validate:
- Iteration 1: Execute JQ transform on input schema
- Iteration 2: Feed iteration-1 output back into same transform
- Compare against expected schemas in
testdata/{test}.out.1.yamlandtestdata/{test}.out.2.yaml
The schema may stabilize exactly or broaden to a consistent overapproximation.
testdata/
TestName.in.yaml # Input schema
TestName.out.1.yaml # Expected output after iteration 1
TestName.out.2.yaml # Expected output after iteration 2
- Create input schema in
testdata/{TestName}.in.yaml - Manually compute or run executor to generate
{TestName}.out.1.yaml - Apply transform again to get
{TestName}.out.2.yaml - Add test case in
transform_test.gothat loads and compares schemas
When implementing a new builtin:
- Start from similar builtins (e.g., compare
builtinMultiplyfor arithmetic) - Implement const folding against enum-singleton values
- Return safest overapproximation for non-constant inputs
- Test arity variations: Some builtins work with different argument counts
- Handle object-first calling convention: Binary operators can receive args in different orders
- Prefer returning multiple branches to encode possible outcomes
Example builtin locations:
- Arithmetic: lines 1504-1679 in
builtins.go - String operations: lines 456-651
- Array operations: lines 297-378
GetPropertydoesn't union with null for non-required properties in Phase 1- This keeps outputs simpler but may be optimistic
- Remains sound for overapproximation (consumers should assume properties may be absent)
- When indexing non-object/array (e.g., null), JQ returns null
- We return
Topfor overapproximation (less precise but sound) - Location:
execute_schema.golines 931-934
execObjectMultiwarns but doesn't deeply handle computed keys- Location:
execute_schema.golines 766-768
- Implemented by exploring both branches (execForkAlt)
- This overapproximates fallback semantics but is sound
opCallRecis widened to Top (not fully supported)- Location:
execute_schema.golines 403-411
test,match: Conservative unless const inputs/patterns- Location:
builtins.golines 988-1035
- Known issue with array slicing producing invalid EitherValue states
- Test disabled:
_TestSymbolicExecuteJQPipeline_ComputedFullName - See:
transform_test.golines 814-875
- Keep
WideningLevelat 1 for practical unions - Set
AnyOfLimitandEnumLimitfor tractable merges - Enable
EnableWarningsduring development to collect warnings - Set
LogLevelto "debug" for detailed execution tracing (logs to stderr)- "" (default): no output — the library is silent on stdout/stderr;
warnings are still returned on
SchemaExecResult.Warnings - "error": Only critical failures
- "warn": Warnings about precision loss and unsupported operations
- "info": High-level execution lifecycle
- "debug": Per-opcode trace with state, stack, and schema changes
- "" (default): no output — the library is silent on stdout/stderr;
warnings are still returned on
- Write test with expected input/output schemas
- Run symbolic executor
- If output differs, investigate:
- Is it a soundness issue? (Missing possible outputs)
- Is it a precision issue? (Too wide but correct)
- Is it a bug? (Incorrect transformation)
Use Debug Logging:
- Set
opts.LogLevel = "debug"to enable comprehensive execution tracing - Each opcode execution is logged with state ID, lineage, PC, stack depth, and schema types
- Fork paths are tracked with lineage strings (e.g., "0" → "0.F" for fork branch, "0.C" for continue)
- Terminal states show final result schemas
- All warnings are automatically logged at WARN level
When Things Don't Work:
- Add debug logging immediately when investigating issues - set
LogLevel: "debug"in your test - Leave the logs in after fixing - they're useful for future debugging and understanding execution flow
- Watch for:
- Unexpected fork branches (lineage tracking shows execution paths)
- Stack depth anomalies (might indicate missing pop/push operations)
- Schema widening to Top (warnings show where precision is lost)
- Accumulator issues for array construction (shows allocID and item type unions)
Other Debugging:
- Check worklist iterations (may indicate infinite loops)
- Verify accumulator materialization for array construction
- Use const folding tests to validate builtin logic
- Review terminal state logs to see what each execution path produced
- Memoization currently disabled (fingerprint issues with nested properties)
- Watch for union explosion (adjust
AnyOfLimit) - Consider widening earlier if compilation times are too long
Areas for enhancement:
- Better nullability tracking: Union optional properties with null
- More precise array slicing: Fix the known bug
- Recursive function support: Implement fixed-point iteration
- Better dynamic key handling: Track key sets symbolically
- Memoization: Fix fingerprinting to enable state deduplication
- Regex analysis: More precise regex matching for common patterns
- JQ manual: https://jqlang.github.io/jq/manual/
- gojq (the JQ implementation we use): https://github.com/itchyny/gojq
- JSON Schema specification: https://json-schema.org/
- KLEE symbolic execution: https://klee.github.io/
When working on this codebase:
- Check soundness first: Does the change preserve overapproximation?
- Test with iteration: Does the schema stabilize or widen reasonably?
- Document assumptions: Add comments explaining non-obvious decisions
- Ask for review: Symbolic execution is subtle; peer review helps
Remember: It's better to be imprecise than incorrect!