diff --git a/ark/schema/__tests__/alias.test.ts b/ark/schema/__tests__/alias.test.ts new file mode 100644 index 000000000..b9619be03 --- /dev/null +++ b/ark/schema/__tests__/alias.test.ts @@ -0,0 +1,62 @@ +import { attest, contextualize } from "@ark/attest" +import { + arkKind, + nodesByRegisteredId, + schemaScope, + type NodeId +} from "@ark/schema" +import { writeShallowCycleErrorMessage } from "@ark/schema/internal/roots/alias.ts" + +contextualize(() => { + it("alias resolution detects self-cycling context", () => { + // Synthesize a context node registered to its own id, the shape an + // in-progress parse would have before the enclosing finalize swaps + // the resolved root in. Since BaseScope.finalize now defers in this + // case (see scope.ts hasUnresolvedContextAlias), this is the only + // way to exercise the cycle detector through a test. + const cyclicId = "shallowCycleProbeSelf" as NodeId + nodesByRegisteredId[cyclicId] = { + [arkKind]: "context", + id: cyclicId + } as never + try { + const alias = schemaScope({}).node( + "alias", + { reference: cyclicId }, + { prereduced: true } + ) + attest(() => alias.resolution).throws( + writeShallowCycleErrorMessage(cyclicId, [cyclicId]) + ) + } finally { + delete nodesByRegisteredId[cyclicId] + } + }) + + it("alias resolution detects multi-step context cycle", () => { + // A -> B -> A should also trip the detector after walking once. + const aId = "shallowCycleProbeA" as NodeId + const bId = "shallowCycleProbeB" as NodeId + nodesByRegisteredId[aId] = { + [arkKind]: "context", + id: bId + } as never + nodesByRegisteredId[bId] = { + [arkKind]: "context", + id: aId + } as never + try { + const alias = schemaScope({}).node( + "alias", + { reference: aId }, + { prereduced: true } + ) + attest(() => alias.resolution).throws( + writeShallowCycleErrorMessage(bId, [bId, aId]) + ) + } finally { + delete nodesByRegisteredId[aId] + delete nodesByRegisteredId[bId] + } + }) +}) diff --git a/ark/schema/scope.ts b/ark/schema/scope.ts index e0128e2fa..cf746f8d3 100644 --- a/ark/schema/scope.ts +++ b/ark/schema/scope.ts @@ -709,6 +709,14 @@ export abstract class BaseScope<$ extends {} = {}> { } finalize(node: node): node { + // If this node contains an alias whose reference still points to an + // in-progress context node (e.g. `this[]` parsed before its enclosing + // type has finished), defer finalization. The enclosing parse will + // finalize once the context has been replaced with the resolved node. + // Gating on isCyclic skips the reference walk for non-cyclic roots, + // which are the overwhelming majority of finalize calls. + if (node.isCyclic && hasUnresolvedContextAlias(node)) return node + bootstrapAliasReferences(node) if (!node.precompilation && !this.resolvedConfig.jitless) precompile(node.references) @@ -751,6 +759,19 @@ export class SchemaScope<$ extends {} = {}> extends BaseScope<$> { } } +// Invariant: scope-named aliases are normalized to `$name` references (see +// alias.ts `serialize` and the `$`-prefix branch in `_resolve`), while +// synthetic `this` aliases use a bare context NodeId (see unenclosed.ts +// `maybeParseReference`). The `$`-prefix carve-out skips scope aliases so +// only synthetic `this` references can defer finalization here. +const hasUnresolvedContextAlias = (node: BaseRoot): boolean => + node.references.some( + ref => + ref.hasKind("alias") && + ref.reference[0] !== "$" && + hasArkKind(nodesByRegisteredId[ref.reference as NodeId], "context") + ) + const bootstrapAliasReferences = (resolution: BaseRoot | GenericRoot) => { const aliases = resolution.references.filter(node => node.hasKind("alias")) for (const aliasNode of aliases) { diff --git a/ark/type/__tests__/this.test.ts b/ark/type/__tests__/this.test.ts index 08a6f130d..264470490 100644 --- a/ark/type/__tests__/this.test.ts +++ b/ark/type/__tests__/this.test.ts @@ -109,4 +109,42 @@ contextualize(() => { "a must be a string (was missing), b.a must be a string (was missing) or b.b must be b.b.a must be a string (was missing) or b.b.b must be an object (was missing) (was {})" ) }) + + // https://github.com/arktypeio/arktype/issues/1406 + it("this[] (array of self)", () => { + const T = type({ + name: "string", + "children?": "this[]" + }) + + attest(T).type.toString.snap( + "Type<{ name: string; children?: cyclic[] }, {}>" + ) + + attest(T({ name: "a" })).snap({ name: "a" }) + attest(T({ name: "a", children: [{ name: "b" }] })).snap({ + name: "a", + children: [{ name: "b" }] + }) + attest(T({ name: "a", children: [{ name: 5 as never }] }).toString()).snap( + "children[0].name must be a string (was a number)" + ) + }) + + it("this[] in unions of self", () => { + const T = type({ + "AND?": "this[]", + "OR?": "this[]", + "name?": "string" + }) + + attest(T).type.toString.snap(`Type< + { AND?: cyclic[]; OR?: cyclic[]; name?: string }, + {} +>`) + + attest(T({ AND: [{ name: "a" }, { OR: [{ name: "b" }] }] })).snap({ + AND: [{ name: "a" }, { OR: [{ name: "b" }] }] + }) + }) })