Skip to content

featherduster - #1685

Draft
ssalbdivad wants to merge 4039 commits into
mainfrom
featherduster
Draft

ssalbdivad wants to merge 4039 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.

Issues this closes

Each was verified against this branch by a test or probe.

Closes #1121
Closes #1473
Closes #1306
Closes #1470
Closes #1529
Closes #1555
Closes #1548
Closes #1530
Closes #1577
Closes #1430
Closes #1600
Closes #1676
Closes #1445
Closes #1160
Closes #1023
Closes #579
Closes #928
Closes #930
Closes #944
Closes #1026
Closes #1630
Closes #1237
Closes #1082
Closes #1476
Closes #1486
Closes #1587
Closes #1437
Closes #1457
Closes #961
Closes #1380
Closes #1543
Closes #1568
Closes #1598
Closes #1370
Closes #1403
Closes #909
Closes #1485
Closes #810
Closes #1498
Closes #1243
Closes #1273
Closes #1481
Closes #1412
Closes #1532
Closes #1490
Closes #1390
Closes #1575
Closes #1492
Closes #1479
Closes #1337
Closes #1310
Closes #1663
Closes #1525
Closes #1448
Closes #1435
Closes #1491
Closes #1287
Closes #963
Closes #1524
Closes #1571
Closes #1475
Closes #1554
Closes #925
Closes #751
Closes #1707
Closes #1720
Closes #1719
Closes #1724
Closes #1469
Refs #1462

Thanks to @RaphaelFakhri (#1721), @louisss1016 (#1722), @RClaudiuM (#1725), @SynthLuvr (#1715), @lprnmns (#1705) and @ybai08 (#1695), whose PRs these fixes started from.

@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) | 𝕏

Comment thread ark/schema/shared/transform.ts Outdated
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 added a commit that referenced this pull request Oct 7, 2026
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>
A Type's interned key no longer holds whether its module's smallest build combines or
shares shapes, as of the fix keeping a Type one with its equal however each module's
smallest writes it. The validators of ark.json's settings hold those keys.
Lazy function expressions pay for themselves by what V8 doesn't read, so they
only pay where there's enough to read. On 11 Types they took gz from 3.55 to
3.57 KB, declaring them to 3.60 KB, for a row already behind Valibot, while
arrows without parentheses, which V8 reads in full, keep most of what laziness
gains there:

  11 Types, min KB / gz KB / import M / first M
  parenthesized arrows, as before 12.2/3.55/7.1/8.7
  function declarations           12.7/3.60/6.1/8.4
  arrows                          12.2/3.55/6.4/8.7

What arrows cost over functions tracks the units' minified text, 47 instructions
a byte: 6394 bytes 0.3M on 11 Types, 33745 bytes 1.6M on 46. So the program
decides by its own text, not a count of Types: under 12000 bytes, half a
million instructions, a function holding its traversals writes them as arrows.
46 Types and up keep declarations, where gz is as good or better (46 10.45 with
arrows at 15.6M, 10.56 at 14.0M, which is the trade the other way).

One form throughout is what compresses. Arrows only for functions under a length
were worse than either: 92 Types at 13.51-13.61 KB against 13.46, 1012 at 158.3M
against 144.0M for 632 KB against 657 KB.

The shared object checks now decide by their text too, written only where the
calls and helpers are shorter than the checks, and only above this size, as a
program below it reads them in full anyway.

Nothing a validator does changes, and neither does what it runs once warm.
A narrow writes a cell, a setter for it, a site calling the setter and a held() on
every validator above it, so that what reads it finds the function its module
created. Where the function reads nothing of its module, that indirection holds
nothing: its text means the same anywhere. So a build writes the text into the cell
itself, and the site, the setter and every held() go. In a size build it's written
into the checker, read directly rather than through a thunk.

What keeps this sound, each refusing back to a site that sets the cell:
- it's a function written in place at the narrow, so nothing else reads it, proved
  to read only its argument;
- everything it reads it declares, or is a global a module a build writes can't
  declare. A global named like a runtime export would be captured, so only standard
  globals pass. this, import(), import.meta and classes refuse;
- its module isn't on a cycle, where a read before its site ran throws;
- its types erase, or it refuses: a cast of something parenthesized isn't in its span;
- a plugin rewriting the module must leave the same code, compared by shape, or the
  build fails as it does for any cell it can't match up.

