feat(blueprint): recursive type schema codegen#215
Conversation
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for recursive Plutus blueprint types in the TypeScript TSchema codegen, addressing previously invalid output for self-referential definitions and improving ordering/import behavior.
Changes:
- Implement cyclic-aware
$refemission using typedSchema.suspendthunks and inferred encodedItypes. - Replace namespace “grouped” emission with a streaming, globally topologically-sorted namespace emitter.
- Add a recursive Aiken multisig fixture + Blueprint tests; update docs sample blueprint and docs codegen UI to use the new
unionStyleconfig.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/evolution/src/blueprint/codegen.ts | Core codegen changes: cycle detection, encoded-type inference, streaming namespace emission, typed suspend + type/const pairs for cyclic defs. |
| packages/evolution/src/blueprint/codegen-config.ts | Config API update: new unionStyle, remove old knobs, add imports.schema. |
| packages/evolution/test/Blueprint.test.ts | Adds regression tests targeting recursive multisig + “no double suspend”. |
| packages/evolution/test/spec/validators/multisig.ak | New recursive Aiken type to produce a real recursive blueprint. |
| packages/evolution/test/spec/plutus.json | Updates blueprint fixture with new compiled code and adds multisig recursive definitions/validators. |
| docs/public/sample-blueprint.json | Expands/updates the docs sample blueprint (now includes additional definitions). |
| docs/app/tools/blueprint-codegen/blueprint-codegen.tsx | UI updated to select unionStyle and fetch sample JSON via NEXT_PUBLIC_BASE_PATH. |
| .changeset/blueprint-codegen-recursive.md | Release notes for recursive type support + config/import changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!listDef.items) return "readonly Data.Data[]" | ||
| const itemEnc = inferEncodedTypeString( | ||
| listDef.items as BlueprintTypes.SchemaDefinitionType, | ||
| definitions, | ||
| depth + 1 | ||
| ) | ||
| return `readonly ${itemEnc}[]` |
There was a problem hiding this comment.
inferEncodedTypeString builds list encoded types as readonly ${itemEnc}[]. If itemEnc itself is a readonly-array type (e.g. nested list), this produces invalid TypeScript like readonly readonly Data.Data[][]. Consider emitting ReadonlyArray<${itemEnc}> or readonly (${itemEnc})[] to keep nesting valid.
| if (!listDef.items) return "readonly Data.Data[]" | |
| const itemEnc = inferEncodedTypeString( | |
| listDef.items as BlueprintTypes.SchemaDefinitionType, | |
| definitions, | |
| depth + 1 | |
| ) | |
| return `readonly ${itemEnc}[]` | |
| if (!listDef.items) return "ReadonlyArray<Data.Data>" | |
| const itemEnc = inferEncodedTypeString( | |
| listDef.items as BlueprintTypes.SchemaDefinitionType, | |
| definitions, | |
| depth + 1 | |
| ) | |
| return `ReadonlyArray<${itemEnc}>` |
| case "list": { | ||
| const listDef = def as BlueprintTypes.ListDefinitionType | ||
| const itemsSchema = listDef.items | ||
| if (!itemsSchema) { | ||
| return "TSchema.Array(PlutusData)" | ||
| } | ||
|
|
||
| const itemType = generateTSchema( | ||
| itemsSchema as BlueprintTypes.SchemaDefinitionType, | ||
| definitions, | ||
| config, | ||
| currentNamespace, | ||
| indent | ||
| indent, | ||
| undefined, | ||
| cyclicNames | ||
| ) | ||
| // Wrap in Schema.suspend to handle forward references | ||
| return config.useSuspend ? `TSchema.Array(Schema.suspend(() => ${itemType}))` : `TSchema.Array(${itemType})` | ||
| return `TSchema.Array(${itemType})` | ||
| } |
There was a problem hiding this comment.
generateTSchema assumes listDef.items is a single schema, but CIP-style JSON schemas can use tuple validation where items is an array (and the updated sample-blueprint.json includes Tuple$Int_Int with items: [...]). With an array here, generateTSchema currently falls through to PlutusData, generating TSchema.Array(PlutusData) and losing tuple structure. Consider detecting Array.isArray(listDef.items) and emitting TSchema.Tuple([...]) (or another appropriate tuple schema) and updating downstream handling accordingly.
| if (refName === "Data") return "PlutusData" | ||
|
|
||
| const refId = resolveReference(refName, currentNamespace, config) | ||
| return config.useSuspend ? `Schema.suspend(() => ${refId})` : refId | ||
| return cyclicNames.has(refName) ? `Schema.suspend((): Schema.Schema<${refId}, ${getEncodedType(refName, definitions)}> => ${refId})` : refId | ||
| } |
There was a problem hiding this comment.
generateTSchema wraps any $ref to a name in cyclicNames with Schema.suspend(...). This means even non-recursive edges that merely point at a cyclic definition (e.g. some unrelated type referencing MultiSig) will get suspended, which diverges from the stated goal of only suspending the inner references that actually close the cycle. Consider tracking a per-definition recursion stack (or SCC membership) and only emitting Schema.suspend when the reference would create a cycle from the current emission context.
| function visit(name: string): void { | ||
| if (visited.has(name)) return | ||
| if (visiting.has(name)) { | ||
| // Circular dependency detected - skip to avoid infinite loop | ||
| // Circular dependency detected — mark everything in the current visiting stack | ||
| for (const inStack of visiting) { | ||
| cyclicNames.add(inStack) | ||
| } | ||
| return |
There was a problem hiding this comment.
Cycle detection in topologicalSort currently marks every definition in the visiting set as cyclic when a back-edge is found. This can over-approximate cycles (e.g. A→B→C and C→B will mark A as cyclic too), causing extra type aliases and Schema.suspend wrappers. Consider tracking an explicit DFS stack and only marking nodes from the repeated node to the top of the stack, or using an SCC algorithm (Tarjan/Kosaraju) to compute the exact cyclic set.
Blueprint codegen produced invalid TypeScript for any Plutus type that references itself directly or through an intermediate type.
Schema.suspendthunks hardcodedData.Constras the encoded type — wrong for list types (readonly Data.Constr[]) — causing a TypeScript invariance error. Namespace-ordered types could also be emitted before their cross-namespace dependencies, and there was no test coverage for recursive Aiken types.The codegen now supports recursive type schemas end-to-end. Encoded types are inferred by walking the blueprint definition graph. Namespace emission uses a streaming topological emitter. Cyclic types emit a
type/constpair with typedSchema.suspendthunks only on inner field references. A newunionStyleconfig option ("Variant" | "Struct" | "TaggedStruct") replaces the removedforceVariantanduseSuspendfields. Generated imports reference@evolution-sdk/evolutioninstead ofeffectdirectly.Closes #214
Closes #156