Skip to content

featherduster - #1685

Draft
ssalbdivad wants to merge 579 commits into
mainfrom
featherduster
Draft

ssalbdivad wants to merge 579 commits into
mainfrom
featherduster

Conversation

@ssalbdivad

@ssalbdivad ssalbdivad commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Based on 2.2.7 (1ee4923): 565 commits, 189 files. A draft for review by area; the history gets rewritten into reviewable ranges after review.

Suggested reading order (each a git diff 1ee4923b..featherduster -- <paths>):

  1. ark/util, ark/regex
  2. The set-algebra split into the new arksets package: ark/sets, ark/schema/shared/{intersections,sets}.ts (git diff 3166b30ab featherduster -- ark/sets shows what changed after the verbatim move)
  3. @ark/schema core and perf, excluding cycles and Transform
  4. Copy-on-write Transform replacing clone: ark/schema/shared/transform.ts
  5. ark/type (public API, lazy keywords, ArkErrors no longer extends Array, CHANGELOG)
  6. Bundling and exports: ark/repo/{bundle,build,testBuild,publish}.ts, per-package internal.ts
  7. Tests, benches, attest
  8. Cycles, last: ark/schema/roots/alias.ts, ark/schema/generic.ts, ark/schema/shared/utils.ts, ark/type/__tests__/cyclic* (a rewrite follows as its own PR)
  9. ark/docs

Known before review:

  • Three regressions in the current cycle code: transform output loses an input's cycle and sharing; jitless overflows at about half 2.2.7's depth; allows throws a RangeError on valid rings from 38 members (12 jitless).
  • Bundle: 206,130 B min / 63,655 B gzip for a one-type app, +32.1% / +33.4% over 2.2.7.
  • Commit messages still cite local scratch notes; the history rewrite removes them.

ssalbdivad and others added 30 commits October 3, 2026 17:35
transformKey took `node: BaseNode | readonly TransformStep[]` and
wrapped a lone node as a step, so its `node` param could hold a list,
and the index loop's local transformKey copied the overload. Every
other NodeCompiler method's `node` is a node (invoke, check,
traverseKey), as in 2.2.7. It now takes `steps`, and the four callers
with one node (a morph's input, a sequence's elements, a structure's
sequence and its single transforming index signature) pass `[{ node }]`.

Emitted code is identical for every validate.bench row.

Battery: identical to the previous commit (300/300).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The comment above BaseNode's transformSelectsByContext described
UnionNode's and IndexNode's overrides, which carry comments of their
own, rather than the inherited rule beneath it: a node passes ctx down
when a child picks what it transforms by ctx.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
10450dd glossed TransformStep's `signature` and the branch in
transformsOf that defers a signature reading ctx. The name, the
allowsRequiresContext test and the step's use as an Allows condition
say as much, and both files already sit above 2.2.7's comment density
(compile.ts had none).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The variadic key passed the sequence's parse context on as options, so a
variadic element that wasn't already cached was created with the
sequence's id; the prefix, optional, defaultable and postfix elements
are parsed without it. Wherever the sequence node kept that id too, the
two compiled to the same names and the element's check replaced the
sequence's, so the array itself was checked against the element:

- without arksets, every array @ark/schema parsed, since ecf3ea9 stopped
  caching nodes parsed without the engine (an element used to come back
  from the cache under its own id): `rootSchema({ proto: Array,
  sequence: "number" })` rejected `[1]` with "must be a number (was an
  object)", where 69288d0 accepted it;
- with the engine, and in 2.2.7, wherever its sequence reduction leaves
  the node as parsed, which an explicit minVariadicLength it keeps does:
  `rootSchema({ proto: Array, sequence: { variadic: { unit: 12345 },
  minVariadicLength: 1 } })` rejects `[12345]` on both.

The element now gets its own id, as its siblings do. The context's other
options were never meant for it: the sequence's own callers pass none,
or `prereduced` with elements that are already nodes. arktype passes
its elements as nodes, which are returned before an id is assigned, so
its arrays were never affected.

A 2.2.7 bug fixed (the minVariadicLength case); its one line of 2.2.7
code, `ctx.$.parseSchema(schema, ctx)`, loses the forwarded ctx.