A variable names the function, so nothing may read that name: a narrow's checker
writes its description in, and a printed name is written as arktype prints an
anonymous function.

min gz, esbuild, smallest (Valibot): 11 narrows 3044 -> 2860 (2765), 46 6052 -> 5764
(5357), 92 7334 -> 6901 (6614), 230 10612 -> 9668 (9893). Default builds drop their
setters and held() too.
…lls them

terser can't drop a read of a global's property at the top level, as it can't
prove the read has no effect, so a webpack app shipped `Object.freeze` and its
siblings whether or not it called them, on every row. The PURE calls hold them
back until something reads the name: esbuild was already dropping these, and a
webpack row gets the same bytes back.
Every program with a union shipped what rewrites a listed issue at a new path
and writes its Standard Schema text, as methods on the issue, which no bundler
can drop, though only piped validators and `~standard` call them. As functions
beside the class they go with their readers; the one thing the issue keeps is a
marker on its prototype, since `instanceof` can't tell an issue another copy of
the runtime wrote.

The other half of the same functions is the path for a Traversal, which only a
compiled validator or a callback hands a union. A new `traversesUnions` feature
is false where no written module names one, which folds that path out. If that
is ever wrong the read of `_p` throws rather than listing anything wrong.

Rows at 19825d3c1 to here, esbuild min gz: 11 2744 to 2744, 46 5649 to 5580,
46n 5764 to 5699, 92 6706 to 6644, 92n 6901 to 6837, 230 9215 to 9137, 230n
9668 to 9601.
Every app shipped `summary`, `throw`, `toJSON`, `toString` and TraversalError,
over 100 B gzipped on each row, though the usual handler reads `.issues` past an
`instanceof` test and nothing else. A class's members can't be tree-shaken, so
ArkErrors is now chosen from tiers by two new flags: `readsErrors` for anything
that may reach the object itself, and `readsSummary` for `.summary` alone.

The usage analysis now follows a call's result: a const holding it, read for
`issues`, `count` or `summary`, or past a test it has failed (the other arm of
`out instanceof type.errors`, or anything after `if (out instanceof type.errors)
return`). Reads of the class itself are followed too, as `instanceof`'s right
side only. Anything else may hold the errors, so keeps them whole, as do `assert`,
`from`, a second argument, a callback's ctx, code the analysis hands off to the
readers, a failed default's message and any other export of arktype. A hoisted
function isn't past a test textually before it, as it can run first.

A bundler keeps a class that extends another and anything a dead branch names,
which is why each tier is a class expression inside its flag's branch and not a
named class selected by it: esbuild folds the flag at link time, after it has
decided what's used. TraversalError is written the same way for the same reason.

Rows, esbuild min gz: 11 2744 to 2643, 11n 2861 to 2764, 46 5580 to 5488, 46n
5699 to 5603, 92 6644 to 6551, 92n 6837 to 6745, 230 9137 to 9038, 230n 9601 to
9499.
Reporting a union is what lists its branches: splitting alternatives out of each
issue's text, grouping them by subject and joining them. That is about 190 B
gzipped on a row whose only union is `string.ip`, whose branches, one for each
format, all fail with the same words, so its listing is the message of either.

A union's issue is now always named at its site, `listedIssue` or the new
`alikeIssue` the build writes where each branch is an intersection of checks with
the one description and no text of its own. So a program reaches the listing only
through a union that needs it. The issue is the same one, marker included, and
what writes a union's text again at a new path still lists it, so a piped or
Standard Schema read is unchanged, which the new test compares to the default build.

Rows, esbuild min gz: 46 5488 to 5310, 46n 5603 to 5419, 92 6551 to 6367, 92n
6745 to 6564, 230 9038 to 8850, 230n 9499 to 9307. 11 and 11n hold no union:
2643 to 2647 and 2764 to 2765, from the order bundling writes modules in.
`type` in a standalone build is a frozen object holding only the errors class, so
each `out instanceof type.errors` reads a property of an object a program ships for
nothing else. A precompiled module now names the class, leaving `type` unread and
out of the bundle wherever a module's sites were its only other reads. A read inside
a site is its factory's, whose text moves, so it's left. So is a write or an optional
read, which the object answers differently than the class's name would.

