From bdccdf4763c53e3be60e1aa4e97f85169ffb7430 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Tue, 6 Oct 2026 15:57:42 +0100 Subject: [PATCH] Detect cyclic defined types --- packages/errors/src/codes.ts | 2 + packages/errors/src/context.ts | 5 + packages/errors/src/messages.ts | 3 + packages/visitors-core/README.md | 41 ++++ packages/visitors-core/src/NodePath.ts | 13 ++ .../visitors-core/src/getByteSizeVisitor.ts | 29 ++- .../src/getDefinedTypeLinksVisitor.ts | 53 +++++ .../src/getHasFiniteValueVisitor.ts | 109 +++++++++ .../src/getInstructionDataFields.ts | 13 +- .../src/getMaxByteSizeVisitor.ts | 22 +- .../src/hasDefinedTypeFiniteValue.ts | 29 +++ packages/visitors-core/src/index.ts | 4 + .../visitors-core/src/isDefinedTypeCyclic.ts | 39 ++++ packages/visitors-core/test/NodePath.test.ts | 15 +- .../test/getByteSizeVisitor.test.ts | 35 +++ .../test/getDefinedTypeLinksVisitor.test.ts | 145 ++++++++++++ .../test/getHasFiniteValueVisitor.test.ts | 208 ++++++++++++++++++ .../test/getInstructionDataFields.test.ts | 34 ++- .../test/getMaxByteSizeVisitor.test.ts | 35 +++ .../test/hasDefinedTypeFiniteValue.test.ts | 79 +++++++ .../test/isDefinedTypeCyclic.test.ts | 190 ++++++++++++++++ packages/visitors/README.md | 2 + .../visitors/src/unwrapDefinedTypesVisitor.ts | 44 +++- .../test/unwrapDefinedTypesVisitor.test.ts | 94 ++++++++ 24 files changed, 1210 insertions(+), 33 deletions(-) create mode 100644 packages/visitors-core/src/getDefinedTypeLinksVisitor.ts create mode 100644 packages/visitors-core/src/getHasFiniteValueVisitor.ts create mode 100644 packages/visitors-core/src/hasDefinedTypeFiniteValue.ts create mode 100644 packages/visitors-core/src/isDefinedTypeCyclic.ts create mode 100644 packages/visitors-core/test/getDefinedTypeLinksVisitor.test.ts create mode 100644 packages/visitors-core/test/getHasFiniteValueVisitor.test.ts create mode 100644 packages/visitors-core/test/hasDefinedTypeFiniteValue.test.ts create mode 100644 packages/visitors-core/test/isDefinedTypeCyclic.test.ts diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index 9537576e9..c7f209241 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -41,6 +41,7 @@ export const CODAMA_ERROR__UNSUPPORTED_VERSION = 12; export const CODAMA_ERROR__INVALID_BRANDED_STRING = 13; export const CODAMA_ERROR__INJECTED_VALUE_NOT_PROVIDED = 14; export const CODAMA_ERROR__CANNOT_RESOLVE_PATH = 15; +export const CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING = 16; // Visitors-related errors. // Reserve error codes in the range [1200000-1200999]. @@ -163,6 +164,7 @@ export type CodamaErrorCode = | typeof CODAMA_ERROR__INVALID_BRANDED_STRING | typeof CODAMA_ERROR__LINKED_NODE_NOT_FOUND | typeof CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE + | typeof CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING | typeof CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS | typeof CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE | typeof CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index e2205ac69..371c54986 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -63,6 +63,7 @@ import { CODAMA_ERROR__INVALID_BRANDED_STRING, CODAMA_ERROR__LINKED_NODE_NOT_FOUND, CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, + CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS, CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE, CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND, @@ -259,6 +260,10 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{ [CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE]: { fsFunction: string; }; + [CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING]: { + /** The path that should contain a program node, e.g. to resolve links from. */ + path: readonly Node[]; + }; [CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS]: { dependencies: readonly string[]; message: string; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index 117072697..405772053 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -42,6 +42,7 @@ import { CODAMA_ERROR__INVALID_BRANDED_STRING, CODAMA_ERROR__LINKED_NODE_NOT_FOUND, CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, + CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS, CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE, CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND, @@ -135,6 +136,8 @@ export const CodamaErrorMessages: Readonly<{ [CODAMA_ERROR__LINKED_NODE_NOT_FOUND]: 'Could not find linked node [$name] from [$kind].', [CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE]: 'Node.js filesystem function [$fsFunction] is not available in your environment.', + [CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING]: + 'Expected a node path containing a program node, e.g. to resolve the links of its last node.', [CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS]: 'No version specified for the following dependencies: [$dependencies]. $message', [CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE]: 'Cannot render the encountered node of kind [$kind].', diff --git a/packages/visitors-core/README.md b/packages/visitors-core/README.md index e41f30bb9..75fc6b4ce 100644 --- a/packages/visitors-core/README.md +++ b/packages/visitors-core/README.md @@ -856,6 +856,47 @@ const size = visit(tupleTypeNode([numberTypeNode('u32'), publicKeyTypeNode()]), By default, this visitor will keep track of its own `NodeStack` but you may provide your own via the `stack` option in order to share the same `NodeStack` across multiple visitors. +### `getDefinedTypeLinksVisitor` + +The `getDefinedTypeLinksVisitor` lists the paths of the defined types a node links to, without following these links. Links that cannot be resolved are skipped, and so are the defined types of `EnumValueNodes`, which only reference them. It powers `isDefinedTypeCyclic`. + +```ts +// node = struct { value: link(amount), next: option } +const stack = new NodeStack(); +stack.visitPath([root, program, node], getDefinedTypeLinksVisitor(linkables, { stack })); +// ^ [[root, program, amount], [root, program, node]] +``` + +### `getHasFiniteValueVisitor` + +The `getHasFiniteValueVisitor` tells whether a type has at least one finite value, following links. Options, enums with another variant and collections without a fixed positive count stop a recursion, whereas a struct field or a fixed-size array linking back to its own type never does. It powers `hasDefinedTypeFiniteValue`. + +```ts +// list = struct { next: option }, loop = struct { next: link(loop) } +const stack = new NodeStack(); +stack.visitPath([root, program, list], getHasFiniteValueVisitor(linkables, { stack })); // true +stack.visitPath([root, program, loop], getHasFiniteValueVisitor(linkables, { stack })); // false +``` + +### `isDefinedTypeCyclic` + +The `isDefinedTypeCyclic` helper tells whether a defined type links back to itself, directly or through other defined types, following the links recorded in a `LinkableDictionary`. The path must contain the program defining the type, since links are resolved from it. + +```ts +// node = struct { value: u8, next: option } +isDefinedTypeCyclic([root, program, node], linkables); // true +``` + +### `hasDefinedTypeFiniteValue` + +The `hasDefinedTypeFiniteValue` helper tells whether a defined type has at least one finite value, using the `getHasFiniteValueVisitor`. Types lack one when they are cyclic without a way out, or when they contain an enum without variants. As above, the path must contain the program defining the type. + +```ts +// list = struct { next: option }, loop = struct { next: link(loop) } +hasDefinedTypeFiniteValue([root, program, list], linkables); // true +hasDefinedTypeFiniteValue([root, program, loop], linkables); // false +``` + ### `getResolvedInstructionInputsVisitor` The `getResolvedInstructionInputsVisitor` visits `InstructionNodes` only and returns an array of instruction accounts and arguments in the order they should be rendered for their default values to be resolved. diff --git a/packages/visitors-core/src/NodePath.ts b/packages/visitors-core/src/NodePath.ts index 9e7da8ec5..811d3cb45 100644 --- a/packages/visitors-core/src/NodePath.ts +++ b/packages/visitors-core/src/NodePath.ts @@ -1,3 +1,4 @@ +import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors'; import { assertIsNode, GetNodeFromKind, InstructionNode, isNode, Node, NodeKind, ProgramNode } from '@codama/nodes'; /** @@ -48,6 +49,18 @@ export function findProgramNodeFromPath(path: NodePath): ProgramNode | undefined return findLastNodeFromPath(path, 'programNode'); } +/** + * Assert that the path contains a program node, e.g. to resolve the links of + * its last node, which are resolved from the closest program of their path. + * + * @throws `CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING` when it does not. + */ +export function assertNodePathHasProgram(path: NodePath): void { + if (!findProgramNodeFromPath(path)) { + throw new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path }); + } +} + export function findInstructionNodeFromPath(path: NodePath): InstructionNode | undefined { return findLastNodeFromPath(path, 'instructionNode'); } diff --git a/packages/visitors-core/src/getByteSizeVisitor.ts b/packages/visitors-core/src/getByteSizeVisitor.ts index 76fe788e6..a7ea8e733 100644 --- a/packages/visitors-core/src/getByteSizeVisitor.ts +++ b/packages/visitors-core/src/getByteSizeVisitor.ts @@ -1,4 +1,11 @@ -import { CountNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS, RegisteredTypeNode } from '@codama/nodes'; +import { + CountNode, + DefinedTypeNode, + isNode, + isScalarEnum, + REGISTERED_TYPE_NODE_KINDS, + RegisteredTypeNode, +} from '@codama/nodes'; import { applyByteSizeTransforms, nodeHasTransforms } from './applyByteSizeTransforms'; import { extendVisitor } from './extendVisitor'; @@ -25,8 +32,9 @@ export function getByteSizeVisitor( ): Visitor { const stack = options.stack ?? new NodeStack(); - const visitedDefinedTypes = new Map(); - const definedTypeStack: string[] = []; + // Keyed by node rather than identifier: same-named types of different programs are distinct. + const visitedDefinedTypes = new Map(); + const visitingDefinedTypes = new Set(); const sumSizes = (values: (number | null)[]): number | null => values.reduce((all, one) => (all === null || one === null ? null : all + one), 0 as number | null); @@ -81,13 +89,11 @@ export function getByteSizeVisitor( }, visitDefinedType(node, { self }) { - if (visitedDefinedTypes.has(node.identifier)) { - return visitedDefinedTypes.get(node.identifier)!; - } - definedTypeStack.push(node.identifier); + if (visitedDefinedTypes.has(node)) return visitedDefinedTypes.get(node)!; + visitingDefinedTypes.add(node); const child = visit(node.type, self); - definedTypeStack.pop(); - visitedDefinedTypes.set(node.identifier, child); + visitingDefinedTypes.delete(node); + visitedDefinedTypes.set(node, child); return child; }, @@ -95,10 +101,9 @@ export function getByteSizeVisitor( // Fetch the linked type and return null if not found. const linkedDefinedPath = linkables.getPath(stack.getPath(node.kind)); if (!linkedDefinedPath) return null; - const linkedDefinedType = getLastNodeFromPath(linkedDefinedPath); - // This prevents infinite recursion by assuming cyclic types don't have a fixed size. - if (definedTypeStack.includes(linkedDefinedType.identifier)) return null; + // Cyclic types have no bounded size, and following them would never end. + if (visitingDefinedTypes.has(getLastNodeFromPath(linkedDefinedPath))) return null; return stack.visitPath(linkedDefinedPath, self); }, diff --git a/packages/visitors-core/src/getDefinedTypeLinksVisitor.ts b/packages/visitors-core/src/getDefinedTypeLinksVisitor.ts new file mode 100644 index 000000000..bba5a12bc --- /dev/null +++ b/packages/visitors-core/src/getDefinedTypeLinksVisitor.ts @@ -0,0 +1,53 @@ +import { DefinedTypeNode } from '@codama/nodes'; + +import { extendVisitor } from './extendVisitor'; +import { mergeVisitor } from './generated/mergeVisitor'; +import type { LinkableDictionary } from './LinkableDictionary'; +import { NodePath } from './NodePath'; +import { NodeStack } from './NodeStack'; +import { pipe } from './pipe'; +import { recordNodeStackVisitor } from './recordNodeStackVisitor'; +import { visit, Visitor } from './visitor'; + +/** + * List the paths of the defined types the visited node links to, without + * following these links. Links that cannot be resolved are skipped. + * + * Only links shaping a type are listed: the defined type of an + * `enumValueNode` merely references it, so it is left out. + * + * Links are resolved from the closest program of the current path, so visit + * nodes with their full path, e.g. using `stack.visitPath(path, visitor)` with + * the visitor's own stack. Links visited without a program are not resolved. + * + * @example + * ```ts + * // node = struct { value: link(amount), next: option } + * stack.visitPath([root, program, node], getDefinedTypeLinksVisitor(linkables, { stack })); + * // [[root, program, amount], [root, program, node]] + * ``` + */ +export function getDefinedTypeLinksVisitor( + linkables: LinkableDictionary, + options: { stack?: NodeStack } = {}, +): Visitor[]> { + const stack = options.stack ?? new NodeStack(); + + return pipe( + mergeVisitor( + () => [] as NodePath[], + (_, values) => values.flat(), + ), + v => + extendVisitor(v, { + visitDefinedTypeLink(node, { next }) { + const linkedPath = linkables.getPath(stack.getPath(node.kind)); + return [...(linkedPath ? [linkedPath] : []), ...next(node)]; + }, + visitEnumValue(node, { self }) { + return node.value ? visit(node.value, self) : []; + }, + }), + v => recordNodeStackVisitor(v, stack), + ); +} diff --git a/packages/visitors-core/src/getHasFiniteValueVisitor.ts b/packages/visitors-core/src/getHasFiniteValueVisitor.ts new file mode 100644 index 000000000..a758144c2 --- /dev/null +++ b/packages/visitors-core/src/getHasFiniteValueVisitor.ts @@ -0,0 +1,109 @@ +import { CountNode, DefinedTypeNode, isNode } from '@codama/nodes'; + +import { extendVisitor } from './extendVisitor'; +import { mergeVisitor } from './generated/mergeVisitor'; +import type { LinkableDictionary } from './LinkableDictionary'; +import { getLastNodeFromPath } from './NodePath'; +import { NodeStack } from './NodeStack'; +import { pipe } from './pipe'; +import { recordNodeStackVisitor } from './recordNodeStackVisitor'; +import { visit, Visitor } from './visitor'; + +/** + * Whether the visited type has at least one finite value, following links. + * + * Only the shape of values matters: options may be `None`, enums may pick any + * variant and collections without a fixed positive count may be empty, so all + * of them stop a recursion. Transforms, prefixes and sizes only prevent a + * value from existing when their constants link back to a type being visited, + * since those constants could then never be created. Links that cannot be + * resolved count as finite. + * + * A link back to a defined type being visited has no finite value on its own: + * a finite value never needs to nest a type within itself, so another branch + * must provide one. Enums without variants have no value at all. + * + * Links are resolved from the closest program of the current path, so visit + * nodes with their full path, e.g. using `stack.visitPath(path, visitor)` with + * the visitor's own stack. Links visited without a program are not resolved. + * + * @example + * ```ts + * // list = struct { next: option }, loop = struct { next: link(loop) } + * stack.visitPath([root, program, list], getHasFiniteValueVisitor(linkables, { stack })); // true + * stack.visitPath([root, program, loop], getHasFiniteValueVisitor(linkables, { stack })); // false + * ``` + */ +export function getHasFiniteValueVisitor( + linkables: LinkableDictionary, + options: { stack?: NodeStack } = {}, +): Visitor { + const stack = options.stack ?? new NodeStack(); + const visitingDefinedTypes = new Set(); + // Only `true` is cached: a `false` may only hold because of the defined types being + // visited, whereas a `true` holds whichever are, so it never needs computing again. + const finiteDefinedTypes = new Set(); + + return pipe( + mergeVisitor( + () => true, + (_, values) => values.every(Boolean), + ), + v => + extendVisitor(v, { + visitArrayType(node, { self }) { + return !hasFixedPositiveCount(node.count) || visit(node.item, self); + }, + + visitDefinedType(node, { next }) { + if (finiteDefinedTypes.has(node)) return true; + visitingDefinedTypes.add(node); + const hasFiniteValue = next(node); + visitingDefinedTypes.delete(node); + if (hasFiniteValue) finiteDefinedTypes.add(node); + return hasFiniteValue; + }, + + visitDefinedTypeLink(node, { self }) { + const linkedPath = linkables.getPath(stack.getPath(node.kind)); + if (!linkedPath) return true; + if (visitingDefinedTypes.has(getLastNodeFromPath(linkedPath))) return false; + return stack.visitPath(linkedPath, self); + }, + + visitEnumType(node, { self }) { + return (node.variants ?? []).some(variant => visit(variant, self)); + }, + + // The defined type of an enum value only references it. + visitEnumValue() { + return true; + }, + + visitMapType(node, { self }) { + return !hasFixedPositiveCount(node.count) || (visit(node.key, self) && visit(node.value, self)); + }, + + visitOptionType() { + return true; + }, + + visitRemainderOptionType() { + return true; + }, + + visitSetType(node, { self }) { + return !hasFixedPositiveCount(node.count) || visit(node.item, self); + }, + + visitZeroableOptionType() { + return true; + }, + }), + v => recordNodeStackVisitor(v, stack), + ); +} + +function hasFixedPositiveCount(count: CountNode): boolean { + return isNode(count, 'fixedCountNode') && count.value > 0; +} diff --git a/packages/visitors-core/src/getInstructionDataFields.ts b/packages/visitors-core/src/getInstructionDataFields.ts index c323b8e0c..843662748 100644 --- a/packages/visitors-core/src/getInstructionDataFields.ts +++ b/packages/visitors-core/src/getInstructionDataFields.ts @@ -17,7 +17,8 @@ export type InstructionDataField = { * * Fields are only addressable where the data type resolves to a struct, so * nested fields are listed for struct-typed (or struct-linked) fields only. - * Each defined type is followed at most once, which guards against cycles. + * Each defined type is followed at most once per branch, which guards against + * cycles while still expanding sibling fields linking the same type. * * @param instructionPath - The path to the instruction. It must contain the * instruction's `programNode` for linked data to be followed; otherwise a @@ -37,8 +38,9 @@ export function getInstructionDataFields( ): InstructionDataField[] { const fields: InstructionDataField[] = []; const stack = new NodeStack(instructionPath); - // Keyed by node rather than identifier: same-named types of different programs are distinct. - const walkedDefinedTypes = new Set(); + // The defined types followed on the current branch, keyed by node rather than + // identifier: same-named types of different programs are distinct. + const followedDefinedTypes = new Set(); const walk = (type: TypeNode | undefined, prefix: string): void => { if (!type) return; @@ -46,9 +48,10 @@ export function getInstructionDataFields( const linkedPath = linkables.getPath([...stack.getPath(), type]); if (!linkedPath) return; const definedType = getLastNodeFromPath(linkedPath); - if (walkedDefinedTypes.has(definedType)) return; - walkedDefinedTypes.add(definedType); + if (followedDefinedTypes.has(definedType)) return; + followedDefinedTypes.add(definedType); stack.withPath(linkedPath, () => walk(definedType.type, prefix)); + followedDefinedTypes.delete(definedType); return; } if (!isNode(type, 'structTypeNode')) return; diff --git a/packages/visitors-core/src/getMaxByteSizeVisitor.ts b/packages/visitors-core/src/getMaxByteSizeVisitor.ts index 2c1734b1c..8d6c38aea 100644 --- a/packages/visitors-core/src/getMaxByteSizeVisitor.ts +++ b/packages/visitors-core/src/getMaxByteSizeVisitor.ts @@ -1,4 +1,4 @@ -import { CountNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS } from '@codama/nodes'; +import { CountNode, DefinedTypeNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS } from '@codama/nodes'; import { applyByteSizeTransforms, nodeHasTransforms } from './applyByteSizeTransforms'; import { extendVisitor } from './extendVisitor'; @@ -18,8 +18,9 @@ export function getMaxByteSizeVisitor( ): Visitor { const stack = options.stack ?? new NodeStack(); - const visitedDefinedTypes = new Map(); - const definedTypeStack: string[] = []; + // Keyed by node rather than identifier: same-named types of different programs are distinct. + const visitedDefinedTypes = new Map(); + const visitingDefinedTypes = new Set(); const sumSizes = (values: (number | null)[]): number | null => values.reduce((all, one) => (all === null || one === null ? null : all + one), 0 as number | null); @@ -74,13 +75,11 @@ export function getMaxByteSizeVisitor( }, visitDefinedType(node, { self }) { - if (visitedDefinedTypes.has(node.identifier)) { - return visitedDefinedTypes.get(node.identifier)!; - } - definedTypeStack.push(node.identifier); + if (visitedDefinedTypes.has(node)) return visitedDefinedTypes.get(node)!; + visitingDefinedTypes.add(node); const child = visit(node.type, self); - definedTypeStack.pop(); - visitedDefinedTypes.set(node.identifier, child); + visitingDefinedTypes.delete(node); + visitedDefinedTypes.set(node, child); return child; }, @@ -88,10 +87,9 @@ export function getMaxByteSizeVisitor( // Fetch the linked type and return null if not found. const linkedDefinedPath = linkables.getPath(stack.getPath(node.kind)); if (!linkedDefinedPath) return null; - const linkedDefinedType = getLastNodeFromPath(linkedDefinedPath); - // This prevents infinite recursion by assuming cyclic types don't have a fixed size. - if (definedTypeStack.includes(linkedDefinedType.identifier)) return null; + // Cyclic types have no bounded size, and following them would never end. + if (visitingDefinedTypes.has(getLastNodeFromPath(linkedDefinedPath))) return null; return stack.visitPath(linkedDefinedPath, self); }, diff --git a/packages/visitors-core/src/hasDefinedTypeFiniteValue.ts b/packages/visitors-core/src/hasDefinedTypeFiniteValue.ts new file mode 100644 index 000000000..62d87c4a4 --- /dev/null +++ b/packages/visitors-core/src/hasDefinedTypeFiniteValue.ts @@ -0,0 +1,29 @@ +import { DefinedTypeNode } from '@codama/nodes'; + +import { getHasFiniteValueVisitor } from './getHasFiniteValueVisitor'; +import { LinkableDictionary } from './LinkableDictionary'; +import { assertNodePathHasProgram, NodePath } from './NodePath'; +import { NodeStack } from './NodeStack'; + +/** + * Whether the defined type at the given path has at least one finite value, + * following the recorded links (see `getHasFiniteValueVisitor`). Types lack + * one when they are cyclic without a way out, e.g. `a = link(a)` or + * `node = struct { next: link(node) }`, or when they contain an enum without + * variants. + * + * The path must contain the program defining the type, since links are + * resolved from it; otherwise, `CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING` is thrown. + * + * @example + * ```ts + * // list = struct { next: option }, loop = struct { next: link(loop) } + * hasDefinedTypeFiniteValue([root, program, list], linkables); // true + * hasDefinedTypeFiniteValue([root, program, loop], linkables); // false + * ``` + */ +export function hasDefinedTypeFiniteValue(path: NodePath, linkables: LinkableDictionary): boolean { + assertNodePathHasProgram(path); + const stack = new NodeStack(); + return stack.visitPath(path, getHasFiniteValueVisitor(linkables, { stack })); +} diff --git a/packages/visitors-core/src/index.ts b/packages/visitors-core/src/index.ts index 5863446ed..3b4e429a2 100644 --- a/packages/visitors-core/src/index.ts +++ b/packages/visitors-core/src/index.ts @@ -7,15 +7,19 @@ export * from './extendVisitor'; export * from './generated'; export * from './getByteSizeVisitor'; export * from './getDebugStringVisitor'; +export * from './getDefinedTypeLinksVisitor'; +export * from './getHasFiniteValueVisitor'; export * from './getInstructionDataFields'; export * from './getMaxByteSizeVisitor'; export * from './getResolvedInstructionInputsVisitor'; export * from './getUniqueHashStringVisitor'; +export * from './hasDefinedTypeFiniteValue'; // Wrapper layering semantic overrides on top of the generated raw // `identityVisitor`; shadows the barrel re-export above. export { identityVisitor } from './identityVisitor'; export * from './interceptFirstVisitVisitor'; export * from './interceptVisitor'; +export * from './isDefinedTypeCyclic'; export * from './LinkableDictionary'; export * from './mapVisitor'; export * from './NodePath'; diff --git a/packages/visitors-core/src/isDefinedTypeCyclic.ts b/packages/visitors-core/src/isDefinedTypeCyclic.ts new file mode 100644 index 000000000..7e4a0b337 --- /dev/null +++ b/packages/visitors-core/src/isDefinedTypeCyclic.ts @@ -0,0 +1,39 @@ +import { DefinedTypeNode } from '@codama/nodes'; + +import { getDefinedTypeLinksVisitor } from './getDefinedTypeLinksVisitor'; +import { LinkableDictionary } from './LinkableDictionary'; +import { assertNodePathHasProgram, getLastNodeFromPath, NodePath } from './NodePath'; +import { NodeStack } from './NodeStack'; + +/** + * Whether the defined type at the given path links back to itself, directly + * or through other defined types, following the recorded links (see + * `getDefinedTypeLinksVisitor`). + * + * The path must contain the program defining the type, since links are + * resolved from it; otherwise, `CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING` is thrown. + * + * @example + * ```ts + * // node = struct { value: u8, next: option } + * isDefinedTypeCyclic([root, program, node], linkables); // true + * ``` + */ +export function isDefinedTypeCyclic(path: NodePath, linkables: LinkableDictionary): boolean { + assertNodePathHasProgram(path); + const target = getLastNodeFromPath(path); + const stack = new NodeStack(); + const linksVisitor = getDefinedTypeLinksVisitor(linkables, { stack }); + const followed = new Set(); + const pending = [path]; + while (pending.length > 0) { + for (const linkedPath of stack.visitPath(pending.pop()!, linksVisitor)) { + const linkedType = getLastNodeFromPath(linkedPath); + if (linkedType === target) return true; + if (followed.has(linkedType)) continue; + followed.add(linkedType); + pending.push(linkedPath); + } + } + return false; +} diff --git a/packages/visitors-core/test/NodePath.test.ts b/packages/visitors-core/test/NodePath.test.ts index 602650d5c..e877e7c50 100644 --- a/packages/visitors-core/test/NodePath.test.ts +++ b/packages/visitors-core/test/NodePath.test.ts @@ -1,3 +1,4 @@ +import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors'; import { accountNode, type AccountNode, @@ -10,7 +11,7 @@ import { } from '@codama/nodes'; import { expect, expectTypeOf, test } from 'vitest'; -import { getLastNodeFromPath, type NodePath } from '../src'; +import { assertNodePathHasProgram, getLastNodeFromPath, type NodePath } from '../src'; const program = programNode({ identifier: 'myProgram', publicKey: '1111' }); const root = rootNode(program); @@ -48,3 +49,15 @@ test('it keeps optional node types as either kind of path', () => { readonly Node[] | readonly [...(readonly Node[]), PdaNode] >(); }); + +test('it accepts paths containing a program node', () => { + const account = accountNode({ identifier: 'myAccount' }); + expect(() => assertNodePathHasProgram([root, program, account])).not.toThrow(); +}); + +test('it rejects paths without a program node', () => { + const account = accountNode({ identifier: 'myAccount' }); + expect(() => assertNodePathHasProgram([account])).toThrow( + new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path: [account] }), + ); +}); diff --git a/packages/visitors-core/test/getByteSizeVisitor.test.ts b/packages/visitors-core/test/getByteSizeVisitor.test.ts index 4ca591ebc..c5960135d 100644 --- a/packages/visitors-core/test/getByteSizeVisitor.test.ts +++ b/packages/visitors-core/test/getByteSizeVisitor.test.ts @@ -209,6 +209,41 @@ describe('definedTypeLinkNode', () => { expectSizeWithContext([context, definedTypeLinkNode('myType')], null); }); + test('it follows links to same-named types of other programs', () => { + const typeA = definedTypeNode({ + identifier: 'myType', + type: definedTypeLinkNode('myType', { program: programLinkNode('programB') }), + }); + const programA = programNode({ definedTypes: [typeA], identifier: 'programA', publicKey: '1111' }); + const programB = programNode({ + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u32') })], + identifier: 'programB', + publicKey: '2222', + }); + const context = rootNode(programA, { additionalPrograms: [programB] }); + + expectSizeWithContext([context, programA, typeA], 4); + }); + test('it returns null if the linked type is recursive', () => { + const context = programNode({ + definedTypes: [ + definedTypeNode({ + identifier: 'list', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u8') }), + structFieldTypeNode({ + identifier: 'next', + type: zeroableOptionTypeNode(definedTypeLinkNode('list')), + }), + ]), + }), + ], + identifier: 'myProgram', + publicKey: '1111', + }); + + expectSizeWithContext([context, definedTypeLinkNode('list')], null); + }); test('it follows linked nodes using the correct paths when jumping between programs', () => { const typeA = definedTypeNode({ identifier: 'typeA', diff --git a/packages/visitors-core/test/getDefinedTypeLinksVisitor.test.ts b/packages/visitors-core/test/getDefinedTypeLinksVisitor.test.ts new file mode 100644 index 000000000..cc0818427 --- /dev/null +++ b/packages/visitors-core/test/getDefinedTypeLinksVisitor.test.ts @@ -0,0 +1,145 @@ +import { + constantValueNode, + definedTypeLinkNode, + definedTypeNode, + enumTypeNode, + enumValueNode, + enumVariantTypeNode, + hiddenPrefixTransformNode, + integerTypeNode, + integerValueNode, + optionTypeNode, + programLinkNode, + programNode, + rootNode, + stringTypeNode, + structFieldTypeNode, + structTypeNode, + tupleTypeNode, +} from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { getDefinedTypeLinksVisitor, getRecordLinkablesVisitor, LinkableDictionary, NodeStack, visit } from '../src'; + +test('it lists the defined types linked by a type without following them', () => { + // Given a type linking to itself, to an alias, and to a type of another program within a transform. + const amount = definedTypeNode({ identifier: 'amount', type: integerTypeNode('u64') }); + const length = definedTypeNode({ identifier: 'length', type: integerTypeNode('u32') }); + const node = definedTypeNode({ + identifier: 'node', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'value', type: definedTypeLinkNode('amount') }), + structFieldTypeNode({ + identifier: 'label', + type: stringTypeNode('utf8', { + transforms: [ + hiddenPrefixTransformNode([ + constantValueNode( + definedTypeLinkNode('length', { program: programLinkNode('other') }), + integerValueNode('0'), + ), + ]), + ], + }), + }), + structFieldTypeNode({ + identifier: 'next', + type: optionTypeNode(definedTypeLinkNode('node'), { prefix: integerTypeNode('u8') }), + }), + ]), + }); + const program = programNode({ definedTypes: [node, amount], identifier: 'test', publicKey: '1111' }); + const otherProgram = programNode({ definedTypes: [length], identifier: 'other', publicKey: '2222' }); + const root = rootNode(program, { additionalPrograms: [otherProgram] }); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we list its links. + const stack = new NodeStack(); + const paths = stack.visitPath([root, program, node], getDefinedTypeLinksVisitor(linkables, { stack })); + + // Then we get the path of each linked type, in order. + expect(paths).toStrictEqual([ + [root, program, amount], + [root, otherProgram, length], + [root, program, node], + ]); +}); + +test('it ignores the defined types of enum values and unresolved links', () => { + // Given an enum whose variant defaults to one of its own values, next to a missing link. + const direction = definedTypeNode({ + identifier: 'direction', + type: enumTypeNode([ + enumVariantTypeNode('up'), + enumVariantTypeNode('down', { + data: structTypeNode([ + structFieldTypeNode({ + defaultValue: enumValueNode('direction', 'up'), + identifier: 'then', + type: integerTypeNode('u8'), + }), + structFieldTypeNode({ identifier: 'missing', type: definedTypeLinkNode('missing') }), + ]), + }), + ]), + }); + const program = programNode({ definedTypes: [direction], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we list its links, then there are none. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, direction], getDefinedTypeLinksVisitor(linkables, { stack }))).toStrictEqual( + [], + ); +}); + +test('it lists links nested deep within a type', () => { + // Given a link nested within an option, a tuple, an enum variant and a struct. + const amount = definedTypeNode({ identifier: 'amount', type: integerTypeNode('u64') }); + const payment = definedTypeNode({ + identifier: 'payment', + type: structTypeNode([ + structFieldTypeNode({ + identifier: 'kind', + type: enumTypeNode([ + enumVariantTypeNode('free'), + enumVariantTypeNode('paid', { + data: tupleTypeNode([ + integerTypeNode('u8'), + optionTypeNode(definedTypeLinkNode('amount'), { prefix: integerTypeNode('u8') }), + ]), + }), + ]), + }), + ]), + }); + const program = programNode({ definedTypes: [payment, amount], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we list its links. + const stack = new NodeStack(); + const paths = stack.visitPath([root, program, payment], getDefinedTypeLinksVisitor(linkables, { stack })); + + // Then the nested link is listed. + expect(paths).toStrictEqual([[root, program, amount]]); +}); + +test('it cannot resolve links when visited without the path of the type', () => { + // Given a type linking to an alias, both recorded. + const amount = definedTypeNode({ identifier: 'amount', type: integerTypeNode('u64') }); + const wrapper = definedTypeNode({ identifier: 'wrapper', type: definedTypeLinkNode('amount') }); + const program = programNode({ definedTypes: [wrapper, amount], identifier: 'test', publicKey: '1111' }); + const linkables = new LinkableDictionary(); + visit(rootNode(program), getRecordLinkablesVisitor(linkables)); + + // When we visit it on its own, without its program on the path. + const paths = visit(wrapper, getDefinedTypeLinksVisitor(linkables)); + + // Then its link cannot be resolved, which is why it must be visited with its full path. + expect(paths).toStrictEqual([]); +}); diff --git a/packages/visitors-core/test/getHasFiniteValueVisitor.test.ts b/packages/visitors-core/test/getHasFiniteValueVisitor.test.ts new file mode 100644 index 000000000..eb2e70384 --- /dev/null +++ b/packages/visitors-core/test/getHasFiniteValueVisitor.test.ts @@ -0,0 +1,208 @@ +import { + arrayTypeNode, + definedTypeLinkNode, + definedTypeNode, + enumTypeNode, + enumVariantTypeNode, + fixedCountNode, + integerTypeNode, + mapTypeNode, + optionTypeNode, + prefixedCountNode, + programNode, + remainderCountNode, + remainderOptionTypeNode, + rootNode, + setTypeNode, + structFieldTypeNode, + structTypeNode, + tupleTypeNode, + TypeNode, + zeroableOptionTypeNode, +} from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { getHasFiniteValueVisitor, getRecordLinkablesVisitor, LinkableDictionary, NodeStack, visit } from '../src'; + +const u8 = integerTypeNode('u8'); +const self = definedTypeLinkNode('self'); +const field = (type: TypeNode) => structTypeNode([structFieldTypeNode({ identifier: 'value', type })]); + +test.each<[string, TypeNode]>([ + ['non-cyclic types', field(u8)], + ['options', field(optionTypeNode(self, { prefix: u8 }))], + ['remainder options', field(remainderOptionTypeNode(self))], + ['zeroable options', field(zeroableOptionTypeNode(self))], + ['prefixed arrays', arrayTypeNode(self, prefixedCountNode(u8))], + ['remainder sets', setTypeNode(self, remainderCountNode())], + ['empty fixed arrays', arrayTypeNode(self, fixedCountNode(0))], + ['prefixed maps', mapTypeNode(u8, self, prefixedCountNode(u8))], + [ + 'enums with another variant', + enumTypeNode([enumVariantTypeNode('nil'), enumVariantTypeNode('cons', { data: field(self) })]), + ], + ['links it cannot resolve', field(definedTypeLinkNode('missing'))], +])('it finds a finite value through %s', (_, type) => { + // Given a defined type named `self` with the given type. + const definedType = definedTypeNode({ identifier: 'self', type }); + const program = programNode({ definedTypes: [definedType], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we visit it, then it has a finite value. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, definedType], getHasFiniteValueVisitor(linkables, { stack }))).toBe(true); +}); + +test.each<[string, TypeNode]>([ + ['aliases of themselves', self], + ['struct fields', field(self)], + ['tuple items', tupleTypeNode([u8, self])], + ['fixed arrays', arrayTypeNode(self, fixedCountNode(2))], + ['fixed maps', mapTypeNode(self, u8, fixedCountNode(1))], + ['enums whose every variant recurses', enumTypeNode([enumVariantTypeNode('cons', { data: field(self) })])], + ['enums without variants', field(enumTypeNode([]))], +])('it finds no finite value through %s', (_, type) => { + // Given a defined type named `self` with the given type. + const definedType = definedTypeNode({ identifier: 'self', type }); + const program = programNode({ definedTypes: [definedType], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we visit it, then it has no finite value. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, definedType], getHasFiniteValueVisitor(linkables, { stack }))).toBe(false); +}); + +test('it finds no finite value through a type linking to one without', () => { + // Given a type that is not cyclic itself, but links to a loop. + const loop = definedTypeNode({ + identifier: 'loop', + type: structTypeNode([structFieldTypeNode({ identifier: 'next', type: definedTypeLinkNode('loop') })]), + }); + const usesLoop = definedTypeNode({ identifier: 'usesLoop', type: definedTypeLinkNode('loop') }); + const program = programNode({ definedTypes: [loop, usesLoop], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we visit it, then it has no finite value. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, usesLoop], getHasFiniteValueVisitor(linkables, { stack }))).toBe(false); +}); + +test('it finds no finite value through a cycle across defined types without a way out', () => { + // Given `a -> b -> a` through struct fields only. + const a = definedTypeNode({ identifier: 'a', type: field(definedTypeLinkNode('b')) }); + const b = definedTypeNode({ identifier: 'b', type: field(definedTypeLinkNode('a')) }); + const program = programNode({ definedTypes: [a, b], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we visit one of them, then it has no finite value. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, a], getHasFiniteValueVisitor(linkables, { stack }))).toBe(false); +}); + +test('it finds a finite value through another branch of a cycle across defined types', () => { + // Given an enum whose first variant recurses through another type, and whose second ends. + const tree = definedTypeNode({ + identifier: 'tree', + type: enumTypeNode([ + enumVariantTypeNode('node', { data: field(definedTypeLinkNode('branch')) }), + enumVariantTypeNode('leaf'), + ]), + }); + const branch = definedTypeNode({ identifier: 'branch', type: field(definedTypeLinkNode('tree')) }); + const program = programNode({ definedTypes: [tree, branch], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we visit the type that only reaches the way out through the other one, then it has a finite value. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, branch], getHasFiniteValueVisitor(linkables, { stack }))).toBe(true); +}); + +test('it finds a finite value through types shared by sibling branches', () => { + // Given a list whose two fields link to the same list type, each of them able to end. + const list = definedTypeNode({ + identifier: 'list', + type: structTypeNode([ + structFieldTypeNode({ + identifier: 'left', + type: optionTypeNode(definedTypeLinkNode('list'), { prefix: u8 }), + }), + structFieldTypeNode({ identifier: 'right', type: definedTypeLinkNode('leaf') }), + ]), + }); + const leaf = definedTypeNode({ + identifier: 'leaf', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'a', type: definedTypeLinkNode('shared') }), + structFieldTypeNode({ identifier: 'b', type: definedTypeLinkNode('shared') }), + ]), + }); + const shared = definedTypeNode({ + identifier: 'shared', + type: optionTypeNode(definedTypeLinkNode('list'), { prefix: u8 }), + }); + const program = programNode({ definedTypes: [list, leaf, shared], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we visit the list, then it has a finite value. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, list], getHasFiniteValueVisitor(linkables, { stack }))).toBe(true); +}); + +test('it finds no finite value through types shared by sibling branches without a way out', () => { + // Given a loop whose two fields link to the same type, which links back to the loop. + const loop = definedTypeNode({ + identifier: 'loop', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'a', type: definedTypeLinkNode('shared') }), + structFieldTypeNode({ identifier: 'b', type: definedTypeLinkNode('shared') }), + ]), + }); + const shared = definedTypeNode({ identifier: 'shared', type: field(definedTypeLinkNode('loop')) }); + const program = programNode({ definedTypes: [loop, shared], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we visit the loop, then it has no finite value. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, loop], getHasFiniteValueVisitor(linkables, { stack }))).toBe(false); +}); + +test('it finds a finite value for a type first reached within a cycle it does not need', () => { + // Given a tree whose recursive variant reaches a branch first, and a root using both. + const tree = definedTypeNode({ + identifier: 'tree', + type: enumTypeNode([ + enumVariantTypeNode('node', { data: field(definedTypeLinkNode('branch')) }), + enumVariantTypeNode('leaf'), + ]), + }); + const branch = definedTypeNode({ identifier: 'branch', type: field(definedTypeLinkNode('tree')) }); + const forest = definedTypeNode({ + identifier: 'forest', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'tree', type: definedTypeLinkNode('tree') }), + structFieldTypeNode({ identifier: 'branch', type: definedTypeLinkNode('branch') }), + ]), + }); + const program = programNode({ definedTypes: [tree, branch, forest], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we visit the root, then the branch is not mistaken for having no finite value. + const stack = new NodeStack(); + expect(stack.visitPath([root, program, forest], getHasFiniteValueVisitor(linkables, { stack }))).toBe(true); +}); diff --git a/packages/visitors-core/test/getInstructionDataFields.test.ts b/packages/visitors-core/test/getInstructionDataFields.test.ts index 5191589db..a201e3064 100644 --- a/packages/visitors-core/test/getInstructionDataFields.test.ts +++ b/packages/visitors-core/test/getInstructionDataFields.test.ts @@ -47,7 +47,7 @@ test('it lists nested data fields with their paths, following links', () => { expect(paths).toStrictEqual(['amount', 'config', 'config.fee', 'extra', 'extra.flag']); }); -test('it follows each defined type at most once', () => { +test('it does not follow cycles', () => { // Given an instruction whose data is a self-referencing defined type. const instruction = instructionNode({ data: definedTypeLinkNode('node'), identifier: 'myInstruction' }); const program = programNode({ @@ -68,6 +68,38 @@ test('it follows each defined type at most once', () => { expect(getInstructionDataFields([program, instruction], linkables).map(({ path }) => path)).toStrictEqual(['next']); }); +test('it follows a defined type once per branch', () => { + // Given sibling fields linking to the same defined type. + const instruction = instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'a', type: definedTypeLinkNode('config') }), + structFieldTypeNode({ identifier: 'b', type: definedTypeLinkNode('config') }), + ]), + identifier: 'myInstruction', + }); + const program = programNode({ + definedTypes: [ + definedTypeNode({ + identifier: 'config', + type: structTypeNode([structFieldTypeNode({ identifier: 'fee', type: integerTypeNode('u16') })]), + }), + ], + identifier: 'myProgram', + instructions: [instruction], + publicKey: '1111', + }); + const linkables = new LinkableDictionary(); + visit(program, getRecordLinkablesVisitor(linkables)); + + // When we list its data fields, then both siblings are expanded. + expect(getInstructionDataFields([program, instruction], linkables).map(({ path }) => path)).toStrictEqual([ + 'a', + 'a.fee', + 'b', + 'b.fee', + ]); +}); + test('it follows same-named defined types from different programs', () => { // Given instruction data linking to a type that links to a same-named type of another program. const instruction = instructionNode({ data: definedTypeLinkNode('args'), identifier: 'myInstruction' }); diff --git a/packages/visitors-core/test/getMaxByteSizeVisitor.test.ts b/packages/visitors-core/test/getMaxByteSizeVisitor.test.ts index 5d59c5caa..ac01c5dea 100644 --- a/packages/visitors-core/test/getMaxByteSizeVisitor.test.ts +++ b/packages/visitors-core/test/getMaxByteSizeVisitor.test.ts @@ -212,6 +212,41 @@ describe('definedTypeLinkNode', () => { expectMaxSizeWithContext([context, definedTypeLinkNode('myType')], null); }); + test('it follows links to same-named types of other programs', () => { + const typeA = definedTypeNode({ + identifier: 'myType', + type: definedTypeLinkNode('myType', { program: programLinkNode('programB') }), + }); + const programA = programNode({ definedTypes: [typeA], identifier: 'programA', publicKey: '1111' }); + const programB = programNode({ + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u32') })], + identifier: 'programB', + publicKey: '2222', + }); + const context = rootNode(programA, { additionalPrograms: [programB] }); + + expectMaxSizeWithContext([context, programA, typeA], 4); + }); + test('it returns null if the linked type is recursive', () => { + const context = programNode({ + definedTypes: [ + definedTypeNode({ + identifier: 'list', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u8') }), + structFieldTypeNode({ + identifier: 'next', + type: zeroableOptionTypeNode(definedTypeLinkNode('list')), + }), + ]), + }), + ], + identifier: 'myProgram', + publicKey: '1111', + }); + + expectMaxSizeWithContext([context, definedTypeLinkNode('list')], null); + }); test('it follows linked nodes using the correct paths when jumping between programs', () => { const typeA = definedTypeNode({ identifier: 'typeA', diff --git a/packages/visitors-core/test/hasDefinedTypeFiniteValue.test.ts b/packages/visitors-core/test/hasDefinedTypeFiniteValue.test.ts new file mode 100644 index 000000000..f61e6d9d5 --- /dev/null +++ b/packages/visitors-core/test/hasDefinedTypeFiniteValue.test.ts @@ -0,0 +1,79 @@ +import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors'; +import { + definedTypeLinkNode, + definedTypeNode, + integerTypeNode, + optionTypeNode, + programNode, + rootNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { getRecordLinkablesVisitor, hasDefinedTypeFiniteValue, LinkableDictionary, visit } from '../src'; + +test('it finds a finite value for types whose recursion may stop', () => { + // Given a list that may end with `None`. + const list = definedTypeNode({ + identifier: 'list', + type: structTypeNode([ + structFieldTypeNode({ + identifier: 'next', + type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }), + }), + ]), + }); + const program = programNode({ definedTypes: [list], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether it has a finite value, then it does. + expect(hasDefinedTypeFiniteValue([root, program, list], linkables)).toBe(true); +}); + +test('it finds no finite value for types whose recursion never stops', () => { + // Given a loop whose every value nests another one. + const loop = definedTypeNode({ + identifier: 'loop', + type: structTypeNode([structFieldTypeNode({ identifier: 'next', type: definedTypeLinkNode('loop') })]), + }); + const program = programNode({ definedTypes: [loop], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether it has a finite value, then it does not. + expect(hasDefinedTypeFiniteValue([root, program, loop], linkables)).toBe(false); +}); + +test('it sees types recorded after a previous call', () => { + // Given a type linking to a loop that is not recorded yet. + const usesLoop = definedTypeNode({ identifier: 'usesLoop', type: definedTypeLinkNode('loop') }); + const loop = definedTypeNode({ identifier: 'loop', type: definedTypeLinkNode('loop') }); + const program = programNode({ definedTypes: [usesLoop, loop], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + linkables.recordPath([root, program, usesLoop]); + expect(hasDefinedTypeFiniteValue([root, program, usesLoop], linkables)).toBe(true); + + // When we record the loop. + linkables.recordPath([root, program, loop]); + + // Then it no longer has a finite value. + expect(hasDefinedTypeFiniteValue([root, program, usesLoop], linkables)).toBe(false); +}); + +test('it throws when the path has no program', () => { + // Given a loop whose program is recorded. + const loop = definedTypeNode({ identifier: 'loop', type: definedTypeLinkNode('loop') }); + const program = programNode({ definedTypes: [loop], identifier: 'test', publicKey: '1111' }); + const linkables = new LinkableDictionary(); + visit(rootNode(program), getRecordLinkablesVisitor(linkables)); + + // When we check it from a path without its program, then it throws rather than missing the loop. + expect(() => hasDefinedTypeFiniteValue([loop], linkables)).toThrow( + new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path: [loop] }), + ); +}); diff --git a/packages/visitors-core/test/isDefinedTypeCyclic.test.ts b/packages/visitors-core/test/isDefinedTypeCyclic.test.ts new file mode 100644 index 000000000..6e9934e4c --- /dev/null +++ b/packages/visitors-core/test/isDefinedTypeCyclic.test.ts @@ -0,0 +1,190 @@ +import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors'; +import { + arrayTypeNode, + constantValueNode, + definedTypeLinkNode, + definedTypeNode, + enumTypeNode, + enumValueNode, + enumVariantTypeNode, + hiddenPrefixTransformNode, + integerTypeNode, + optionTypeNode, + prefixedCountNode, + programLinkNode, + programNode, + rootNode, + stringTypeNode, + stringValueNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { getRecordLinkablesVisitor, isDefinedTypeCyclic, LinkableDictionary, visit } from '../src'; + +test('it detects defined types linking to themselves', () => { + // Given a linked list. + const list = definedTypeNode({ + identifier: 'list', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u8') }), + structFieldTypeNode({ + identifier: 'next', + type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }), + }), + ]), + }); + const program = programNode({ definedTypes: [list], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether it is cyclic, then it is. + expect(isDefinedTypeCyclic([root, program, list], linkables)).toBe(true); +}); + +test('it detects cycles through other defined types', () => { + // Given a tree linking to a forest that links back to the tree. + const tree = definedTypeNode({ + identifier: 'tree', + type: structTypeNode([structFieldTypeNode({ identifier: 'children', type: definedTypeLinkNode('forest') })]), + }); + const forest = definedTypeNode({ + identifier: 'forest', + type: arrayTypeNode(definedTypeLinkNode('tree'), prefixedCountNode(integerTypeNode('u32'))), + }); + const program = programNode({ definedTypes: [tree, forest], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether the tree is cyclic, then it is. + expect(isDefinedTypeCyclic([root, program, tree], linkables)).toBe(true); +}); + +test('it does not mark types linking to a cycle as cyclic', () => { + // Given a leaf linking to a list, which links to itself. + const list = definedTypeNode({ + identifier: 'list', + type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }), + }); + const leaf = definedTypeNode({ + identifier: 'leaf', + type: structTypeNode([structFieldTypeNode({ identifier: 'list', type: definedTypeLinkNode('list') })]), + }); + const program = programNode({ definedTypes: [list, leaf], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether the leaf is cyclic, then it is not. + expect(isDefinedTypeCyclic([root, program, leaf], linkables)).toBe(false); +}); + +test('it detects cycles through transforms', () => { + // Given a string hiding a constant prefix of its own type. + const tagged = definedTypeNode({ + identifier: 'tagged', + type: stringTypeNode('utf8', { + transforms: [ + hiddenPrefixTransformNode([constantValueNode(definedTypeLinkNode('tagged'), stringValueNode('x'))]), + ], + }), + }); + const program = programNode({ definedTypes: [tagged], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether it is cyclic, then it is. + expect(isDefinedTypeCyclic([root, program, tagged], linkables)).toBe(true); +}); + +test('it ignores links within values', () => { + // Given an enum whose struct variant defaults to one of its own values. + const direction = definedTypeNode({ + identifier: 'direction', + type: enumTypeNode([ + enumVariantTypeNode('up'), + enumVariantTypeNode('down', { + data: structTypeNode([ + structFieldTypeNode({ + defaultValue: enumValueNode('direction', 'up'), + identifier: 'then', + type: integerTypeNode('u8'), + }), + ]), + }), + ]), + }); + const program = programNode({ definedTypes: [direction], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether it is cyclic, then it is not, since values do not shape the type. + expect(isDefinedTypeCyclic([root, program, direction], linkables)).toBe(false); +}); + +test('it does not confuse same-named defined types of different programs', () => { + // Given a type of program A linking to a same-named type of program B. + const typeA = definedTypeNode({ + identifier: 'config', + type: definedTypeLinkNode('config', { program: programLinkNode('b') }), + }); + const typeB = definedTypeNode({ identifier: 'config', type: integerTypeNode('u32') }); + const programA = programNode({ definedTypes: [typeA], identifier: 'a', publicKey: '1111' }); + const programB = programNode({ definedTypes: [typeB], identifier: 'b', publicKey: '2222' }); + const root = rootNode(programA, { additionalPrograms: [programB] }); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether the type of program A is cyclic, then it is not. + expect(isDefinedTypeCyclic([root, programA, typeA], linkables)).toBe(false); +}); + +test('it ignores links it cannot resolve', () => { + // Given a type linking to a missing type. + const orphan = definedTypeNode({ identifier: 'orphan', type: definedTypeLinkNode('missing') }); + const program = programNode({ definedTypes: [orphan], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + // When we check whether it is cyclic, then it is not. + expect(isDefinedTypeCyclic([root, program, orphan], linkables)).toBe(false); +}); + +test('it sees types recorded after a previous call', () => { + // Given a type linking to a type that is not recorded yet. + const a = definedTypeNode({ identifier: 'a', type: definedTypeLinkNode('b') }); + const b = definedTypeNode({ identifier: 'b', type: definedTypeLinkNode('a') }); + const program = programNode({ definedTypes: [a, b], identifier: 'test', publicKey: '1111' }); + const root = rootNode(program); + const linkables = new LinkableDictionary(); + linkables.recordPath([root, program, a]); + expect(isDefinedTypeCyclic([root, program, a], linkables)).toBe(false); + + // When we record the missing type. + linkables.recordPath([root, program, b]); + + // Then the cycle is found. + expect(isDefinedTypeCyclic([root, program, a], linkables)).toBe(true); +}); + +test('it throws when the path has no program', () => { + // Given a cyclic type whose program is recorded. + const list = definedTypeNode({ + identifier: 'list', + type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }), + }); + const program = programNode({ definedTypes: [list], identifier: 'test', publicKey: '1111' }); + const linkables = new LinkableDictionary(); + visit(rootNode(program), getRecordLinkablesVisitor(linkables)); + + // When we check it from a path without its program, then it throws rather than missing the cycle. + expect(() => isDefinedTypeCyclic([list], linkables)).toThrow( + new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path: [list] }), + ); +}); diff --git a/packages/visitors/README.md b/packages/visitors/README.md index c629626f5..ef0a61719 100644 --- a/packages/visitors/README.md +++ b/packages/visitors/README.md @@ -313,6 +313,8 @@ This visitor replaces any `DefinedTypeLinkNode` with the type of the `DefinedTyp Note that if multiple link nodes point to the same defined type, each link node will be replaced by a copy of the defined type. The `transforms` of each link are applied on top of the inlined type. +Defined types in a cycle made only of types to inline, such as `node = struct { next: option }`, are not inlined since inlining them would never end: they are kept in their programs, and so are the links to them. A cycle going through a type that is not inlined ends at its link, so its other types are inlined as usual. + ```ts codama.update(unwrapDefinedTypesVisitor(['counter', 'splToken.escrow'])); ``` diff --git a/packages/visitors/src/unwrapDefinedTypesVisitor.ts b/packages/visitors/src/unwrapDefinedTypesVisitor.ts index e800f79ad..534e95143 100644 --- a/packages/visitors/src/unwrapDefinedTypesVisitor.ts +++ b/packages/visitors/src/unwrapDefinedTypesVisitor.ts @@ -1,9 +1,11 @@ -import { assertIsNode, IdentifierString, programNode, TYPE_NODES } from '@codama/nodes'; +import { assertIsNode, DefinedTypeNode, IdentifierString, programNode, TYPE_NODES } from '@codama/nodes'; import { extendVisitor, findProgramNodeFromPath, + getDefinedTypeLinksVisitor, getLastNodeFromPath, LinkableDictionary, + NodePath, NodeStack, nonNullableIdentityVisitor, pipe, @@ -25,6 +27,12 @@ import { inlineDefinedType } from './inlineDefinedTypeHelpers'; * The link's own `transforms` are layered on top of the inlined type, and * links inside a type inlined into another program are qualified with the * type's original program. + * + * Defined types in a cycle made only of types to inline (e.g. + * `node = struct { next: option }`) cannot be inlined, since + * inlining would never end: they are kept, and so are the links to them. + * Cycles going through a type that is not inlined end at its link, so their + * other types are inlined as usual. */ export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') { const linkables = new LinkableDictionary(); @@ -35,6 +43,34 @@ export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') { return typesToInline.includes(typeName); }; + // Whether the defined type links back to itself through types to inline only, in which case + // inlining it would never end. Linkables are recorded once, on the first visit, so it is cached. + const linksStack = new NodeStack(); + const linksVisitor = getDefinedTypeLinksVisitor(linkables, { stack: linksStack }); + const inlinedCycles = new Map(); + const isInInlinedCycle = (path: NodePath): boolean => { + const target = getLastNodeFromPath(path); + if (inlinedCycles.has(target)) return inlinedCycles.get(target)!; + let cyclic = false; + const followed = new Set(); + const pending = [path]; + while (!cyclic && pending.length > 0) { + for (const linkedPath of linksStack.visitPath(pending.pop()!, linksVisitor)) { + const linkedType = getLastNodeFromPath(linkedPath); + if (!shouldInline(linkedType.identifier, findProgramNodeFromPath(linkedPath)?.identifier)) continue; + if (linkedType === target) { + cyclic = true; + break; + } + if (followed.has(linkedType)) continue; + followed.add(linkedType); + pending.push(linkedPath); + } + } + inlinedCycles.set(target, cyclic); + return cyclic; + }; + return pipe( nonNullableIdentityVisitor(), v => @@ -46,6 +82,7 @@ export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') { return link; } const definedTypePath = linkables.getPathOrThrow(stack.getPath('definedTypeLinkNode')); + if (isInInlinedCycle(definedTypePath)) return link; const definedType = getLastNodeFromPath(definedTypePath); const type = stack.withPath(definedTypePath, () => visit(definedType.type, self)); @@ -58,11 +95,14 @@ export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') { }, visitProgram(program, { next }) { + const programPath = stack.getPath('programNode'); return next( programNode({ ...program, definedTypes: (program.definedTypes ?? []).filter( - definedType => !shouldInline(definedType.identifier, program.identifier), + definedType => + !shouldInline(definedType.identifier, program.identifier) || + isInInlinedCycle([...programPath, definedType]), ), }), ); diff --git a/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts b/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts index fe67efc30..5ee7f4907 100644 --- a/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts +++ b/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts @@ -1,5 +1,6 @@ import { accountNode, + arrayTypeNode, assertIsNode, constantNode, definedTypeLinkNode, @@ -8,9 +9,11 @@ import { fixedSizeTransformNode, integerTypeNode, integerValueNode, + optionTypeNode, pdaNode, programLinkNode, programNode, + remainderCountNode, rootNode, sizePrefixTransformNode, stringTypeNode, @@ -246,3 +249,94 @@ test('it qualifies links inside types inlined into another program', () => { ]), ); }); + +test('it keeps cyclic defined types and the links to them', () => { + // Given a linked list used by an account, next to a plain alias. + const list = definedTypeNode({ + identifier: 'list', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'value', type: definedTypeLinkNode('amount') }), + structFieldTypeNode({ + identifier: 'next', + type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }), + }), + ]), + }); + const node = programNode({ + accounts: [accountNode({ data: definedTypeLinkNode('list'), identifier: 'myAccount' })], + definedTypes: [list, definedTypeNode({ identifier: 'amount', type: integerTypeNode('u64') })], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we unwrap every defined type. + const result = visit(node, unwrapDefinedTypesVisitor('*')); + + // Then only the alias is inlined, including within the kept cyclic type. + expect(result).toStrictEqual( + programNode({ + accounts: [accountNode({ data: definedTypeLinkNode('list'), identifier: 'myAccount' })], + definedTypes: [ + definedTypeNode({ + identifier: 'list', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u64') }), + structFieldTypeNode({ + identifier: 'next', + type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }), + }), + ]), + }), + ], + identifier: 'myProgram', + publicKey: '1111', + }), + ); +}); + +test('it keeps defined types in a cycle made only of types to inline', () => { + // Given two types linking to each other. + const node = programNode({ + definedTypes: [ + definedTypeNode({ identifier: 'a', type: arrayTypeNode(definedTypeLinkNode('b'), remainderCountNode()) }), + definedTypeNode({ identifier: 'b', type: arrayTypeNode(definedTypeLinkNode('a'), remainderCountNode()) }), + ], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we unwrap both of them, then nothing changes. + expect(visit(node, unwrapDefinedTypesVisitor(['a', 'b']))).toStrictEqual(node); +}); + +test('it inlines defined types in a cycle going through a type not to inline', () => { + // Given two types linking to each other. + const node = programNode({ + definedTypes: [ + definedTypeNode({ identifier: 'a', type: arrayTypeNode(definedTypeLinkNode('b'), remainderCountNode()) }), + definedTypeNode({ identifier: 'b', type: arrayTypeNode(definedTypeLinkNode('a'), remainderCountNode()) }), + ], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we unwrap one of them. + const result = visit(node, unwrapDefinedTypesVisitor(['a'])); + + // Then it is inlined, and the cycle ends at the link to the other one. + expect(result).toStrictEqual( + programNode({ + definedTypes: [ + definedTypeNode({ + identifier: 'b', + type: arrayTypeNode( + arrayTypeNode(definedTypeLinkNode('b'), remainderCountNode()), + remainderCountNode(), + ), + }), + ], + identifier: 'myProgram', + publicKey: '1111', + }), + ); +});