Skip to content

Commit bdccdf4

Browse files
committed
Detect cyclic defined types
1 parent 1093809 commit bdccdf4

24 files changed

Lines changed: 1210 additions & 33 deletions

‎packages/errors/src/codes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const CODAMA_ERROR__UNSUPPORTED_VERSION = 12;
4141
export const CODAMA_ERROR__INVALID_BRANDED_STRING = 13;
4242
export const CODAMA_ERROR__INJECTED_VALUE_NOT_PROVIDED = 14;
4343
export const CODAMA_ERROR__CANNOT_RESOLVE_PATH = 15;
44+
export const CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING = 16;
4445

4546
// Visitors-related errors.
4647
// Reserve error codes in the range [1200000-1200999].
@@ -163,6 +164,7 @@ export type CodamaErrorCode =
163164
| typeof CODAMA_ERROR__INVALID_BRANDED_STRING
164165
| typeof CODAMA_ERROR__LINKED_NODE_NOT_FOUND
165166
| typeof CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE
167+
| typeof CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING
166168
| typeof CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS
167169
| typeof CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE
168170
| typeof CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND

‎packages/errors/src/context.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
CODAMA_ERROR__INVALID_BRANDED_STRING,
6464
CODAMA_ERROR__LINKED_NODE_NOT_FOUND,
6565
CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE,
66+
CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING,
6667
CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS,
6768
CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE,
6869
CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND,
@@ -259,6 +260,10 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{
259260
[CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE]: {
260261
fsFunction: string;
261262
};
263+
[CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING]: {
264+
/** The path that should contain a program node, e.g. to resolve links from. */
265+
path: readonly Node[];
266+
};
262267
[CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS]: {
263268
dependencies: readonly string[];
264269
message: string;

‎packages/errors/src/messages.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
CODAMA_ERROR__INVALID_BRANDED_STRING,
4343
CODAMA_ERROR__LINKED_NODE_NOT_FOUND,
4444
CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE,
45+
CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING,
4546
CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS,
4647
CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE,
4748
CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND,
@@ -135,6 +136,8 @@ export const CodamaErrorMessages: Readonly<{
135136
[CODAMA_ERROR__LINKED_NODE_NOT_FOUND]: 'Could not find linked node [$name] from [$kind].',
136137
[CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE]:
137138
'Node.js filesystem function [$fsFunction] is not available in your environment.',
139+
[CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING]:
140+
'Expected a node path containing a program node, e.g. to resolve the links of its last node.',
138141
[CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS]:
139142
'No version specified for the following dependencies: [$dependencies]. $message',
140143
[CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE]: 'Cannot render the encountered node of kind [$kind].',

‎packages/visitors-core/README.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,47 @@ const size = visit(tupleTypeNode([numberTypeNode('u32'), publicKeyTypeNode()]),
856856

857857
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.
858858

859+
### `getDefinedTypeLinksVisitor`
860+
861+
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`.
862+
863+
```ts
864+
// node = struct { value: link(amount), next: option<link(node)> }
865+
const stack = new NodeStack();
866+
stack.visitPath([root, program, node], getDefinedTypeLinksVisitor(linkables, { stack }));
867+
// ^ [[root, program, amount], [root, program, node]]
868+
```
869+
870+
### `getHasFiniteValueVisitor`
871+
872+
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`.
873+
874+
```ts
875+
// list = struct { next: option<link(list)> }, loop = struct { next: link(loop) }
876+
const stack = new NodeStack();
877+
stack.visitPath([root, program, list], getHasFiniteValueVisitor(linkables, { stack })); // true
878+
stack.visitPath([root, program, loop], getHasFiniteValueVisitor(linkables, { stack })); // false
879+
```
880+
881+
### `isDefinedTypeCyclic`
882+
883+
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.
884+
885+
```ts
886+
// node = struct { value: u8, next: option<link(node)> }
887+
isDefinedTypeCyclic([root, program, node], linkables); // true
888+
```
889+
890+
### `hasDefinedTypeFiniteValue`
891+
892+
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.
893+
894+
```ts
895+
// list = struct { next: option<link(list)> }, loop = struct { next: link(loop) }
896+
hasDefinedTypeFiniteValue([root, program, list], linkables); // true
897+
hasDefinedTypeFiniteValue([root, program, loop], linkables); // false
898+
```
899+
859900
### `getResolvedInstructionInputsVisitor`
860901

861902
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.

‎packages/visitors-core/src/NodePath.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors';
12
import { assertIsNode, GetNodeFromKind, InstructionNode, isNode, Node, NodeKind, ProgramNode } from '@codama/nodes';
23

34
/**
@@ -48,6 +49,18 @@ export function findProgramNodeFromPath(path: NodePath): ProgramNode | undefined
4849
return findLastNodeFromPath(path, 'programNode');
4950
}
5051

52+
/**
53+
* Assert that the path contains a program node, e.g. to resolve the links of
54+
* its last node, which are resolved from the closest program of their path.
55+
*
56+
* @throws `CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING` when it does not.
57+
*/
58+
export function assertNodePathHasProgram(path: NodePath): void {
59+
if (!findProgramNodeFromPath(path)) {
60+
throw new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path });
61+
}
62+
}
63+
5164
export function findInstructionNodeFromPath(path: NodePath): InstructionNode | undefined {
5265
return findLastNodeFromPath(path, 'instructionNode');
5366
}

‎packages/visitors-core/src/getByteSizeVisitor.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { CountNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS, RegisteredTypeNode } from '@codama/nodes';
1+
import {
2+
CountNode,
3+
DefinedTypeNode,
4+
isNode,
5+
isScalarEnum,
6+
REGISTERED_TYPE_NODE_KINDS,
7+
RegisteredTypeNode,
8+
} from '@codama/nodes';
29

310
import { applyByteSizeTransforms, nodeHasTransforms } from './applyByteSizeTransforms';
411
import { extendVisitor } from './extendVisitor';
@@ -25,8 +32,9 @@ export function getByteSizeVisitor(
2532
): Visitor<number | null, ByteSizeVisitorKeys> {
2633
const stack = options.stack ?? new NodeStack();
2734

28-
const visitedDefinedTypes = new Map<string, number | null>();
29-
const definedTypeStack: string[] = [];
35+
// Keyed by node rather than identifier: same-named types of different programs are distinct.
36+
const visitedDefinedTypes = new Map<DefinedTypeNode, number | null>();
37+
const visitingDefinedTypes = new Set<DefinedTypeNode>();
3038

3139
const sumSizes = (values: (number | null)[]): number | null =>
3240
values.reduce((all, one) => (all === null || one === null ? null : all + one), 0 as number | null);
@@ -81,24 +89,21 @@ export function getByteSizeVisitor(
8189
},
8290

8391
visitDefinedType(node, { self }) {
84-
if (visitedDefinedTypes.has(node.identifier)) {
85-
return visitedDefinedTypes.get(node.identifier)!;
86-
}
87-
definedTypeStack.push(node.identifier);
92+
if (visitedDefinedTypes.has(node)) return visitedDefinedTypes.get(node)!;
93+
visitingDefinedTypes.add(node);
8894
const child = visit(node.type, self);
89-
definedTypeStack.pop();
90-
visitedDefinedTypes.set(node.identifier, child);
95+
visitingDefinedTypes.delete(node);
96+
visitedDefinedTypes.set(node, child);
9197
return child;
9298
},
9399

94100
visitDefinedTypeLink(node, { self }) {
95101
// Fetch the linked type and return null if not found.
96102
const linkedDefinedPath = linkables.getPath(stack.getPath(node.kind));
97103
if (!linkedDefinedPath) return null;
98-
const linkedDefinedType = getLastNodeFromPath(linkedDefinedPath);
99104

100-
// This prevents infinite recursion by assuming cyclic types don't have a fixed size.
101-
if (definedTypeStack.includes(linkedDefinedType.identifier)) return null;
105+
// Cyclic types have no bounded size, and following them would never end.
106+
if (visitingDefinedTypes.has(getLastNodeFromPath(linkedDefinedPath))) return null;
102107

103108
return stack.visitPath(linkedDefinedPath, self);
104109
},
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { DefinedTypeNode } from '@codama/nodes';
2+
3+
import { extendVisitor } from './extendVisitor';
4+
import { mergeVisitor } from './generated/mergeVisitor';
5+
import type { LinkableDictionary } from './LinkableDictionary';
6+
import { NodePath } from './NodePath';
7+
import { NodeStack } from './NodeStack';
8+
import { pipe } from './pipe';
9+
import { recordNodeStackVisitor } from './recordNodeStackVisitor';
10+
import { visit, Visitor } from './visitor';
11+
12+
/**
13+
* List the paths of the defined types the visited node links to, without
14+
* following these links. Links that cannot be resolved are skipped.
15+
*
16+
* Only links shaping a type are listed: the defined type of an
17+
* `enumValueNode` merely references it, so it is left out.
18+
*
19+
* Links are resolved from the closest program of the current path, so visit
20+
* nodes with their full path, e.g. using `stack.visitPath(path, visitor)` with
21+
* the visitor's own stack. Links visited without a program are not resolved.
22+
*
23+
* @example
24+
* ```ts
25+
* // node = struct { value: link(amount), next: option<link(node)> }
26+
* stack.visitPath([root, program, node], getDefinedTypeLinksVisitor(linkables, { stack }));
27+
* // [[root, program, amount], [root, program, node]]
28+
* ```
29+
*/
30+
export function getDefinedTypeLinksVisitor(
31+
linkables: LinkableDictionary,
32+
options: { stack?: NodeStack } = {},
33+
): Visitor<NodePath<DefinedTypeNode>[]> {
34+
const stack = options.stack ?? new NodeStack();
35+
36+
return pipe(
37+
mergeVisitor(
38+
() => [] as NodePath<DefinedTypeNode>[],
39+
(_, values) => values.flat(),
40+
),
41+
v =>
42+
extendVisitor(v, {
43+
visitDefinedTypeLink(node, { next }) {
44+
const linkedPath = linkables.getPath(stack.getPath(node.kind));
45+
return [...(linkedPath ? [linkedPath] : []), ...next(node)];
46+
},
47+
visitEnumValue(node, { self }) {
48+
return node.value ? visit(node.value, self) : [];
49+
},
50+
}),
51+
v => recordNodeStackVisitor(v, stack),
52+
);
53+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { CountNode, DefinedTypeNode, isNode } from '@codama/nodes';
2+
3+
import { extendVisitor } from './extendVisitor';
4+
import { mergeVisitor } from './generated/mergeVisitor';
5+
import type { LinkableDictionary } from './LinkableDictionary';
6+
import { getLastNodeFromPath } from './NodePath';
7+
import { NodeStack } from './NodeStack';
8+
import { pipe } from './pipe';
9+
import { recordNodeStackVisitor } from './recordNodeStackVisitor';
10+
import { visit, Visitor } from './visitor';
11+
12+
/**
13+
* Whether the visited type has at least one finite value, following links.
14+
*
15+
* Only the shape of values matters: options may be `None`, enums may pick any
16+
* variant and collections without a fixed positive count may be empty, so all
17+
* of them stop a recursion. Transforms, prefixes and sizes only prevent a
18+
* value from existing when their constants link back to a type being visited,
19+
* since those constants could then never be created. Links that cannot be
20+
* resolved count as finite.
21+
*
22+
* A link back to a defined type being visited has no finite value on its own:
23+
* a finite value never needs to nest a type within itself, so another branch
24+
* must provide one. Enums without variants have no value at all.
25+
*
26+
* Links are resolved from the closest program of the current path, so visit
27+
* nodes with their full path, e.g. using `stack.visitPath(path, visitor)` with
28+
* the visitor's own stack. Links visited without a program are not resolved.
29+
*
30+
* @example
31+
* ```ts
32+
* // list = struct { next: option<link(list)> }, loop = struct { next: link(loop) }
33+
* stack.visitPath([root, program, list], getHasFiniteValueVisitor(linkables, { stack })); // true
34+
* stack.visitPath([root, program, loop], getHasFiniteValueVisitor(linkables, { stack })); // false
35+
* ```
36+
*/
37+
export function getHasFiniteValueVisitor(
38+
linkables: LinkableDictionary,
39+
options: { stack?: NodeStack } = {},
40+
): Visitor<boolean> {
41+
const stack = options.stack ?? new NodeStack();
42+
const visitingDefinedTypes = new Set<DefinedTypeNode>();
43+
// Only `true` is cached: a `false` may only hold because of the defined types being
44+
// visited, whereas a `true` holds whichever are, so it never needs computing again.
45+
const finiteDefinedTypes = new Set<DefinedTypeNode>();
46+
47+
return pipe(
48+
mergeVisitor(
49+
() => true,
50+
(_, values) => values.every(Boolean),
51+
),
52+
v =>
53+
extendVisitor(v, {
54+
visitArrayType(node, { self }) {
55+
return !hasFixedPositiveCount(node.count) || visit(node.item, self);
56+
},
57+
58+
visitDefinedType(node, { next }) {
59+
if (finiteDefinedTypes.has(node)) return true;
60+
visitingDefinedTypes.add(node);
61+
const hasFiniteValue = next(node);
62+
visitingDefinedTypes.delete(node);
63+
if (hasFiniteValue) finiteDefinedTypes.add(node);
64+
return hasFiniteValue;
65+
},
66+
67+
visitDefinedTypeLink(node, { self }) {
68+
const linkedPath = linkables.getPath(stack.getPath(node.kind));
69+
if (!linkedPath) return true;
70+
if (visitingDefinedTypes.has(getLastNodeFromPath(linkedPath))) return false;
71+
return stack.visitPath(linkedPath, self);
72+
},
73+
74+
visitEnumType(node, { self }) {
75+
return (node.variants ?? []).some(variant => visit(variant, self));
76+
},
77+
78+
// The defined type of an enum value only references it.
79+
visitEnumValue() {
80+
return true;
81+
},
82+
83+
visitMapType(node, { self }) {
84+
return !hasFixedPositiveCount(node.count) || (visit(node.key, self) && visit(node.value, self));
85+
},
86+
87+
visitOptionType() {
88+
return true;
89+
},
90+
91+
visitRemainderOptionType() {
92+
return true;
93+
},
94+
95+
visitSetType(node, { self }) {
96+
return !hasFixedPositiveCount(node.count) || visit(node.item, self);
97+
},
98+
99+
visitZeroableOptionType() {
100+
return true;
101+
},
102+
}),
103+
v => recordNodeStackVisitor(v, stack),
104+
);
105+
}
106+
107+
function hasFixedPositiveCount(count: CountNode): boolean {
108+
return isNode(count, 'fixedCountNode') && count.value > 0;
109+
}

‎packages/visitors-core/src/getInstructionDataFields.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export type InstructionDataField = {
1717
*
1818
* Fields are only addressable where the data type resolves to a struct, so
1919
* nested fields are listed for struct-typed (or struct-linked) fields only.
20-
* Each defined type is followed at most once, which guards against cycles.
20+
* Each defined type is followed at most once per branch, which guards against
21+
* cycles while still expanding sibling fields linking the same type.
2122
*
2223
* @param instructionPath - The path to the instruction. It must contain the
2324
* instruction's `programNode` for linked data to be followed; otherwise a
@@ -37,18 +38,20 @@ export function getInstructionDataFields(
3738
): InstructionDataField[] {
3839
const fields: InstructionDataField[] = [];
3940
const stack = new NodeStack(instructionPath);
40-
// Keyed by node rather than identifier: same-named types of different programs are distinct.
41-
const walkedDefinedTypes = new Set<DefinedTypeNode>();
41+
// The defined types followed on the current branch, keyed by node rather than
42+
// identifier: same-named types of different programs are distinct.
43+
const followedDefinedTypes = new Set<DefinedTypeNode>();
4244

4345
const walk = (type: TypeNode | undefined, prefix: string): void => {
4446
if (!type) return;
4547
if (isNode(type, 'definedTypeLinkNode')) {
4648
const linkedPath = linkables.getPath([...stack.getPath(), type]);
4749
if (!linkedPath) return;
4850
const definedType = getLastNodeFromPath(linkedPath);
49-
if (walkedDefinedTypes.has(definedType)) return;
50-
walkedDefinedTypes.add(definedType);
51+
if (followedDefinedTypes.has(definedType)) return;
52+
followedDefinedTypes.add(definedType);
5153
stack.withPath(linkedPath, () => walk(definedType.type, prefix));
54+
followedDefinedTypes.delete(definedType);
5255
return;
5356
}
5457
if (!isNode(type, 'structTypeNode')) return;

0 commit comments

Comments
 (0)