Rows, esbuild min gz: 11 2647 to 2613, 11n 2765 to 2737, 46 5310 to 5279, 46n 5419 to
5391, 92 6367 to 6334, 92n 6564 to 6535, 230 8850 to 8813, 230n 9307 to 9270.
A narrow the build proved reads only data wrote its failure's site apart from its
checker, which no other reader needs: only an intersection holds it, whose brief
says what a missing key expects. As its own site it's the text of `check` but for a
line, which gzip all but erases: 11n 2737 to 2719, level at 46n and 92n, where a
compiled structure still calls `siteOf`.
`string | null` was the only union most apps write that `domains` serves, and to
serve it `domains` looks a case up by the name of data's domain and carries what
any case's transform would need. A nullable's checker is one comparison and a
typeof, and only object domains, where typeof and arktype's domain differ, or a
branch that transforms, still go through `domains`. So it's no longer a shape: it
pays on every row, at any number of calls, unlike the shapes left, and is also two
branches on the hot path in place of a call and a lookup.

Rows, esbuild min gz: 11 2613 to 2578, 11n 2719 to 2683, 46 5279 to 5230, 46n 5393
to 5343, 92 6334 to 6272, 92n 6538 to 6476, 230 8813 to 8742, 230n 9268 to 9201.
A production webpack build of a package with `"sideEffects": false` dropped
the validators it wrote into re-exports. The build was clean and the app threw
`(0 , types_namespaceObject.U) is not a function` at its first call, however
the module was imported: directly, through a barrel, a star, a namespace.

The cause is webpack's lazy barrels meeting the as-built read (e3ae633), not
its side-effects optimization moving targets. webpack leaves a side-effect-free
module's re-exports unbuilt until a module asks for their names, and
LazyBarrels.classify forgets what was asked of a module with nothing to defer.
Raw, `export const U = type(...)` is a local export, so its names are forgotten.
The read then writes it as `import { create as U } from "~arktype/..."` and
`export { U }`, a re-export, which the rebuild's classify defers under no names
at all. What asked for U was built long ago and won't ask again, so the unit is
never built, U is flagged unprovided and the module comes out empty. Only a
rebuild can do this: `prune: false` writes the re-export on the first build,
where classify replays the names it was asked. `preset: "smallest"` keeps a
local export, and Rspack builds what's asked for itself.

So the names are asked again ahead of the rebuild, through webpack's own
compilation._lazyBarrels.request. Forcing the module not to be lazy instead
would build a re-export the raw build left alone, after the read, which fails
as a module the read missed. That member is private, so at seal an asked-for
re-export of a written module left unbuilt now fails the build: nothing else
would say so, and a webpack that changes how it defers them must not ship a
validator that's undefined. Rspack isn't checked, as its re-exports have no
such name, and a module handle read once rebuilt throws there.

Pinned on both compilers, each shape concatenating and not, with the case the
fix must not widen: a re-export nothing asks for stays unbuilt. 16 of them fail
on webpack without the fix, none on Rspack.
The gate's incremental fuzz stayed red past naming a bound copy by its original, at
`ARK_INCREMENTAL=1:242057:17`, on `type("string.date.parse").or("null")`. The other
number a process counts reached the same name through meta: `contentKeyOf` hashed
`node.metaJson`, which writes an object or function as what it's registered by, so
`$ark.object` in a new process and `$ark.object$2` after a build that registered
another. A keyword's `jsonSchema` was enough.

Meta is now read as inner is, so what's compared by identity prints alike, whichever
was registered first. Registered names told apart what nothing else in the name
does, so each case keeps its answer. A function reaches a validator's holes, which
compare it at runtime, or the build refuses it. An object in meta already kept a Type
from being named, but only among its references, and `declaredIn` and `declaredOut`
aren't references: that's how `string.date.parse` holding one got a name at all.
The name now reads, and refuses, exactly the nodes contentKeyOf reads, so a Type
declaring an object stays its own, as does `string.date.parse` until keywords' meta
objects are told apart from a program's.

identity.test.ts pins a declared object apart, which merges if the walk stops at
references, a function held the same way, and one a root holds shared. The settings'
validators are written again, as the names they intern by changed.
…gate writes

testCache writes a precompiled unit for every root a test finalizes, as `runtime:
"arktype"` would. A test running a standalone Precompiler in its own thread finalizes
roots there too, and a narrow that build proved only returns its reports compiles
reading `silentCtx`, a value no unit binds. So the gate read those roots as unbound, or as
two units for one key.

