| name | trieste-dev |
| description | Plan and implement Trieste-based compiler passes and AST transformations for rego-cpp. Use when: adding new compiler passes, modifying AST structure, implementing new Rego language features, debugging pass failures, working with well-formedness definitions, or performing any multi-step implementation that touches the Trieste pass pipeline. Includes the multi-planner approach for complex features. |
| argument-hint | Describe the feature or pass work to plan or implement. |
Trieste Development Workflow
Plan and implement Trieste-based compiler passes, AST transformations, and language features in rego-cpp.
When to Use
- Adding or modifying a compiler pass in the file-to-rego or rego-to-bundle pipeline
- Implementing new Rego language syntax (new tokens, grammar rules)
- Changing well-formedness definitions
- Debugging pass failures or well-formedness violations
- Implementing complex multi-step features that touch the AST pipeline
- Any task requiring coordination across parser, passes, built-ins, and VM
Core Concepts
Trieste is a multi-pass term-rewriting system. Understanding these concepts is mandatory before proceeding:
- Pass: A
PassDef that takes an AST conforming to an input well-formedness (WF) definition and rewrites it to conform to an output WF definition. Passes run repeatedly until no more rules match (fixpoint), unless dir::once is specified.
- Well-formedness (WF): A structural specification of valid AST shapes. Each pass declares its output WF. WF definitions are incremental — each extends the previous with
| (choice).
- Pattern → Effect rules: Each pass contains rules of the form
Pattern >> Effect. Patterns match AST subtrees; effects produce replacement subtrees.
- Driver/Reader/Rewriter: Trieste helpers that chain passes into pipelines. rego-cpp uses
Reader for parsing and Rewriter for transformation.
- Generative testing: Trieste can generate random ASTs from WF definitions to fuzz each pass. This discovers edge cases in rewrite rules.
Procedure
Step 0: Understand the Current AST
Before any implementation, you must understand the AST structure at the point you're modifying.
-
Read the well-formedness definitions for the passes surrounding your change:
- File-to-rego passes: defined in
src/file_to_rego.cc (WF definitions inline with passes)
- Rego-to-bundle passes: defined in
src/rego_to_bundle.cc
- Base WF:
include/rego/rego.hh → wf
- Bundle WF:
include/rego/rego.hh → wf_bundle
- Internal WF:
src/internal.hh → wf_bundle_input
-
Dump the AST at the relevant pass to see the actual tree shape:
./build/tools/rego eval --dump_passes .copilot/pass-debug/ -p <pass_name> '<query>'
Or write a minimal .rego file and use --wf to check well-formedness.
-
Never assume node structure — always verify by reading the WF definition. Nodes are typically wrapped (e.g., Array elements inside Term nodes). Use unwrap() helpers.
Step 1: Multi-Planner Analysis
For any non-trivial feature, use the multi-planner approach — analyze the problem from multiple perspectives before writing code. This prevents costly rework.
Perspective 1: Reference Implementation (OPA)
How does OPA implement this feature?
- Check OPA's documentation for the feature's specification
- Inspect OPA's IR output to see how OPA compiles the feature:
mkdir -p .copilot/opa-ir-test
cat > .copilot/opa-ir-test/policy.rego << 'EOF'
package test
EOF
/tmp/opa build --bundle .copilot/opa-ir-test --target plan -e test/<entry> -o .copilot/opa-ir-test/bundle.tar.gz
cd .copilot/opa-ir-test && tar xzf bundle.tar.gz && python3 -m json.tool plan.json
- Test both constant and variable expressions — OPA's optimizer may fold constants, hiding the general compilation path
- Record: internal built-in names, calling conventions, undefined-handling patterns
Perspective 2: AST Pipeline Impact
Where in the rego-cpp pipeline does this feature need to be handled?
- Parser changes? — Does this require new tokens in
include/rego/rego.hh and rules in src/parse.cc?
- Which file-to-rego passes are affected? — Map the feature to specific passes in the 18-pass file-to-rego pipeline (see pass-pipeline.md)
- Which rego-to-bundle passes are affected? — Map to the 11-pass rego-to-bundle pipeline
- VM changes? — Does
src/virtual_machine.cc need new opcodes or evaluation logic?
- Built-in additions? — Any new built-in functions required?
- New Term alternative? — If adding a new node type to
Term, audit all type-dispatch sites:
src/dependency_graph.cc — add_lhs_var / add_rhs must handle the new type
src/resolver.cc — variable resolution may need a case
src/virtual_machine.cc — evaluation dispatch
src/encoding.cc — serialization in to_key()
src/opblock.cc — lowering to opcodes in term_to_opblock()
Perspective 3: Well-formedness Chain
How do WF definitions need to change?
- Trace the WF chain from the first affected pass to the last
- Identify which node types need to be added, modified, or removed at each stage
- Verify that WF changes are incremental — each definition extends the previous
- Check that no downstream pass is broken by the WF changes
Perspective 4: Test Strategy
How will you verify correctness at each stage?
- YAML test cases — Write expected input/output pairs in
tests/regocpp.yaml or tests/bugs.yaml
- OPA conformance tests — Identify which OPA test subdirectories exercise the feature
- Generative testing — Plan to run the Trieste
test command to check WF validity
- Incremental verification — After each pass modification, run targeted tests before proceeding
Step 2: Implementation Plan
Based on the multi-planner analysis, create a sequenced implementation plan:
- Order changes by pipeline stage — parser first, then file-to-rego passes in order, then rego-to-bundle passes, then VM
- Implement one pass at a time — never modify multiple passes simultaneously without testing between changes
- Write test cases first — add YAML test cases for the feature before implementing, so you can verify each step
- Use smallest possible passes — prefer adding a new small pass over making an existing pass more complex (Trieste philosophy: "there is no downside to having many passes")
Step 3: Incremental Implementation
For each pass change:
- Read the current pass code and its surrounding WF definitions
- Modify the WF definition for the pass output if needed (define new node shapes)
- Add rewrite rules using the pattern → effect DSL:
In(ParentType) * T(NodeType)[Capture] >> [](Match& _) {
return NewNode << _(Capture);
},
- Add error rules for invalid inputs the WF would allow:
T(BadNode)[Node] >> [](Match& _) {
return err(_(Node), "descriptive error message");
},
- Run targeted tests immediately:
./build/tests/rego_test -wf tests/regocpp.yaml
./build/tests/rego_test -wf opa/v1/test/cases/testdata/v1/<subdir>
- Dump the AST to verify the transformation:
./build/tools/rego eval --dump_passes .copilot/pass-debug/ '<query>'
Step 4: Validation
After all passes are implemented:
- Run the full rego-cpp test suite:
ctest --test-dir build -R "rego_test_regocpp|rego_test_bugs|rego_test_cts|rego_test_cpp_api"
- Run OPA conformance tests (if applicable):
ctest --test-dir build -R rego_test_opa --output-on-failure
- Run generative testing to check WF validity:
./build/tools/rego test -f -c 1000
- Run with AddressSanitizer for memory safety:
cmake --preset asan-clang && ninja -C build-asan && ctest --test-dir build-asan
Key Patterns Reference
PassDef Structure
PassDef my_pass()
{
return {
"my_pass",
wf_my_pass,
dir::bottomup | dir::once,
{
In(Parent) * T(Child)[C] >> [](Match& _) { return _(C); },
}
};
}
Traversal Directions
| Direction | Meaning |
|---|
dir::bottomup | Process children before parents |
dir::topdown | Process parents before children |
dir::once | Single traversal (combine with above) |
| (no once) | Repeat until fixpoint (no rules match) |
Pattern DSL Quick Reference
| Pattern | Meaning |
|---|
T(Foo) | Match a node of type Foo |
T(Foo)[X] | Match Foo, bind to variable X |
T(Foo) / T(Bar) | Match Foo or Bar |
A * B | Match A followed by B (siblings) |
P << C | Match children C inside parent P |
In(P) | Parent context is P (not part of match) |
Any | Match any single node |
Any++[X] | Match one or more remaining nodes, bind to X |
End | Assert no more siblings |
_(X) | In effect: get single node bound to X |
_[X] | In effect: get all nodes bound to X (NodeRange) |
*_[X] | In effect: get children of nodes bound to X |
Well-formedness DSL
inline const auto wf_my_pass =
wf_previous_pass
| (NewNode <<= ChildA * ChildB)
| (Container <<= Element++)
| (Container <<= Element++[1])
| (Wrapper <<= (ChoiceA | ChoiceB))
| (Parent <<= Name * Body)[Name]
;
Creating AST Nodes
NewNode << child1 << child2
TokenType ^ "string content"
Container << *_[MatchVar]
Container << _[MatchVar]
return {};
Common Mistakes
- Not reading the WF definition first — The #1 source of bugs. Nodes are wrapped in unexpected ways.
- Modifying multiple passes without testing between — Errors compound and become impossible to diagnose.
- Comparing
child->type() directly — Use unwrap() helpers; nodes are wrapped in Term/Scalar layers.
- Forgetting error rules — Generative testing will generate inputs that your positive rules don't handle. You must add error rules for these cases.
- Wrong traversal direction —
bottomup processes children first (useful when collapsing); topdown processes parents first (useful when pushing structure down).
- Rule ordering — Rules are matched in order. If a general rule comes before a specific one, the specific rule will never fire.
- Missing
dir::once — Without it, the pass runs to fixpoint. This is correct for most passes but causes infinite loops if rules don't converge.
- Creating parallel paths instead of reusing the standard pipeline — When adding a new compound node type (e.g.,
TemplateString), prefer routing its sub-expressions through the existing Group → Literal → Expr pipeline rather than creating a custom parallel path (e.g., TemplateString <<= (TemplateLiteral | Expr)++). The standard pipeline already handles with/as, some, comprehensions, and other features. Creating a parallel path means manually replicating all of that machinery. In the parser, use m.term() to separate groups naturally and m.in(NodeType) to detect context on closing delimiters, rather than m.push(Brace) which creates a separate nesting scope. Convert specialized tokens (e.g., TemplateLiteral) to standard types (e.g., Scalar << String << JSONString) as early as possible (in the prep pass) to minimize WF cascading.
- Not auditing
dependency_graph.cc when adding new Term alternatives — The dependency graph in src/dependency_graph.cc has explicit if (lhs == Type) cases for every node type that can appear as a Term child. When adding a new Term alternative, you must add a corresponding case there. Missing cases cause "Unable to unify due to cycle" errors. Also audit and for similar type-dispatch patterns.