Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions ark/schema/__tests__/alias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { attest, contextualize } from "@ark/attest"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I am not sure this is actually helpful, was added based on some feedback from pullfrog. Let me know what you think.

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]
}
})
})
21 changes: 21 additions & 0 deletions ark/schema/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,14 @@ export abstract class BaseScope<$ extends {} = {}> {
}

finalize<node extends BaseRoot>(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)
Expand Down Expand Up @@ -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")
)
Comment thread
yharaskrik marked this conversation as resolved.

const bootstrapAliasReferences = (resolution: BaseRoot | GenericRoot) => {
const aliases = resolution.references.filter(node => node.hasKind("alias"))
for (const aliasNode of aliases) {
Expand Down
38 changes: 38 additions & 0 deletions ark/type/__tests__/this.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
)
})
Comment thread
yharaskrik marked this conversation as resolved.

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" }] }]
})
})
Comment thread
yharaskrik marked this conversation as resolved.
})
Loading