Only a standalone build proves it: interpreter.ts passes `runtime !== "arktype"` to
`proveFunction`, so a build writing units holds no such root. The hook now leaves them
alone rather than checking a pairing no build makes.
…head of its config

`configFirstOf` makes a size build's module load the config module ahead of its
validators, as those are created as their module loads, earlier than a site would run.
In a read program it did so for any module, wherever the program loaded the config. So a
module whose sites run as it loads, imported ahead of the config, ran fine where arktype
throws `configure() must be called before any Type is created`. The build's own check, R3,
catches that order, but not through an import it can't read, as `./m1.ts?v=1`: cells fuzz
found it, 2 of 40 apps, and it reproduces from 8ae3b00 on.

Such a module doesn't need the config first. A site running as its module loads runs as
early either way, so either the program loaded the config ahead of it, and nothing moves,
or arktype throws too. Only two kinds still need it: a module whose own imports load the
config, as its site would run after them, and one with a site in a function, which runs
once any module may have loaded it. Everything else is left to `fixConfigured` to throw.
…d now

Dynamic vs Precompiled still listed webpack and Rspack among the builds reading each
module alone, which they no longer are in production, and left out `prune: false` and
`--emit`, which are. It also said reading the whole program gives one value for
`type("unknown")` and `type.pipe()`. It's the other way round: a production bundle keeps
them apart as arktype does, and a build reading each module alone is what makes them one.

Checked on 20 builds, each of esbuild, Vite, webpack and Rspack as default, `smallest`,
`prune: false` and both, plus Node development, Node production and `--emit`. The new
identity row pins both differences on esbuild and the Node driver.
…undler keeps

Moving a union's text out of its issue's methods so they could tree-shake with
their readers had readers name what writes it. esbuild decides what's used before
it folds a build's flags, so those names kept the whole listing in any program
reading `~standard`, even with no union: login 1349 to 1567 B gz, moltar 1426 to
1635, shop 3725 to 3861, each of which exports its validators.

So readers call `_m` and `_r` again, as they did, and name nothing. Those two exist
only where a new `rewritesUnions` flag leaves them, written in its flag's branch,
which is true for Standard Schema or wherever errors reach code the build can't
follow. Each writer of a union's text, `listedIssue` and `alikeIssue`, gets its own
class of them, so a program with only alike unions doesn't name the listing either.
`_r` is written by what wrote the issue, from its branches' issues at a shorter
path, so neither writer takes a depth. The test comparing a union's issue to the
default build's covers both, through a pipe's reader and Standard Schema.
TurboFan inlines into one optimized function only so much bytecode: 920 B
by default. Big validators run into that cap, so it decides how fast
they are. Doubling it made ntwine's ThreadView 22% faster on Turbopack.
The same cap is why flat lost ThreadView on Turbopack (0a1a587). Two
patterns cost the most bytecode on ThreadView's valid path; this
removes both.

- A switch over data's domain compared strings from
  `typeof data === "object" ? data === null ? "null" : "object" :
  typeof data === "function" ? "object" : typeof data`. TurboFan can't
  fold that phi into type tests. Speed builds now write each case as
  its own check, `if (typeof data === "number") return ...`, as size
  builds already did (StandaloneCompiler.switch, moved from forSize).
  A speed build returns one case per statement, so flat's inlineCalls
  can still write a called branch in its place. A size build keeps
  its single-expression form, byte-identical. Naming compiles keep the
  switch, so unit names don't change.
- A structure transforming only its sequence (no props, defaults,
  deletes, sliced items or error checks) wrote
  `let out = data; const t = seq(data); if (t !== data) out = t; return out`,
  which is just `return seq(data)`. It now writes that (structure.ts
  jsTf), and units.ts then aliases it to the sequence's Transform.
  666d099 had measured this alias at +0.5% on parse items defaults.
  benchCount now reads -0.01% / +0.02% there.

With both, flat wins on Turbopack, so nextProgram's keepsUnitsApart is
gone. Turbopack flattens wherever its entries reach all sites or none.

esbuild default, gz6: ntwine 180932 -> 180475 (-457), min -9.7 KB;
shop 5494 -> 5484. Every other row's default and every row's smallest
are byte-identical.
Hot, instructions/call, n=11, sign test, ntwine ThreadView:
  ok 3504 -> 3393 (-3.19%, p .001), bad -0.10% (p .23), allbad -0.01% (p .55)