Test: parse.test.ts "variadic element with minVariadicLength" (a fresh
scope, so the element isn't cached), which fails at the parent.

Battery: identical to the parent (300/300). No change to emitted code
for any node arktype builds, so validation isn't measured; parse
registers one id per uncached raw variadic element.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…engine

69288d0 refused, without arksets, a sequence with anything but a
variadic element, on the grounds that it implies a length bound the
engine would add to its intersection. Optional and defaultable elements
before a variadic imply none (impliedSiblings is empty unless minLength
is positive or there is no variadic), so `rootSchema({ proto: Array,
sequence: { optionals: ["string"], variadic: "number" } })` and the
same with `defaultables: [["string", "d"]]` threw missingSetEngineMessage
although they validate the same with and without the engine, as they
did at 1017a2d.

The refusal now follows impliedSiblings: no variadic (a maximum length),
or a prefix, postfix or minVariadicLength (a minimum length). Without
the engine, `{ optionals: [] }` with no variadic now throws
missingSetEngineMessage before reaching the TypeError ("Cannot read
properties of undefined (reading 'nestableExpression')") it throws with
the engine and in 2.2.7.

A break avoided: these parsed in 2.2.7's @ark/schema, which held the
algebra.

The README names the refused case as a tuple that bounds its length.

Test: engine.test.ts "parsing a tuple with no length bound does not",
which throws at the parent. Probe (/tmp/fd/rv-p1sc/seqfix.ts, ten
sequence shapes by six inputs, jit and jitless): each one parsed without
the engine either is refused or matches the engine's results.

Battery (engine installed): identical to the parent (300/300). One
property read per engine-less sequence parse; not measured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
037e4e7 deleted holdsNodesWeakly, the only argument ever passed to
WeakCache's `weak` parameter, and left the parameter because ark/util
was outside that lane. Every WeakCache is now constructed without it,
so it holds values weakly wherever WeakRef and FinalizationRegistry
exist and pins them otherwise, as each one already did.

No behavior change; WeakCache is new on the branch, so nothing 2.2.7
exposed changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…irst

testSchemaFirst has failed since a19f4e4, so `pnpm testRepo` fails on
the branch: `type("object.json")({ a: 1n })` reads "a must be a number, a
string, an object, boolean or null (was a bigint)" where the test, and
2.2.7, expect "a must be an object (was a bigint)". It isn't specific to
parsing with @ark/schema first; plain arktype gives the same, and the
same at `a.b`.

2.2.7's message came from #1026: jsonData's `$jsonObject` branch stayed
an alias, so its discriminant routed every domain it didn't list,
bigint included, to that branch as the default case and reported only
"an object". a19f4e4 rebuilds the union once the alias resolves, so it
is discriminated on all its domains. 2.2.7 itself gives the branch's
message for the same union written as a scope, `scope({ j: "number |
string | boolean | null | o", o: { "[string]": "j" } }).export().o({ a:
1n })`.

A break required by correctness (the old message omits four of the five
domains the value may have). The assertion is updated to it; the
test's other two checks still tell an intrinsic parsed with the engine
from one parsed without it. The break is added to E-breaking for David
to confirm.

Gates: testIntegration passes (all five scripts).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
57d5e78 reads symbol keys only when some index signature doesn't
extend string, which it decides with `extends`, and `extends` needs the
set engine. Without arksets, every structure with an index signature
threw missingSetEngineMessage as it compiled, so
`rootSchema({ domain: "object", index: { signature: "string", value:
"number" } })` couldn't validate anything, though a string, symbol or
`string | symbol` signature parses without the engine. That includes
the intrinsic jsonObject, so testSchemaFirst, which parses with
@ark/schema before arksets is installed, threw it.

Without the engine, the compiled structure reads symbol keys again, as
it did before 57d5e78 and as jitless always does; the traversal skips
any a signature doesn't match. With the engine, the getter and the
emitted code are unchanged.

Test: engine.test.ts "parsing an index signature does not", which
throws at the parent. Probe: string, symbol and string | symbol
signatures give the same allows results without and with the engine.

Battery (engine installed): identical to the parent (300/300). The
change is a property read per signature at compile time; validation
code is unchanged, so it isn't measured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6743f7f changed benchErrorOnThresholdExceeded's default from true to
"types", so a consumer's runtime bench that exceeded its baseline
logged 📈 and exited 0 where @ark/attest 0.56.7 (arktype 2.2.7) exited
1 with a ❌ summary. Its reason, that runtime medians are too noisy to
fail on, is about this repo's own benches, and 2.2.7 already handled
that per script. Restoring the default avoids the break.

The repo keeps today's behavior through its scripts:
- `bench` gets back 2.2.7's ATTEST_benchErrorOnThresholdExceeded
  prefix. Every bench it runs is a type bench, where true and "types"
  behave the same, so it changes nothing; it only restores 2.2.7's
  line.
- `benchRuntime` passes `--benchErrorOnThresholdExceeded types` to each
  of its three files, since an env prefix reaches only the first
  command of an && chain.

The README's default block says true again, and its paragraph says
what the code does: a bench fails over the threshold, and "types"
fails only type benches. Its example passes a value, since 2.2.7's
bare `--benchErrorOnThresholdExceeded` took the next flag as one.

Checked with a runtime bench whose baseline is 0.0001 ns, run from
source: by default two ❌ lines and exit 1, as in 2.2.7; with
`--benchErrorOnThresholdExceeded types` or 2.2.7's env prefix, 📈 only
and exit 0, as `pnpm benchRuntime` behaves now.

Battery: not affected (attest and scripts only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
99bc817 needed a condition for its new bundle() call and also rewrote
2.2.7's dtsGen line to use it. That line sits in the else of
`packageName === "arktype"`, where the new const reduced to 2.2.7's
`packageName in packagesByScope.type.json.dependencies!`, so the
rewrite changed nothing. The condition is now inline on bundle()'s
branch, its only remaining use, and the dtsGen line is 2.2.7's again.

Adding arksets also sorted the dependencies around it:
- arktype's package.json put @ark/schema before @ark/util;
- ark/repo's put arkregex and arksets before arktype.
Nothing reads key order (build.ts uses `in`, and pnpm builds in
topological order), so both keep 2.2.7's order with the additions
after it.

These restore 2.2.7 code. The same packages are bundled and get
dtsGen; `pnpm build`, testBuild and testBundle pass.

Battery: not affected (build scripts and package.json order only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ecbecf7's exact `./internal/config` and `./internal/keywords/ts`
entries in arktype's and @ark/schema's package.json carried a "types"
condition pointing at the .d.ts beside their "default" .js. TypeScript
finds that .d.ts from "default" on its own, which is how 2.2.7's
"./config" entry works ({ "ark-ts", "default" } only), so the six
entries now take 2.2.7's shape. The wildcard entries keep "types",
since their "default" is index.js.

assertMapsToOwnFile's message, which tells a maintainer what entry to
write, drops it too, and bundle.test.ts's assertion of that message
follows. That is the one test edit: it pins the build script's own
error text, which this commit changes by design.

A consumer importing all six specifiers resolves each to the same
out/*.d.ts with and without the condition under TS 5.9 NodeNext and
Bundler (--traceResolution), with identical diagnostics. No published
type changes; `pnpm build`, testBuild, testBundle, tsc and the 1893
tests pass.

Battery: identical to the previous commit (300/300).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
haveSameMap wrapped the one %HaveSameMap call, while the same loop
calls %HasFastProperties through eval inline, as 2.2.7's testV8.js
does. A direct eval reads the loop's locals, so the call now sits where
it is used.

`pnpm testV8` passes (17 node kinds, one map each), and a probe that
adds a slot to one unit node still fails with "unit nodes false and
true have different maps after construction".

Battery: not affected (test script only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
packageOf makes a temp dir and writes a package into it, but an `xOf`
in 2.2.7 is a pure derivation (domainOf, keysOf, precedenceOfKind), and
2.2.7 names its file writers writeFile, writeJson and writeApiDataFile.
It is writePackage now.

evaluated() bound globalThis to a local named `global`, shadowing
Node's. It now drains the trace array with splice(0), as afterEach
drains `packages`, so it needs no local: the next traced module pushes
into the emptied array through its `??=`. Once a traced module has
run, evaluated() returns [] rather than undefined when nothing new
evaluated, so the one assertion of that case (importing internal.js
evaluates nothing) reads `.equals([])`. That is the only assertion
edit, and it pins the helper's own return, which this commit changes.

afterEach stays, as in 2.2.7's one fs-writing test
(attest/__tests__/externalSnapshots.test.ts).

Battery: not affected (test only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`until: { count: 20 }` was commented as capping how many types a bench
makes, and effc497's body said the same. attest's ResultCollector
warms up for min(500 ms, until.ms) whatever `count` is, and `count`
stops only the sampling that follows, as `ms` does. A bench whose call
takes 0.86 ms made 297 calls under count 20 (load ~20), against about
6,000 for the default 5 s of samples. So the cap bounds the samples,
and through them most of the types a bench retains; the comment now
says that.

Counting warm runs toward `count` instead would make `count` and `ms`
bound different phases and cut the warmup of every low-count bench,
and the create medians were baselined with this warmup (c508faf).

Battery: not affected (comment only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6743f7f made two changes so `ATTEST_filter=moltar` would select every
row of a bench scenario:
- a string `filter` kept a bench when any path segment started with
  it, where @ark/attest 0.56.7 (arktype 2.2.7) required a segment equal
  to it, so an existing filter like "ab" also ran "abc" and "ab (zod)";
- parseEnvValue read an ATTEST_ value that is not JSON as a plain
  string. That also turned a typo like ATTEST_benchPercentThreshold=1O
  into the string "1O", where 2.2.7 threw. baseline.ts then compares
  `delta > "1O"`, which is always false, so the threshold silently
  stopped failing anything.

Nothing in the repo passes a prefix filter, and neither change was
needed for correctness or perf, so both go back to 2.2.7's code:
benchFn's filter block and addEnvConfig are 2.2.7's verbatim, which
avoids the break in filter selection and restores the throw on a
malformed env value. The README drops the sentence that documented the
prefix match; 2.2.7 had none. A scenario's rows can still be run one at
a time with the exact name, e.g.
ATTEST_filter='"moltar allows (arktype)"'.

Checked from source with benches "ab", "abc" and "ab (zod)":
ATTEST_filter='"ab"' runs only "ab", ATTEST_filter='["abc"]' only
"abc", and ATTEST_benchPercentThreshold=1O and ATTEST_filter=ab each
throw a JSON SyntaxError, all as in 2.2.7.

Battery: not affected (attest only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6743f7f stored each sample's result in a module-level benchSink,
which warnIfUnused read as a global, on the reasoning that a module
variable is what keeps V8 from dropping the calls whose result a bench
returns. A local that warnIfUnused reads after the loop is just as
observable, and the generated loop itself keeps only its last call's
result either way, so the module variable protected nothing a local
does not. loopCalls and loopAsyncCalls now keep their result local, as
2.2.7's loops kept all their state, and warnIfUnused takes it as a
parameter.

Measured with allocating benches, since a boolean has no allocation
for V8 to remove: this commit's attest (B) against its parent (A),
fresh process per sample, ABBA, n=8 per arm, until 2 s, load 40-47:
- object literal: 7.36 → 7.23 ns, x0.982, p 0.63
- two-element array: 13.89 → 14.07 ns, x0.993, p 0.73
- object spread: 19.71 → 19.59 ns, x0.987, p 0.46
No difference, and at ~7 ns the object literal is still allocated in
both arms. The review's earlier run (n=16, load 12-17) agrees: x1.036
p 0.055, x0.986 p 0.65, x0.994 p 0.79. A bench returning undefined
still warns, sync or async.

Battery: not affected (attest only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
createLoop's comment said a loop compiled per bench gives its call site
one fn to inline. That holds only because `// bench loop ${loopCount++}`
makes each loop's source unique: two `new Function` calls with
identical source share V8's compiled state, so without the counter
every bench's loop would share one call site's feedback. The comment
said nothing about it, so the counter read as a leftover label that a
maintainer could delete. 2878627's cut to one line dropped the clause
that said so; the comment now states it.

On node 25.2.1, after optimizing one of two loops built from the same
source, the never-called other reports the same optimized status
(%GetOptimizationStatus 101001); with distinct labels it reports a
fresh one (1000001).

Battery: not affected (comment only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bundle.ts spelled out the same eight esbuild options for its main build
and for flattenIntoInternal's pass, and the copies had drifted: only
the pass set absWorkingDir, which ff7b55f added because esbuild
otherwise resolves from the directory its service started in. So a
bundle() run after a chdir, as bundle.test.ts does, gave the main
build's output path comments relative to the process's start
(`// ../../../../tmp/chd-x/out/a.js` where a build started in the
package writes `// out/a.js`), and chunk hashes that follow from them.

Both builds now spread one buildOptions() and add what is theirs: the
main build its entry points, the pass its stdin entry and metafile. It
is a function because process.cwd() changes between bundle.test's
packages. The pass gains charset utf8, which only changes how its
output, read for its export clause, escapes non-ASCII.

`pnpm build` writes byte-identical out/ trees for util, schema, sets,
regex and type, since build.ts runs in the package directory.

Battery: identical to the previous commit (300/300).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
renamesTo returned `Edit[] | undefined` through an isRenamable flag,
and its one caller turned undefined into "Can't rename class _X to X":
a maybe-function without the maybe name, its throw split from its
cause. Two of its three aborts rejected input it could rename:
- an identifier spelled X, which inside class _X is only ever a
  property name (o.X, a method X(), { X: 1 }), since esbuild renames
  any binding of X there, even an unreferenced one, to X2;
- a property name spelled _X (o._X, a method _X(), { _X: 1 },
  `const { _X: y } = o`), which renaming the bindings leaves alone.

The third abort guarded a real case: a shorthand { _X }, which esbuild
keeps when the source names the class _X itself
(`const X = class _X { static self = { _X } }`). Renaming only the
bindings there would leave _X unbound. It is now written as
{ _X: X }, which keeps its key and value.

So renamesTo always returns its edits, and where the old one did not
abort its edits are the same: `pnpm build` writes byte-identical out/
trees for util, schema, sets, regex and type. bundle.test gains a case
with both shapes, `class K { static self = { K }; m(o) { return o.K }
_K() {} }` and `class _L { static self = { _L } }`, which threw before
and now keeps each class's name and self reference.

Battery: identical to the previous commit (300/300).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
flattenIntoInternal built namesByPath with
Object.fromEntries(outputs.flatMap(... ? [[k, v]] : [])), a second way
to do what flatMorph does in 2.2.7's repo scripts (jsdocGen.ts returns
[] to skip a doc, shared.ts builds packagesByScope with it). It now
uses flatMorph over metafile.outputs, and the stdin output's path is
found among the same keys, so the intermediate entries array goes.

build.ts already loads @ark/util through shared.ts, so the import adds
no dependency. `pnpm build` writes byte-identical out/ trees for util,
schema, sets, regex and type.

Battery: identical to the previous commit (300/300).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
reexporting took an array of specifiers and joined it, but each of its
callers passes one, so it takes that specifier. `[x].join(", ")` is x,
so the emitted re-exports are the same: `pnpm build` writes
byte-identical out/ trees for util, schema, sets, regex and type.

Battery: identical to the previous commit (300/300).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
assertMapsToOwnFile worked out a module's name by hand: the path
relative to the cwd, backslashes normalized, then "./out/" and ".js"
sliced off. Every path it gets is under fromCwd("out") and ends in
.js, so that is moduleOf(path), which the rest of flattenIntoInternal
already uses, and the package.json target is `./out/${module}.js`.

The message and the check are unchanged; bundle.test's assertion of
the message passes as it was, and `pnpm build` writes byte-identical
out/ trees for util, schema, sets, regex and type.

Battery: identical to the previous commit (300/300).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
testBuild's entryFiles repeated bundle.ts's publicEntryPoints token for
token (the exports walk, the string-or-default target and the
three-part filter), differing only in where it read package.json and
how it returned each path. The build and its check could drift apart.
publicEntryPoints now takes the package directory and is exported;
bundle() passes the cwd, and testBuild maps each package's entry
paths to URLs, imports them, and compares loaded URLs against them.

testBuild also annotated `typeof import("arktype")` on a dynamic
import that TypeScript types the same way, which F2 leaves to
inference; it goes.

testBuild now loads bundle.ts, and through it @ark/util, before it
registers its resolve hook. util's out/index.js imports nothing, so
the hook misses no resolution, and arktype loads the same util, so no
second registry installs.

Checks: each package's entry URLs equal the old entryFiles' (util,
sets, regex: index.js; schema, type: index.js and config.js); testBuild
passes, and fails with "Importing util's entries loads out/index.js"
when entryUrls drops an entry. `pnpm build` writes byte-identical out/
trees for util, schema, sets, regex and type.

Battery: identical to the previous commit (300/300).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A morph key branch called the morph fallback, discarded its schema and
fell into the intersection-only path, so
`{ "[string.numeric.parse]": "string" }` threw "Unexpected index branch
kind morph." whatever fallback was given. 2.2.7 has the same throw.

The fallback's schema now decides how the key is written, as a key type
would be: a pattern keys `patternProperties`, a bare `{ type: "string" }`
is `additionalProperties` (as an index on `string` is), and anything
else is left out, as an intersection key with no pattern already is. So
`fallback: { morph: ctx => ctx.base }` describes the input, as the
fallback docs promise: `[string.numeric.parse]` becomes `[string.numeric]`'s
patternProperties and `[string.trim]` becomes `additionalProperties`.
With no fallback the default handler still throws the documented
`ToJsonSchemaError` with code "morph".

The fallback's `base` and `out` are resolved schemas rather than
`toJsonSchemaRecurse` results, since a key can't be a `$ref`: under
`useRefs` (or for a cyclic type) `base` was `{ $ref }` for any
non-basis input like `string.numeric`'s, so no fallback could recover
its pattern. Behavior change, useRefs only: the no-fallback error's
context prints that input's schema where 2.2.7 printed its `$ref`.
Without useRefs the error is byte-identical to 2.2.7's.

New test: jsonSchema.test.ts "morph index key" (fails before with
"Unexpected index branch kind morph."), with and without useRefs.

Not changed: an index on `string | symbol` still throws "Unexpected
index branch kind domain." with a symbolKey fallback (2.2.7 too); its
string branch is a domain, which the same loop doesn't expect.

Gates: build, tsc clean; 1991 tests passing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
a19f4e4 tracked the nodes a `$ref` reaches in a module-level `refs`
array, saved and restored around each call with try/finally so a
fallback calling `toJsonSchema` re-entrantly got its own, and kept it
unique with `refs.includes(node)` on every `$ref`, O(refs) each.

The list now lives on the call's context, as 2.2.7 keeps traversal
state on its `Traversal`: `JsonSchemaContext` extends
`ToJsonSchema.Context` with `refs`, set when the config is merged, so a
re-entrant call has its own without a save, restore or finally. Each
`$ref` pushes its node unconditionally, and the existing id-keyed
`schemasById[id] ??=` resolves a node once however many times it
appears, so no lookup runs per `$ref`. Resolution order, and so `$defs`
key order and the order fallbacks are called, is unchanged: a node is
still resolved at its first appearance. `ToJsonSchema.Context` itself
is untouched, so public types don't change.

A Set on the context was measured first and rejected: n=200 below,
before 3.19 ms, Set 3.36 ms (1.05x, 22/30 slower, p .016).

Measured on the source snapshots of the parent and this commit, a
fresh process per sample, roots interleaved ABBA, exact sign test, load
7-11: `toJsonSchema({ useRefs: true })` of an object with n props, each
a distinct object holding another (2n+1 defs):
- n=20: 0.131 -> 0.130 ms (0.99x, 10/20 faster, p 1), 3000 calls each
- n=200: 3.71 -> 3.78 ms (1.02x, 16/30 faster, p .86), 300 calls each
- n=3000: 135.7 -> 117.0 ms (0.86x, 9/10 faster, p .021), 6 calls each
So no change at common sizes, and the quadratic term is gone at
thousands of defs.

No new test: behavior is unchanged (re-entrancy was already handled by
the save and restore). Outputs of 20 shapes (cyclic scopes in both
declaration orders, unions, morphs with outs, draft-07) are identical
to the parent's.

Gates: tsc clean; 1991 tests passing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`type("Date")(Object.create(Date.prototype))` threw "Method
Date.prototype.toString called on incompatible receiver" from both
allows and apply, JIT and jitless, as it does on 2.2.7, where the check
is `data.toString() !== "Invalid Date"`. A validator returns a result
for any input, so it now fails with "must be a Date (was an invalid
Date)".

isValidDate already caught the TypeError the intrinsic getTime throws
for an object inheriting Date.prototype without a time value, then
called toString to throw its own. It now returns false there, so the
check, the interpreted traversal and the `actual` writer read such an
object as an invalid Date. A Date whose toString is overridden is still
judged by what toString returns, as on 2.2.7, and the path a real Date
takes is unchanged.

A break from 2.2.7 required by correctness: a throw becomes an error.

No measurable change on the path a Date takes (built output, a fresh
process per sample, arms interleaved, n=12, load 11-12): `{ d: Date,
n: number }` allows 26.7 -> 25.3 ns (faster in 5/12 processes),
invalid apply 2.26 -> 2.24 us (6/12).

Test edit: keywords/object.test.ts "Date is valid unless its toString
is Invalid Date" pinned the TypeError as a known bug (b551c16); it
now asserts the error, and fails before this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
56f8f5a gave a scope's thunk a parse context once it returned, and
threw a shallow-cycle ParseError for any reference to its alias while
it was still being called. A thunk is usually called from a definition
it references back, so each such mutual reference resolved thunk first
threw:

	const $ = scope({
		w: () => $.type("a | string"),
		a: { v: "string", "w?": "w" }
	})
	$.export() // ParseError: Alias 'w' has a shallow resolution cycle: w->w

and `$.type("w")` threw the same whichever was declared first. On 2.2.7
the nested reference called the thunk again, so these resolved; at the
parent of 56f8f5a the other order threw InternalArktypeError
("Unexpected attempt to overwrite node id union11").

A thunk's context is now created before it's called, in phase
"resolving", and the call counts as an open definition. A reference to
its alias while it runs gets its alias, as a reference to any
definition being parsed does, and nothing finalizes a type holding that
alias until the thunk returns. Its definition is then parsed under that
context. A default checked against a cyclic node during the call is
checked before that parse, which would otherwise discard it as left
over from a parse that threw. A thunk returning a generic or module,
or throwing, drops the context, so the next reference calls it again
as before.

So `a: () => $.type({ "a?": "a" })` resolves to `{ a?: $a }`; 56f8f5a
threw for it and 2.2.7 overflowed the stack. A thunk whose alias is
shallow (`a: () => $.type("a | string")`) still throws the
shallow-cycle error.

Probed, jit and jitless: `$.type("a").or("string")`,
`type("string").or($.type("a"))` (orInThunk, which 2.2.7 rejects with
"'a' is unresolvable"), a tuple and a string union, an array, a pipe
and an intersection, each declared in both orders and resolved by
export, `$.type("w")` and `$.type("a")`: all resolve, and each but
the array reads the same in every order. The unions' expressions,
json, transforms and messages match the same scope written without a
thunk. The array differs: a definition parsed while the thunk is
called is inlined into it, as on 2.2.7, so `$.type("a[] | string")`
reads `string | { v: string, w?: $w }[]` (`string | $a[]` when `a` is
parsed first) where the thunkless scope reads `string | $a[]`.

Creation (built output, a fresh process per sample, ABBA interleaved,
load 11-13, n=60 per arm, Mann-Whitney): import 0.99x (p .96), first
type 1.00x (p .56), ten keywords including four generics 1.10x (p .63;
1.05x, p .47 in a second run), 100 scopes of thunks 0.99x (p .76). All
of ark's keywords (`ark.export()`): 209.6 -> 213.3 ms, 1.02x, faster
in 16 of 30 pairs, so no measurable change. Parity battery: identical
(300 cases).

Test edits: thunk.test "self-referencing thunk in scope" asserted that
`a: () => $.type({ "a?": "a" })` throws; it now asserts `{ a?: $a }`,
and the throw moves to the shallow `a | string`. New: "thunk
referenced by a definition it parses", which throws without this
change, including a default the thunk's type checks against a cyclic
node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
19aea7c dropped renamesTo's abort on any identifier spelled X inside
class _X, reasoning that esbuild renames every binding of X there (to
X2), so X can only be a property name. That holds for the _X esbuild
writes for a self-referencing class, but not for a class expression
the source named itself, whose body can refer to the outer binding:

    export let X = class Y { get() { return X } }

Renaming Y to X there rebinds the inner X to the class, so after
`X = 5` an existing instance's get() returned the class instead of 5.
The parent threw "Can't rename class Y to X"; 19aea7c built it
silently.

Likewise, a shorthand { _X } survives in esbuild's output only when the
source named the class _X (esbuild expands its own as { X: _X }), so
19aea7c's rewrite to { _X: X } renamed a source-named class and
changed its runtime name from _L to L, which its new test then pinned.
The shorthand case throws again.

renamesTo now throws where an identifier spelled X is a reference
(including a shorthand { X }) or _X is a shorthand, and otherwise
returns the same edits as 19aea7c: property names spelled X or _X
(o.K, a method _K(), { K: 1 }, `const { _K: y } = o`) are still
renamable, which was the real half of that fix. A nested binding of
_X needs no guard either: esbuild renames it to _X2, as it does X.

bundle.test keeps the K case (`class K { static self = { K }; m(o) {
return o.K } _K() {} }`, named K with K.self.K === K) and replaces the
_L half with a test that both source-named shapes throw.

`pnpm build` writes byte-identical out/ trees for util, schema, sets,
regex and type, so neither shape occurs in a shipped package and the
battery is unchanged (300/300).

Still open, and older than this: any other source-named class
expression (`const L = class Named {}`) is renamed to L, since the
output alone can't tell it from esbuild's _X.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An instantiation reached again while it's open is referenced by an
alias that reads as the generic applied to its arguments
(`alternate<"off", "on">`). Each argument printed its full expression.
For an expansive generic the arguments nest, so every level printed
the previous level's arguments twice and the eager expression doubled
per instantiation:

	scope({ "g<p, q>": { "n?": "g<g<p, p>, q>" } }).export()
	// RangeError: Invalid string length after 0.7 s, 850 MB rss
	scope({ "g<t>": { v: "t", "n?": "g<t>" }, "h<u>": { "n?": "h<g<u>>" },
		x: "h<string>" }).export()
	// RangeError: Invalid string length; heap OOM at 1.5 GB

so the 100-instantiation bound never got to throw.

An argument nesting operations (it holds an alias with operands, an
open or deferred instantiation, an operation, an input or output, one
of whose operands holds another) now prints as `...`. An argument
holding operations only one level deep still prints in full, so
`list<list<string>>` reads as before and the open instantiation's
expression grows linearly with nesting instead of doubling. Named
aliases (`$a`), `this` ids and deferred values print as before, so
`box<$a | null>` and `Extract<$a | string, object>` are unchanged, and
so are `&` and `=>` aliases.

Now each of these throws writeUnclosedGenericCycleMessage:
- `g<g<p, p>, q>`: 84 ms, 67 MB rss (was RangeError, 850 MB)
- `g<p, g<q, p>>`, a: g<string, number>: 69 ms, 69 MB (was 1.2 GB)
- h<g<u>>: 356 ms, 89 MB (was RangeError, 860 MB)
- g0<p0, q0> = g1<string, g0<p0, p0>>: 24 ms (was 1.7 GB, OOM)

`list<list<list<string>>>` reads `{ value: { value: { value: string,
next?: list<string> }, next?: list<{ value: string, next?:
list<string> }> }, next?: list<...> }`.

The constrained form (`g<p extends object>`) still exhausts memory:
its instantiations are deferred, which the next commit bounds.

Drops alias.ts's gloss on isIo, which reaches() states already.

Tests: cyclic.test "describes a recursive generic argument without
repeating it". 1991 passing, tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A constrained generic whose argument can't be resolved yet defers its
instantiation to an alias that checks the constraint once it resolves.
In an expansive generic each deferred instantiation's body defers the
next one, and every instantiation closes before the next resolves, so
the open count never passed 1 and the 100-instantiation bound never
threw. Export resolved the chain until the heap ran out:

	scope({ "g<p extends object>": { "n?": "g<g<p>>" } }).export()
	// FATAL ERROR: JavaScript heap out of memory (--max-old-space-size=3000)

while the unconstrained `{ "g<p>": { "n?": "g<g<p>>" } }` threw
writeUnclosedGenericCycleMessage in 12-80 ms.

A deferred instantiation now records how many instantiations of its
generic were open when it was deferred, and instantiates at the deeper
of that and the current count, so a chain of deferrals counts toward
the bound as nested instantiations do. The count is restored after
each instantiation as before.

`g<p extends object>` and `g<p extends object | string>` now throw
writeUnclosedGenericCycleMessage in 113-141 ms (73-89 MB rss).
Non-expansive constrained generics are unaffected: their chains close
within a few levels (cyclic.test's box/Partial/Extract tests).

Tests: cyclic.test "rejects an expansive generic its constraint
defers". 1992 passing, tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ssalbdivad and others added 12 commits October 6, 2026 17:37
With the option, a reject type allows every undeclared symbol key, so
reject types that differ only by a symbol key are no longer disjoint.
A union of them with a morph then throws at parse time, where the
default parses it:

  type({ "+": "reject", a: "string.trim" }).or({
  	"+": "reject",
  	a: "string",
  	[s]: "1"
  })
  // ParseError: An unordered union of a type including a morph and
  // a type with overlapping input is indeterminate

The throw is right under the option's semantics, since
{ a: " x ", [s]: 1 } matches both branches, but opting in for speed
can turn a working definition into a ParseError, so the CHANGELOG
entry shows it and the configuration docs say it in a sentence.
Checked against the built package with and without the option.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
allowsUntracked saves the enclosing root's visit count and tracked
continuation, resets both for its own traversal and restores them
after it; allowsAcyclic does the same for aliasVisits.tracked. Both
restored only on return. A top-level call resets on entry, so what a
throw left behind reached only a root still running: one whose
predicate calls a cyclic type and catches what it throws. Then, in jit
and jitless:

- past the depth bound, the enclosing root continued the nested root's
  Traversal, whose resolutions were still entered. Data reaching one
  was assumed valid, so allows returned true for a chain whose last
  value is invalid. dac8f8a shares one continuation per root on the
  invariant that each hit settles what it assumed before the next
  reads it, and the throw broke that.
- a nested root apply that threw before the bound left `tracked`
  false, so a transforming root that had passed the bound took its
  data as acyclic and overflowed the stack on a cyclic object.

2.2.7 had neither, since each root call had its own ctx. The new
traverse.test case fails on the branch tip at its first assertion
(true), and at its second (RangeError) with only allowsUntracked fixed.

allowsUntracked restores in a catch that rethrows, so its return path
is unchanged: a finally there slowed the cyclic rows whose allows
takes tens of ns. Head to head, paired, n=30, load 3.6-7.1, catch
against finally: list apply 0.90x (p 0.010), list allows 0.98x
(p 0.060), list invalid allows 0.99x (p 0.075), mutual 0.99x
(p 0.18). allowsAcyclic runs only on transforming roots, where a
finally measured no cost, so it uses one.

The emitted code is unchanged: for each row below, its scope's
precompilation, root apply and allows are byte-identical to the branch
tip's.

Against the branch tip (bd8f17d), paired in one process per sample
(both builds loaded, loops alternated ABBA, which build loads first
alternating by process), median of 30 rounds; p is Wilcoxon signed-rank
on the log ratios:

  row                          n   load     tip -> this    ratio  p
  list allows (8 deep)        30  3.1-6.2   61.0 -> 59.4    0.98  0.15
  list apply                  30  3.1-6.2   61.9 -> 63.2    1.02  0.85
  list invalid allows         30  3.1-6.1   55.0 -> 57.1    0.99  0.72
  mutual allows               30  3.2-5.8   50.2 -> 50.1    0.99  0.93
  morph apply (acyclic path)  30  3.2-5.8    731 -> 729     1.00  0.85
  list invalid apply          20  2.9-7.4   5447 -> 5427    1.00  0.63
  deep allows (100, bound)    20  2.8-7.4   8138 -> 8358    1.00  0.97
  tree allows (depth 3)       20  2.8-7.5    502 -> 508     1.00  0.91
  tree apply                  20  3.0-7.4    475 -> 484     0.95  0.60
  flat allows (acyclic)       20  2.9-7.4    3.9 -> 3.9     1.01  0.68

(ns; ratio is the median per-process ratio.) An A/A run, the branch tip
against a copy of itself on the first five rows (n=20, load 3.3-8.0),
gave 0.99-1.02x, p >= 0.30. No measurable change.

Battery identical to the branch tip (300 lines, jit and jitless).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`static readonly [Symbol.species] = Array` is a define on native class
fields, but a toolchain that transpiles class fields with assignment
semantics emits `ArkErrors[Symbol.species] = Array`. That assignment
walks the prototype chain to Array's getter-only `Symbol.species` and
throws a TypeError in strict code as soon as the module loads:

    TypeError: Cannot set property Symbol(Symbol.species) of function
    Array() { [native code] } which has only a getter

`pnpm buildDocs` hit it: Next's SWC compiled the workspace source to
`static { this[r] = Array }`, and prerendering /playground failed, so
prChecks failed on the branch. Loose class-property transforms (Babel's
loose mode, as in React Native's preset) would fail the same way for
users who transpile dependencies. 2.2.7's static getter did not have the
problem, since an accessor is always defined.

The species is now set with `Object.defineProperty` after the class,
the pattern TraversalError already uses for `arkErrors`, and the field
stays as a `declare` so `.d.ts` output is unchanged (diffed:
`static readonly [Symbol.species]: ArrayConstructor`). It is still a
data property rather than a symbol-keyed static accessor, which keeps
6b3f672's instanceof fast path; that investigation (C1-v8) found
`Object.defineProperty` and a static field equally fast. A probe here
at load 17-30 could not resolve the effect for any form, the 2.2.7
getter included, so no new number is claimed.

Gates on this commit: build, tsc, test (2088 passing), testV8,
testBuild, testIntegration, attest (63 passing), buildDocs (passes;
failed before), prettier and eslint on the file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`pnpm buildDocs` writes llms.txt from the docs content, and the
committed copy predated 6b71f33 (clone config removed) and 3937f4d /
3f56190 (rejectAllowsSymbolKeys). It still documented `clone` and
lacked `rejectAllowsSymbolKeys`. This is the build's output verbatim;
two docs builds (shared worktree and lane) produced identical diffs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The comment said a finally slows every cyclic allows, a line above
allowsAcyclic's finally on a cyclic allows. A reader would "fix" one
or the other. Measured, a finally cost only the short cyclic rows.

With a finally in allowsUntracked (and in allowsAcyclic), against the
branch tip, paired, n=50, load 1.1-3.5: list apply 1.043x (p 0.003),
mutual 1.026x (p 0.006), list allows 1.023x (p 0.016), morph apply
1.011x (p 0.012), list invalid allows 1.010x (p 0.002); list invalid
apply, deep, tree and flat n.s. (n=20, p >= 0.26). That run is what
justifies the catch. allowsAcyclic runs only on transforming roots,
whose apply takes hundreds of ns, so it keeps its finally: with it and
the catch, morph apply measured 1.00x against the branch tip (n=30, load
3.2-5.8, p 0.85), within the A/A spread.

The message of the fix this follows led with a weaker run instead:
catch against finally (n=30, load 3.6-7.1), whose list apply 0.90x
(p 0.010) the two runs against the branch tip put near 0.98x (1.043x for
a finally, 1.02x for the catch), so that row is noise; its other rows
were n.s. (p 0.06-0.18), morph apply 1.006x (p 0.13) among them. Its
table, the catch against the branch tip, was taken at load 2.8-7.5, so
it rules out only changes outside the A/A spread (0.99-1.02x), not the
1-4% detected above at load under 3.5. That table's base, bd8f17d, has
the same traversal.ts as the fix's parent.

Comment only; built behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s name

mergeTransformed merges a later Transform step's output r, computed on
a key's input, into the previous step's output l. A key of the input
that r lacks was deleted by the later step, so the merge deletes it
from l's copy. The check was `k in r`, which walks r's prototype chain:
a deleted key named like an Object.prototype member (toString, valueOf,
constructor, hasOwnProperty, ...) read as present, so l's value
survived. The check is now an own-key check,
Object.prototype.hasOwnProperty.call, the form structure.ts uses for
the same question.

Reproduction, jit and jitless:

	type({
		"p?": ["object", ["string", "=", "d"]],
		"[string]": { "+": "delete", "[/^\\d+$/]": "object" }
	}).assert({ p: Object.assign([{}], { toString: 1 }) })

p's tuple default appends "d", which the index value rejects, so the
index value transforms p's input, deleting its undeclared toString, and
the two outputs merge. p kept toString: own keys ["0", "1",
"toString"]; now ["0", "1"], as for a key named "z".

The bug came in with dcde765 on this branch; 2.2.7 has no
mergeTransformed, so no released behavior changes. Only keys with two or
more transforming steps reach this loop, which no bench row has.

Test: "index transform deleting an inherited key name" in
transform.test.ts fails before (own keys include "toString") and
passes after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The unreleased CHANGELOG entry for removing `clone` said transforming
never writes to the input. That holds for plain objects, arrays and the
builtins copyOf can copy (Date, Error, Map, Set, RegExp, URL, Headers,
typed arrays, ...), but a builtin whose state can't be read or rebuilt
(a function, Promise or WeakMap) or isn't copied (FormData, Blob) is
transformed in place and returned, by design since 1a9a6fc and as
2.2.7 did for every input. A Function, Promise or WeakMap with a
`string.trim` prop comes back as the input itself with the prop
trimmed on the caller's object, and an object holding one is returned
as is.

The sentence now names that exception. No other doc repeats the claim.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
copyOf copied an array's elements with data.slice(), which builds its
result through ArraySpeciesCreate: it reads data.constructor and, if
that is an object, its [Symbol.species]. An array's named keys are
user data, so an own `constructor` key decided the copy:

	type("string.trim[]")(Object.assign([" a "], { constructor: 1 }))
	// TypeError: object.constructor[Symbol.species] is not a constructor

and an own `constructor: Sub` (Sub extends Array) gave a plain array's
copy Sub's prototype. 2.2.7 does the same (its deepClone also sliced),
so both are released behavior, but the first is a crash on valid input.

slice is kept where its constructor is Array, so the common copy is
unchanged. Any other array, an own constructor key or a subclass, is
copied by concat on a new array, which reads no constructor of data
and keeps holes as slice does, and is given data's prototype, as
copyOf does for any other object. A subclass's copy is therefore still
an instance of it, but its constructor is no longer called to make it,
and its Symbol.species no longer chooses the copy's class (no test
relied on either).

A species-free copy for every array was measured first and rejected:
concat plus a prototype check, paired in-process against the branch tip
(0835919) from source, 20 rounds, medians:

  trim array (8 elements)   388 ->  428 ns  1.10x  1/20 wins, sign p 4e-5
  tuple default             197 ->  228 ns  1.16x  0/20, sign p 2e-6
  arr32 (32 objects)       1585 -> 1657 ns  1.05x  8/20, p 0.5
  recursive morph          6340 -> 6737 ns  1.06x  1/20, sign p 4e-5

and the prototype check alone (slice kept) cost 1.07x and 1.08x on the
first two (0/20 and 1/20 wins). This commit, same setup:

  trim array (8 elements)   408 ->  415 ns  1.02x  11/20 wins, p 0.91
  tuple default             201 ->  202 ns  1.01x  10/20, p 1.0
  arr32 (32 objects)       1727 -> 1701 ns  0.98x  10/20, p 0.94
  recursive morph          6712 -> 6622 ns  0.99x  10/20, p 0.77

(p is Mann-Whitney, sign p the binomial on per-round wins), so no
measurable change. The check names `constructor` because it is slice's
own precondition; no general species-free copy matched slice's speed.

Tests: "array with undeclared props" (jit and jitless) adds an array
with an own constructor key, which threw before; "preserves prototypes"
adds an Array subclass, which passed before and pins that its copy
keeps the subclass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
aabdb2f copied an array whose constructor isn't Array with
[].concat(data), which honors data's Symbol.isConcatSpreadable. For a
subclass that sets it to false, concat yields [data], so the copy took
one element and the transformed index 0 overwrote it:

	class NotSpread extends Array {
		get [Symbol.isConcatSpreadable]() {
			return false
		}
	}
	type("string.trim[]")(NotSpread.from([" a ", "b"]))
	// ["a"], length 1

2.2.7 and this branch before aabdb2f returned ["a", "b"]. A plain
array with own `constructor` and `[Symbol.isConcatSpreadable]: false`
keys threw before aabdb2f and lost "b" after it.

That branch now builds the copy without reading any key of data
besides its indices: an empty array given data's prototype and length,
with data's index keys (the keys[0..i) copyOf already finds, so holes
stay holes) defined on it. The named keys and symbols are copied as
before. The Array-constructor path still slices.

Tests: "preserves prototypes" makes its List subclass non-spreadable
with two elements, and "array with undeclared props" (jit and jitless)
adds a non-spreadable key to its constructor-key array. Both lost "b"
at aabdb2f.

Perf, paired in-process from source against aabdb2f, 20 rounds:
medians (MWU p, per-round wins of this commit):

  trim array (8 elements)   626 ->  623 ns  0.996x  13/20, p 0.81
  tuple default             290 ->  290 ns  0.997x  12/20, p 0.83
  arr32 (32 objects)       2660 -> 2573 ns  0.97x   10/20, p 0.89
  recursive morph          9955 -> 9712 ns  0.98x    9/20, p 0.89

so no measurable change (the machine was loaded, hence the higher
absolute times than aabdb2f's run).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
5f1b47e described the builtins a transform writes in place as those
"whose state can't be copied". Whether one is written in place depends
only on whether copyOf has a copier for its kind (typed arrays,
ArrayBuffer, Date, Error, Headers, Map, RegExp, Set, URL), not on
whether its state could be copied: FormData, Blob and boxed
primitives (String, Number, Boolean) all can be, yet each is trimmed
in place, as are WeakSet, File, Request and Response.

The sentence now says "a builtin arktype doesn't copy" and adds boxed
primitives to the examples.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three bugs in ArkErrors.add and merge that 2.2.7 shares:

- An intersection formed away from ctx.path lost its path. Two errors
  added at the same relative path, e.g. by ctx.reject({ relativePath })
  twice in a root narrow, were stored under byPath["a"] but merged into
  an intersection built from the traversal's own path, so its
  propString was "" and its message read ({"a":1}) must be... with no
  path. The intersection now takes its path and data from the error it
  replaces: a ({"a":1}) must be...

- An error already merged at its path was merged again. A morph that
  calls ctx.error at a path and then returns another ctx.error there
  added the returned error a second time, giving ◦ p ◦ q ◦ q and a
  count of 3. An error that is already a member of the intersection at
  its path is now ignored and not counted: ◦ p ◦ q, count 2.

- A morph that returned ctx.errors merged the ArkErrors into itself.
  At the root both versions listed each error twice. At a key, 2.2.7's
  merge pushed each copy, at a longer path, onto the array it was
  iterating, adding errors until it ran out of memory; the branch tip
  listed every error again under the key, a sibling key's errors
  included (count 3 for one error). merge(this) is now a no-op, since
  every error in it has already been added.

These come first because the next commit derives byPath from the
errors themselves, which needs each error's propString to equal the
key it is stored under.

Parity battery (27 invalid cases, branch tip ae11311 against this
commit): only the cases above differ. Measured together with the next
commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng Array

ArkErrors extended Array, and this branch had already worked around
three costs of that: push on a subclass, Symbol.species (defined, not
assigned, to keep instanceof fast and to survive transpilers that assign
static fields), and join's generic ToPrimitive. David, 10-06: "for [this
branch] ArkErrors should not subclass array it's too much of a mess.
find a better abstraction that essentially maintains a very similar
API with the same levels of granularity".

ArkErrors is now a plain class. Its errors live in issues, an own,
plain Array with one ArkError per path in the order each path first
failed, which is the array Standard Schema consumers read. length and
[Symbol.iterator] forward to it, so for...of, spread, Array.from and
destructuring keep working, and every other member keeps its 2.2.7
granularity: byPath per path, flatByPath and flatProblemsByPath per
error, byAncestorPath per ancestor path, count per add.

byPath and byAncestorPath become lazy getters over issues:

- byPath is built on its first read, or by the first add once 8 paths
  exist, and kept current by add from then on. Below 8 paths with no
  index, add finds an existing path by scanning issues backwards
  comparing cached propStrings, and the first add reads none at all.
- byAncestorPath is built on its first read and appended for new
  paths; a merge into an existing path drops it, so the next read
  rebuilds it in issue order. Its values are flat errors in issue
  order, then intersection member order (2.2.7: add order), an
  intersection merged in from another ArkErrors lists its members, and
  an error a never error replaced is gone.

Invariants, checked on the 27-case parity battery: issues has one
entry per propString, each entry's propString is its byPath key (the
previous commit's B1 fix), both indexes equal a fresh build from issues
whenever they are read, add is the only writer (merge and transform go
through it), count >= length, and every ArkErrors has one V8 map: empty,
1, 3 and 20 errors and after reading the indexes (testV8; on the branch
tip the empty one differed by elements kind). Valid data still builds no
ArkErrors (0 allocations over 14 morph shapes x call, ~standard and
assert, jit and jitless), and the JIT code is untouched: it reads only
currentErrorCount and compares ctx._errors by identity.

Removed: the Array base, species and its defineProperty, the mutable
alias, index keys, the inherited Array methods and the from/of/isArray
statics. out.map(...) and out[0] become out.issues.map(...) and
out.issues[0], which also work on 2.x, where issues returned the
ArkErrors itself. Array.isArray(out) is false, arr.concat(out) nests
it, and a Standard Schema failure's issues is a plain array, so
r.issues.summary is undefined. The CHANGELOG lists the migration;
AE-design.md section 2 has every observable difference.

Rejected:

- Forwarding every non-mutating Array method to issues (the
  runner-up): no breaks for the 8 sampled code files in 7 repos that
  call array methods on a result, but about 35 more members, a callback
  third argument that is issues rather than the result, and a type that
  still reads as array-ish. David may still choose it.
- Dropping length and iteration too: breaks the documented for...of and
  11 sampled iteration sites, and out.length becomes a silent undefined.
- Own index keys, so lodash, Array.prototype.x.call and index loops
  stay right: +264 ns (x1.88, 0/20, p 1.9e-6) per 1-error result.
- Symbol.isConcatSpreadable, so concat spreads: makes every concat in
  the process x5.72 slower (0/20, p 1.9e-6).
- An eager byPath without the scan ("dict"), same harness: form x0.838
  in the scan's favor (17/20, p .003), strict x0.927 (15/20, p .041),
  container 2-4 paths x0.78-0.85 (p <= .005); ties at 1, 16, 100 paths.

Test edits, each reading the result through issues because it is no
longer an Array, none loosened:

- schema errors.test "Array methods allocate plain Array
  (Symbol.species)" becomes "issues is a plain array": map on issues
  still allocates a plain Array, Array.isArray is false for the result
  and true for issues, spreading the result equals issues, and length
  equals issues.length.
- bounds.test -0 rule, cyclic.test deep union, cyclicGenerated pathsOf,
  repo/testBuild bundle class names: out[0] / out.map -> out.issues.
- standardSchema.test: the failure is type.errors and its issues is an
  array (was: issues is type.errors).
- attest assertions.test "doesn't boom on ArkErrors vs plain object":
  printable shows ArkErrors where it showed [ArkError].

New tests: the loud breaks at the type level (out[0], out.map,
out.push, out.length = 0, out.issues.push, assigning to
readonly ArkError[], concat), byAncestorPath read between adds equals
read after, a never error replacing the errors at its path, and errors
at 10 paths with repeats at the 4th and 10th.

Measured on the built packages against the branch tip in
steps/arkerrors-result.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ssalbdivad ssalbdivad mentioned this pull request Oct 7, 2026
ssalbdivad and others added 2 commits October 6, 2026 22:16
Brings in the three commits main gained after 2.2.7 (1ee4923), each
ported to where its logic lives on this branch so its intent holds:

- a003f03 fix(schema): keep object defaults from meta in JSON Schema
  (#1677). main patched BaseRoot.toResolvedJsonSchema in
  ark/schema/roots/root.ts, which this branch no longer has: JSON Schema
  generation moved to ark/sets/toJsonSchema.ts. The conflict in root.ts
  keeps the branch side, and the same meta loop goes into the sets
  module's toResolvedJsonSchema, replacing `Object.assign(result,
  node.metaJson)`: a meta value that is valid JSON (and not a function)
  is emitted as-is rather than as its "$ark.arrayN" reference, a
  non-JSON `default` goes through ctx.fallback.defaultValue, and any
  other key keeps its metaJson form. Every root's JSON Schema, $defs
  entries included, passes through that function. The five new tests in
  ark/schema/__tests__/jsonSchema.test.ts fail without the port and
  pass with it, in jit and (by probe) jitless.

- 97ed4f9 Return empty props for base object instead of throwing
  (#1682). applyStructuralOperation is still in root.ts on the branch
  and merged cleanly: `props` is excluded from the bare-object bypass,
  so it reaches structureOf, which binds $ark.intrinsic.emptyStructure
  for a bare object, and type("object").props is []. The new tests in
  ark/type/__tests__/objects/props.test.ts ("base object", "object
  generic constraint") fail without the change on this branch and pass
  with it, in jit and (by probe) jitless.

- 6c35694 fix(docs): fetch GitHub star count at build time (#1683).
  The branch never touched ark/docs/components/GhStarButton.tsx, so it
  merged as-is; pnpm buildDocs prerenders the count into the static
  export.

Gates: build, tsc, test (2104 passing), testV8, testBuild,
testIntegration, attest tests, prettier and eslint on the changed
files, buildDocs. No existing assertion edited.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The section was headed with a version number before any was decided, and three
of its entries compared against a named release ("as in 2.x", "in 2.x
... works on both versions", "which 2.2.7 kept"). The heading becomes
"Unreleased" and those entries say "before"/"previously" instead, so
the section reads correctly whatever version it ships as. Released
sections are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ssalbdivad

Copy link
Copy Markdown
Member Author

@pullfrog review this

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

Two reproducible crashes in the new copy-on-write helper copyOf (ark/schema/shared/transform.ts): it throws for every RegExp, and for any array carrying an own slice property. Both are reachable through the public API and contradict the "never writes to the input" contract this PR introduces. Details inline.

Reviewed changes

  • ArkErrors no longer extends Array — errors now live in a plain issues array with lazily built byPath/byAncestorPath caches; iteration, length, summary and Standard Schema support are preserved, and the new merge/count logic tracks 2.2.7's traversal-stop semantics.
  • Copy-on-write Transform replaces clone — new copyOf/mergeTransformed helpers plus TransformErrors in ark/schema/shared/transform.ts; this is the source of both findings below.
  • New arksets package — the set algebra is extracted from @ark/schema into a separately installed engine (setEngine). Without it, @ark/schema parses unreduced nodes and throws missingSetEngineMessage on relations/JSON Schema; arktype imports it. I traced the extracted intersection loop (intersectConstraints) against its prior form and found no dropped-constraint or non-termination defect.
  • Public API/behavior updates — configure() must now run before any package import; "+": "delete" removes undeclared symbol keys; root-morph failures throw from .assert; dates describe in UTC.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Error: () => new Error(),
Headers: (data: Headers) => new Headers(data),
Map: (data: Map<unknown, unknown>) => new Map(data),
RegExp: (data: RegExp) => new RegExp(data),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

copyContentsOf.RegExp builds its copy with new RegExp(data), which always owns a non-configurable lastIndex. The descriptor overlay at lines 82-88 then redefines that property with configurable = true and throws TypeError: Cannot redefine property: lastIndex for every RegExp. This is reachable through the public API — e.g. type({ x: "string.trim" })(regexpWithOwnProp) (a RegExp with any own structure-transformed prop) routes through StructureNode.copy → copyOf.

Technical details
# copyOf throws on every RegExp

## Affected sites
- `ark/schema/shared/transform.ts:127` — `copyContentsOf.RegExp` produces a copy that already owns non-configurable `lastIndex` (and does not carry the source's `lastIndex`).
- `ark/schema/shared/transform.ts:82-88` — the overlay copies `Object.getOwnPropertyDescriptors(data)` (including `lastIndex`) onto the copy after forcing `configurable = true`; redefining the copy's non-configurable `lastIndex` throws.

## Evidence
`copyOf(/a/g)` throws `Cannot redefine property: lastIndex`; `type({ x: "string.trim" })` invoked on a RegExp with an own prop throws the same.

## Required outcome
Copying a RegExp must not throw and must preserve `lastIndex` plus any own enumerable props. Assigning `copy.lastIndex = data.lastIndex` (the property is writable) or otherwise avoiding the non-configurable descriptor collision would satisfy this. Typed arrays already short-circuit at line 80 for the same class of reason; RegExp needs equivalent handling.

## Open questions
- Are there other `copyContentsOf` kinds whose copy owns a non-configurable property the overlay would redefine? `lastIndex` appears unique to RegExp.

Comment thread ark/schema/shared/transform.ts Outdated
}
let copy: unknown[]
// slice builds its copy from data.constructor, which a named key can shadow
if (data.constructor === Array) copy = (data as unknown[]).slice()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This branch deliberately supports arrays with named keys (they are copied at lines 63-64), but it calls data.slice() through the instance, so an array that owns a slice property throws TypeError: data.slice is not a function. Reachable via e.g. type(["string.trim"])(arr) where arr.slice is set, since StructureNode.copy/SequenceNode call copyOf on the array.

The constructor shadowing is guarded on this very line; the call should use Array.prototype.slice.call(data) for the same reason.

Suggested change
if (data.constructor === Array) copy = (data as unknown[]).slice()
if (data.constructor === Array) copy = Array.prototype.slice.call(data)

ssalbdivad and others added 12 commits October 6, 2026 22:38
Two crashes in copyOf, both reported on #1685 and reproduced through
the public API:

- A RegExp's copy is `new RegExp(data)`, which already owns a
  non-configurable `lastIndex`. The descriptor overlay forced every
  descriptor to `configurable: true`, so redefining `lastIndex` threw
  "Cannot redefine property: lastIndex" for any RegExp with a
  transformed prop. A descriptor now stays non-configurable where the
  copy already owns that key non-configurably; `lastIndex` is
  writable, so the redefine succeeds and carries the input's value.
- An array copied with `data.slice()` threw when the array owned a
  `slice` key. It now calls `Array.prototype.slice` directly, as the
  `constructor` guard beside it already anticipates a shadowing key.

Both tests fail without the fix, jit and jitless.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Windows CI job failed in bundle.test.ts's afterEach: rmSync threw EPERM on a temp package dir the test had just imported from. Windows can hold such a file briefly after use; rmSync's maxRetries retries exactly EPERM/EBUSY with backoff.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Windows CI job failed removing the bundle test's temp packages with
EPERM, and retrying (413d803) didn't help. buildSync's first call
starts esbuild's long-lived service process with the current cwd, and
that first call ran inside bundleIn's process.chdir(dir), so the
service kept the temp package as its cwd for the rest of the run.
Windows won't remove a directory a live process has as its cwd.

A `before` hook now makes the first buildSync call from the repo's cwd.
Checked on Linux via /proc: without it the esbuild child's cwd is the
temp dir; with it, the repo. The retry is reverted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
mergeConfigs threw a ParseError for any config with a `clone` key, and
exported unsupportedCloneConfigMessage for it. That guard exists only
to report a removed option, so every bundle paid for a message about an
API it no longer has. `clone` is already absent from ArkSchemaConfig,
so TypeScript users get an error for it at the call site; at runtime
the key is now ignored like any other unknown key.

The CHANGELOG still tells users to remove `clone`, and no longer says
it throws. config.ts's imports are back to 2.2.7's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ports #1604's string.base58 to the branch's lazy keyword thunks. It is written inline in the string module, like alpha, rather than as a top-level base58 const: bundle.ts reads a top-level name ending in digits as esbuild's rename of the bare name (here base) and fails the build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`number` now rejects `Infinity` and `-Infinity` the way it already rejects
`NaN` (and `Date` rejects an invalid Date). Like those values, an infinity
is a degenerate result of arithmetic (overflow, division by zero) that is
almost never valid input, and accepting it by default silently lets it
through every `number` and every open-ended range like `number > 0`
(#1598).

The behavior is modeled 1:1 on `numberAllowsNaN`:

- Domain gains a `numberAllowsInfinity` key, valid only with domain
  "number", defaulting from a new global-only `numberAllowsInfinity`
  config option (false). `configure({ numberAllowsInfinity: true })`
  restores the previous behavior; the `number.Infinity` and
  `number.NegativeInfinity` keywords still allow one infinity in a single
  type, e.g. `number | number.Infinity`.
- When neither NaN nor Infinity is allowed (the default), the check is
  `Number.isFinite(data)`, compiled and interpreted alike; each flag alone
  keeps its own check.
- An infinity reports as itself, so errors read "must be a number (was
  Infinity)", matching "(was NaN)". The expression of an allowing node is
  `number | Infinity | -Infinity`, like `number | NaN`.
- Intersecting number domains allows a non-finite value only if both
  sides do. When each side allows one the other excludes, the result is a
  new node with both flags explicitly false so global config can't flip
  it back on.
- A discriminant's typeof check excludes neither NaN nor Infinity, so a
  number branch keeps its domain check unless it allows both.
- `number | number.Infinity` stays a union, as `number | number.NaN`
  already does, rather than collapsing into the flag. Its JSON Schema
  throws on the Infinity unit just as `number | number.NaN` does on NaN;
  plain `number` still emits `{ type: "number" }`.
- fast-check arbitraries pass `noDefaultInfinity` alongside `noNaN`.

`number.safe` and `number.integer` already excluded infinities through
their range and divisor, so they need no new key, but `number.safe` now
reports `must be a number (was Infinity)` rather than its range error,
since the domain check runs first.

The config test helper restored `$ark.config` by reference, but
`configure` assigns into that object in place, so `numberAllowsNaN: true`
leaked into every later test. The new Infinity tests are the first to
notice; the helper now restores from a copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in #1606 (agent docs), #1680 (attest TypeScript 7 support) and
the Infinity default on this branch. Conflicts: package.json keeps
testBuild beside main's new testTsVersions, testTsCompat and
testTypedLoose scripts; arktype's CHANGELOG puts the unreleased section
above main's 2.2.8; attest's puts this branch's bench changes under
Unreleased above main's released 0.57.0.

Ported for TypeScript 7, which main now installs:
- bundle.ts and testBuild.ts import `ts` from ts-morph, since TS 7 has
  no JS API (main's jsdocGen.ts made the same move).
- bench/scenarios.ts annotates the cyclic arktype Tree, which TS 7's
  declaration emit can't serialize (TS5088).
- cyclic.test.ts casts the generic-plus-variadic scope in "deferred
  checks resolving aliases" to never. Once TS 7's checker infers that
  scope, attest's per-node queries read later spreads and module
  members as `any` (9 assertions in spread, imports and submodule
  tests). A full tsc run infers them correctly; the test only asserts
  the runtime throw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main's #1686 moves attest's README content into the docs site, so the
branch's bench guidance (median, returning results, threshold modes) is
ported into attest/benches.mdx and llms.txt is regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit of the ArkType docs against the Unreleased CHANGELOG entries and
the breaking-change inventory, fixing or filling each passage they affect:

- primitives: `number` rejects NaN, Infinity and -Infinity. Name the
  number.NaN / number.Infinity / number.NegativeInfinity keywords for
  allowing them in one type, and the numberAllowsNaN /
  numberAllowsInfinity global options for allowing them everywhere. The
  keyword table itself is generated from `ark`, so it already lists the
  new keywords.
- expressions (pipe): with the `clone` option gone, state the contract
  that replaced it. A morph gets the validated value itself, arktype never
  writes to its input, a transformed object shares untransformed values
  with the input, and untransformed input is returned as is.
- traversal-api, cheat-sheet: ArkErrors is no longer an Array. It is
  iterable and has `length`; indexing and array methods go through
  `issues`. The cheat sheet called it "array-like".
- configuration: configure() must run before any import of arktype or
  @ark/schema, including `arktype/internal/*` deep imports, now that each
  package loads as one module.
- objects (dates): dates in descriptions and messages are written in
  UTC, collapsing to the calendar date or year at midnight. Examples are
  taken from range.test.ts snapshots.
- scopes (cyclic types): an alias that resolves to itself without
  passing through an object, array or morph is a type error and a
  ParseError. Prose only: the snippet would fail twoslash's type check.

Left alone: the internal/introspection node-kind tables (reduction now
runs in arksets, but what they describe is unchanged for arktype users),
the generated apiData.ts and dts bundles, blog posts, and llms.txt,
which is regenerated separately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The cast in "deferred checks resolving aliases" works around a TS 7.1
type printer regression: printing the scope's type without truncation
hits the instantiation limit and leaves generic type nodes cached as
errors, so attest reads spreads inferred afterwards as any. Reported as
microsoft/TypeScript#64683.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ArkRegex's docs lived in its README and the announcement post. It now has
its own root section at /docs/regex, mirroring the Attest split (#1686):
setup, inference, errors, types (Regex, regex.infer/parse/validate,
regex.as), ArkType integration and an FAQ. Types in the examples are
twoslash queries, so they are computed from the built package and fail the
docs build if they drift.

The section gets its own llms.txt at /docs/regex/llms.txt. The primitives
page links to it instead of the announcement, and the README now points at
it instead of carrying the FAQ. The README's semver example switches from
\d* to \d+, since \d* infers an eight-member union, not the type shown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To do

Development

Successfully merging this pull request may close these issues.

1 participant