Skip to content

feat(blueprint): recursive type schema codegen#215

Merged
solidsnakedev merged 6 commits intomainfrom
feat/blueprint-codegen-recursive
Mar 20, 2026
Merged

feat(blueprint): recursive type schema codegen#215
solidsnakedev merged 6 commits intomainfrom
feat/blueprint-codegen-recursive

Conversation

@solidsnakedev
Copy link
Collaborator

Blueprint codegen produced invalid TypeScript for any Plutus type that references itself directly or through an intermediate type. Schema.suspend thunks hardcoded Data.Constr as 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/const pair with typed Schema.suspend thunks only on inner field references. A new unionStyle config option ("Variant" | "Struct" | "TaggedStruct") replaces the removed forceVariant and useSuspend fields. Generated imports reference @evolution-sdk/evolution instead of effect directly.

Closes #214
Closes #156

Copilot AI review requested due to automatic review settings March 20, 2026 12:21
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 $ref emission using typed Schema.suspend thunks and inferred encoded I types.
  • 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 unionStyle config.

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.

Comment on lines +173 to +179
if (!listDef.items) return "readonly Data.Data[]"
const itemEnc = inferEncodedTypeString(
listDef.items as BlueprintTypes.SchemaDefinitionType,
definitions,
depth + 1
)
return `readonly ${itemEnc}[]`
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}>`

Copilot uses AI. Check for mistakes.
Comment on lines 292 to 309
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})`
}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 219 to 223
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
}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 858 to 865
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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@solidsnakedev solidsnakedev merged commit e56def0 into main Mar 20, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blueprint codegen: recursive types produce invalid output test: blueprint codegen is untested against recursive Aiken types

2 participants