Import 241.68 -> 241.47 M, first validation 248.92 -> 248.77 M (n=5).
benchCount (arktype, standalone, standaloneFirst; 11 repeats each,
Mann-Whitney): every valid-path case within 0.1%. Off by more: JIT
first moltar +0.38% (p .002, error path), standalone errors product
-0.74% (p .006).

Turbopack (Next server chunks), units apart -> flat, both with the
above. gz6:
  1012   271109 -> 200978 (-25.9%)
  ntwine 347332 -> 325823 (-6.2%)
Route import M, n=5: 1012 988.88 -> 929.42, ntwine 1067.37 -> 982.25.
Hot, n=11:
  ntwine ok 2987 -> 2933 (-1.79%, p .001), bad -0.16% (p .55),
    allbad -2.10% (p .012)
  1012 ok -4.85% (p .55), bad -0.03% (p .55), allbad +0.02% (p .55)
  Against the old non-flat build, apart alone: ntwine ok -1.10%
  (p .001), bad -0.26% (p .55), allbad +1.02% (p .55). 1012's valid
  path is bimodal on Turbopack: identical validator code read 2740 to
  3181 instructions across runs.

Hand simulations on Turbopack's flat build first split the ThreadView
gain: domain checks -3.19%, return-of-sequence -2.61%, both -5.04%
(p .001 each).

Tests:
- precompile.test.ts "writes a switch over data's domain as its cases'
  checks" fails without the first change.
- precompile.test.ts "transforms an array whose items alone transform
  as its items" fails without the second.
- flat.test.ts's inlineCalls case now matches the per-case form.

Gates: lint, typecheckRepo, build, test, testPrograms,
testPerturbation, testPruneParity, testDifferential, testMembersFuzz,
testPruneFuzz. An earlier run failed one vite watch test once in
testDifferential's rerun of the suite; it passed in test and here.
testCache's harness reads every root it finalizes to compare units, so a
Type is read as soon as it's created, and the deferred suite's assertions
that nothing is built until a read can't hold under it.
The history worker sets $ark.eagerTypes so what a unit observes happens at
its call, so the deferred suite's assertions that a Type is built only at
its first read can't hold there.
A root reaching an alias (ThreadView, any recursive type) applies by first
asking allowsAcyclic whether its data is shown acyclic, which called
allowsUntracked, which saved the traversal state, ran tAllows in a
try/finally and restored the state. allowsAcyclic now does that itself:
the same state saved, set and restored, the same result, one call and one
try fewer. Each Apply inlines allowsAcyclic, so the bytecode saved goes to
TurboFan's inlining budget for its callers (the root's Apply and what it
calls). allowsUntracked is unchanged for node.allows and the checkers.
The JIT's cyclic roots read the same function, so both paths move together.

Measured on ntwine ThreadView, n=11, perf instructions:u per call (sign test):
- esbuild default: valid 3390 -> 2958 (-12.73%, p .001), one bad 7211 ->
  7197 (-0.18%, p .065), all bad 14105 -> 14077 (-0.20%, p 1)
- Turbopack (stage vs this): valid 2932 -> 2540 (-13.36%, p .001), one bad
  -0.16% (p .55), all bad -0.10% (p 1)
- gz: ntwine default 180428 -> 180502 (+74, min +148), smallest 129184 ->
  129205 (+21)
- benchCount (arktype JIT, standalone, standaloneFirst; 11 runs each,
  Mann-Whitney): every case within 0.05% except JIT first-validation
  moltar -0.84% (p .028); parse tree unchanged.

Headroom: with --max-inlined-bytecode-size-cumulative doubled this bundle
runs ThreadView valid at 2784, so about 6% is still left to the budget.

Variants simulated on the same bundle and dropped, since the budget lands
differently on each: one rootApply call site in callingRootApply (+7.5%),
transforming an untracked root directly past transformResolution (+0.2%,
+0.9% with this), passing the rest of rootApply's arguments as defaults
(+7.9%, +6.2% with this).

Gates: lint, typecheckRepo, build, test, testPrograms, testPerturbation,
testPruneParity, testDifferential, testMembersFuzz, testPruneFuzz,
testJitless.
…r does

