Repository navigation
featherduster - #1685
featherduster#1685ssalbdivad wants to merge 4039 commits into
Conversation
060798f to
776daf2
Compare
|
@pullfrog review this |
There was a problem hiding this comment.
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
ArkErrorsno longer extendsArray— errors now live in a plainissuesarray with lazily builtbyPath/byAncestorPathcaches; iteration,length,summaryand Standard Schema support are preserved, and the new merge/countlogic tracks 2.2.7's traversal-stop semantics.- Copy-on-write
Transformreplacesclone— newcopyOf/mergeTransformedhelpers plusTransformErrorsinark/schema/shared/transform.ts; this is the source of both findings below. - New
arksetspackage — the set algebra is extracted from@ark/schemainto a separately installed engine (setEngine). Without it,@ark/schemaparses unreduced nodes and throwsmissingSetEngineMessageon relations/JSON Schema;arktypeimports 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.
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), |
There was a problem hiding this comment.
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.| } | ||
| 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() |
There was a problem hiding this comment.
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.
| if (data.constructor === Array) copy = (data as unknown[]).slice() | |
| if (data.constructor === Array) copy = Array.prototype.slice.call(data) |
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.
…-clone' into fd/bugfixes
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.
…'fd/reject-discrim' into fd/held
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.
e4b54ea to
8599524
Compare

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>):ark/util,ark/regexarksetspackage:ark/sets,ark/schema/shared/{intersections,sets}.ts(git diff 3166b30ab featherduster -- ark/setsshows what changed after the verbatim move)@ark/schemacore and perf, excluding cycles and Transformclone:ark/schema/shared/transform.tsark/type(public API, lazy keywords,ArkErrorsno longer extends Array, CHANGELOG)ark/repo/{bundle,build,testBuild,publish}.ts, per-packageinternal.tsark/schema/roots/alias.ts,ark/schema/generic.ts,ark/schema/shared/utils.ts,ark/type/__tests__/cyclic*(a rewrite follows as its own PR)ark/docsKnown before review:
allowsthrows a RangeError on valid rings from 38 members (12 jitless).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.