A Type and the standalone validator a build writes for it now produce the
same message, expected, problem (where the validator holds one), summary,
union listing and thrown TraversalError message for every failure the test
suite reaches. The differential harness enforces it: it compares each
issue's written text (expected, message, problem where the validator writes
one, nested union errors), the summary, the message assert throws and each
Standard Schema issue's message, in the inline, modules, flat and deferred
layouts. `actual` is not compared, since a validator's issue has none. Every
test that normalized the text apart (unionText, bundled, checkerPrograms,
checkers, the perturbation worker, the cells fuzzer) now compares it raw.

Each difference was settled toward the precompiled text unless the
information was worth its bytes. Sizes are min/gzip against lane/stage
802cc5c on the bench bundles and the external login form:

- " (was X)": dropped. Printing the value in a validator costs +604/+287 B on
  every bundle by kind, +3004/+1255 with arktype's per-code writers. An
  error's actual is now "" for every code but `required`, whose " (was
  missing)" both modes write (+42/+10..+17 B on every bundle): a missing key
  and a wrong type are the one case where the lost value matters to someone
  fixing data. A configured `actual` still writes "(was ...)", and so does an
  `actual` passed to ctx.reject, in both. The per-code en actual writers
  (unit, notEqual, domain, lengths, proto, json.parse) are gone.
- A discriminant miss on an absent key: no "(was missing)" in either; a
  validator can't tell absent from undefined there without per-site data.
- Intersection grouping: gone. Each failed check is its own error in
  `issues`, in the order it failed; byPath keeps the first per path,
  flatByPath and flatProblemsByPath list each. Grouping in validators would
  grow the form row, whose empty email fails twice.
- A union whose every branch fails at its basis at the union's own path:
  both report one union error with no `errors`, expecting the bases listed
  ("must be a Date or a RegExp"), decided at runtime in both modes, since a
  static decision diverged in checker mode and for undiscriminated data.
  +0 on rows without such a union; +304/+125 on saas, whose undiscriminated
  union ships the check (its branches fail alike, so they're described by
  the first base and the listing writer stays out).
- A description holding "or" or commas: arktype's union now expects what the
  validator's listing writes (writeListed is shared), so a branch's text
  splits as alternatives in both.
- assert / TraversalError message: the errors' summary, one line per error,
  in both. The runsArktype issue flag is gone.
- "value at": gone; a path starting with a bracket is written as any path,
  and a symbol key as String writes it.
- An error ctx.reject raises with a code but no text: "must be valid" in
  both; text in its meta is ignored in both.
- A pipe.try morph that threw: "must be <expected> (<thrown>)" in both, as
  arktype wrote it (the thrown value explains the failure; it costs only
  bundles with a callback). A thrown value String can't write is left out
  in both.
- A configured actual with a configured message: the validator keeps the
  problem holding it, so its Standard Schema message is arktype's.
- The `_r` hook relprob added for a union's Standard Schema message is
  gone: a rewritten union issue's `problem` is its relative listing (a
  getter), and a union site's own issue sets `problem`, which the
  Standard Schema result reads as for any issue.

Net bundle cost against 802cc5c: login +42/+10, moltar +42/+10, shop
+39/+19, saas +304/+125; under "smallest" +42/+17, +42/+18, +39/+10,
+298/+119; login form b1 2110/1004 -> 2152/1020 (+16 gz), b2 +42/+13,
optimizeErrors size +42/+14. Instructions per call (node --jitless, perf,
median of 3, login and a nested union): valid within noise (4.5k-6.9k
either side); invalid dynamic 160.4-161.1k -> 156.3-156.9k, precompiled
88.3-89.0k -> 91.0-91.5k (+3%, the missing-key suffix and the bases check).
…lidators

The errors page, configuration, upgrading guide, precompiled validators page,
skill references and examples now show what a Type writes: no "(was X)"
unless an actual is configured or passed (a missing key keeps "(was
missing)"), one error per failed check, no "value at", and assert throwing
the errors' summary.

The upgrading guide gains sections for each break (messages leave out the
value, each failed check is its own error, assert throws the summary, paths
don't start with "value at") and drops the ones they replace (objects print
by their kind, described length bounds say "length"); its "before" examples
keep 2.2.9's text. The validators page keeps only the field differences a
validator's errors still have: no actual, a problem only where configured,
no morph or cause on a pipe.try issue.

The arktype CHANGELOG replaces the entries this undoes (printing objects by
kind, naming a length, describing instanceof misses, "was missing" on a
discriminant, describing constraints alike once) with entries for each
change, and its examples show the current text. @ark/aot's notes that a
validator's errors read as its Type's. llms text and playground
declarations are regenerated by buildDocs.
# Conflicts:
#	ark/docs/components/dts/schema.ts
bb9f0e4 added discrimination tests snapshotting "(was ...)" and a missing
key listed in a union with "(was missing)", which this lane's alignment no
longer writes. The schema declarations are regenerated by pnpm build after
taking stage's side of the conflict in the generated file.
…uthors

Each fix below landed on featherduster from a port or an independent fix,
but each started from a reasonable community PR against 2.x, and the
CHANGELOG thanks every contributor whose work this release supersedes:

- #1721 (@RaphaelFakhri): fast-check keeps a regex's flags (#1720)
- #1722 (@louisss1016): Temporal values survive a morph / deepClone (#1719)
- #1725 (@RClaudiuM): an undefined discriminant case checks its key (#1724)
- #1715 (@SynthLuvr): ~standard.jsonSchema.input drops undefined (#1462);
  only its input half was taken, output keeps routing to fallback.unit

#1718 is not credited: 3.0 answers #1469 with jsonSchema meta rather than
its bare pattern key. #1705 and #1695 were already thanked.

Also notes that a cyclic index signature with no way out now generates
shrinking dictionaries instead of overflowing the stack (6f4dd5d).
# Conflicts:
#	ark/type/CHANGELOG.md
Error messages no longer print the value (error-text alignment), and attest's
own suite runs outside the main mocha spec, so its snapshot of the old text
wasn't updated with the rest.
Error alignment dropped " (was X)" from default messages, but the
integration scripts, testShadow, the homepage's error snippet and two JSDoc
examples (and the API docs generated from them) run outside the mocha spec
the lane gated, so they still pinned the old text.
… harness

The full-text comparison compared every tier as errors "all" does, so each
non-"all" config in CI's matrix disagreed on text the tier never promised:

- errors "none" fails with one root issue whose message is arktype's ("is
  invalid"), but arktype's issue expects "valid" where the validator's
  expects nothing. Its expected is now compared only where errors are
  detailed; message, summary, the thrown message and Standard Schema
  messages still are (18176 and 18160 disagreements, all this field).
- errors "first" compared arktype's problem, which a validator's issue holds
  only where text sets one; the first-issue comparison now masks it as the
  "all" comparison does (most of 17744 and 13302).
- errors "first" reporting an issue alike to arktype's at another path, an
  object reached at two paths, kept arktype's message for the path it
  reached first; it's now written at the reported path.
- errors "first" reporting a union compared its text, which is written from
  each branch's first issue, which a first-issue build finds in its own
  order. Only where it is is compared there, as before the full-text
  comparison.

No disagreement was about acceptance or output. All ten configs pass: the
default, {mutatesInput: true, errors: "none"} and the eight in diffmatrix.
Error messages leave out the value by default now, so a problem writer's
ctx.actual is empty unless actual is configured. The integration locale
renders expected alone, which is what it was checking.
prChecks runs the suite untyped and then typed in one job. With the
release's added suites the untyped run alone takes 15-17 minutes on a
runner, so the job was cancelled at 30 minutes inside testTypedLoose
(run 42606118817); the last green core run took 24.
A generated program's default narrowed over a mutable closure threw as
written but not precompiled: the build checks a default once per node,
arktype once per owner. That difference is excluded already, but the
site after it then merged with it precompiled and stayed new as written,
and comparesIdentity only skipped identity when the precompiled world's
referent threw. Guard both worlds, and let ARK_PRECOMPILE_PROGRAMS take a
reported path to replay one counterexample.
Compatibility, typescript (attest@tsgo took 23 minutes locally),
differential and perturbation were cancelled at their limits on the
release branch, so give each room.
The first Windows run to finish failed twelve tests, each on how the test
read a path or output, not on what the build did: the cache test matched
package files by a '/'-joined root, the inline loader requests put a
backslashed path in a string literal, and the configure-first runs split
Node's CRLF stderr on '\n'.
Its full-text errors tiers compare every call in each layout: locally the
slowest configs take 45 to 60 minutes of mocha alone, and on the runner
both mutatesInput configs with errors: first were cancelled at the hour.
…ges leave it out

7951304 dropped " (was X)" from default messages by making en's default
`actual` writer return "" for every code but `required`, and by converting a
precompiled issue to an ArkError with `actual: ""`. That conflated two things:
what `error.actual` is (a description of the data) and whether a default
message prints it. A `problem`, `message` or locale writer that read
`ctx.actual` got "" unless `actual` was configured, so `divisor: { problem:
ctx => `${ctx.actual} ${ctx.expected}` }` wrote " % 2 !== 0" where its docs
promise "3 % 2 !== 0", and a precompiled site's configured writer got "" too.

Now `actual` always describes the data unless it's configured or passed to
ctx.reject, and only the default text decides to leave it out:

- The per-code en describers are restored from before 7951304 (domain,
  unit, proto, the lengths, notEqual, after/before, union, discriminant,
  predicate; printKind otherwise), and an undeclared key's value is described
  too. minLength's describer no longer returns "" for an empty value, which
  only existed to keep "(was 0)" out of a message that no longer prints it.
- writeActual (errors.ts), used by writeText and the locale renderer, stamps
  an error whose actual came from one of en's describers (an identity set in
  errorWriters.ts, `describesData`). en's default problem writers read
  `printedActualOf(ctx)`, "" for a stamped error, so the default text is
  unchanged: no "(was X)" but "(was missing)". A configured or supplied
  actual isn't stamped, so it still writes "(was X)" in both modes, and
  `actual: ""` still hides it, error.actual included. A locale that inherits
  en's problem leaves the description out as en does; a locale's own problem
  (de's, or the integration test's) reads it.
- The stamp is a private field installed on the error (as ErrorSource stamps
  #config), so an error stays a plain object to JSON, spread and
  for...in. A WeakSet was tried first: an entry per written error doubled an
  invalid call's cost (login and a nested union, read summary: 1.4us ->
  2.8-3.8us). With the field, 4 interleaved rounds read fix 1496/1527/1710/
  1704 ns vs base 1453/1600/1763/1891 ns, within this machine's noise. The
  description is computed eagerly when an error is written (errors are
  written on first read already); a getter would cost a defineProperty per
  error and break the fixed shape compiled error builders give errors.
- Precompiled: a written site (one with a configured writer function) ships
  the runtime's describer for its code only where a configured problem or
  message function can read it, else "" as before, and sets keeps bit 4 so
  writeIssue stamps the writer context and a default problem it computes
  leaves the description out. raisingTraversalOf's writers do the same.
  errorOfIssue no longer sets `actual: ""`, so an issue merged into a Type's
  errors describes its data; its text is kept in meta as before.
- An atBases union with a configured meta.actual keeps `actual: ""`, as
  today: that is a configured actual, and forcing it keeps every locale's
  text as it was.

Sizes (min/gzip, built, base 78c795b): every precompiled default bundle is
byte-identical in length and identical in code modulo minifier identifier
names (checked by normalizing short identifiers), so the gzip deltas are
esbuild renaming noise: login 3132/1477 -> 3132/1480, first 1508 -> 1509,
smallest 1275 -> 1269; moltar 1544 -> 1546, first 1569 -> 1569, smallest
1345 -> 1341; shop 5487 -> 5484, first 5503 -> 5504, smallest 3622 -> 3627;
saas 10106 -> 10103, first 10129 -> 10133, smallest 5492 -> 5490. Login form
b1 2152/1020 -> 2152/1019, b2 1962/987 same, b2 development 3954/1899 ->
3954/1900, optimizeErrors size 2116/1013 -> 2116/1012. Dynamic arktype
bundles grow by the restored describers: +1424/+438 B (login), +1413/+494
(saas).

Text is unchanged: testDifferential reports 0 disagreeing with
ARK_PRECOMPILE_OPTIONS='{}' and '{"mutatesInput": true, "errors": "first"}'.
Snapshots that pinned actual: "" now hold the description, the integration
locale reads actual again ("<string>! (5)", reverting 9bf6816), and new
tests pin a configured writer reading the description in both modes, an
issue merged from a validator, an error's actual beside its message, and a
locale's own problem against en's.
On the macOS runner a next build has hung twice on the release branch, at
different steps, until the job's 45 minute limit, with nothing to show
where. It doesn't reproduce on Linux (4 of 4 local runs pass in about
three minutes). Each build now gets three minutes; a hung one prints what
it wrote, runs once more, and fails with its output if it hangs again.

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

1 participant