Skip to content

featherduster - #1685

Draft
ssalbdivad wants to merge 2914 commits into
mainfrom
featherduster
Draft

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

@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>
…smaller than the default

The Precompile page's size chart and the FAQ said the "smallest" preset
bundles valibot.dev's login form in 1313 B gzipped, measured at 8951070.
The board's Method says the default build bundles the same form in 1158 B
(forms/, 808927f), so the docs showed a size build bigger than the
default. Both were right at their commits: the default build has shrunk since
8951070, and so has the preset. Rerun at 1e74181 with login/size.ts
(results/login-1e74181e4.json), the form is 1140 B with the default build
(1158 with forms/, whose entry exports its assert instead of calling it),
1037 B with "smallest" and 1547 B with Valibot. The chart and loginSize now
say 1037 / 1.04 kB.

Dynamic ArkType grew in the same interval: 82965 B on the form (was 82263),
and `import { type } from "arktype"` by dynamicSize's method is 83000 B
gzipped, 260577 minified (was 82292 / 258336 at f29b8d1). dynamicSize and
the CHANGELOG say 83.0 kB / 261 kB. The CHANGELOG's precompiled sizes were
the homepage's login and shop apps as development builds at e9e6ebc (1.8
and 9.0 kB). As the production builds the board averages
(results/arktype-808927feb.json) they are 1478 and 6662 B, so it now says
1.5 and 6.7 kB.
…he earlier averages as history

A skeptic following the board's Method link landed on a table of the six
suites the rule started with (the board says 5, 4 and 4), and then on the
first average, where ArkType loses size. The standings the site states were
the last table, about 250 lines down. Best average now opens with the
current standings at 808927f, a table of all 13 qualifying suites (75, 9
and 7 rows, each with its measure and who runs it) and the leaderboard. Then
come the rule and "Which suites", unchanged. Every earlier average follows
under History, unchanged except for its heading level. The #best-average
anchor the site links to is unchanged.

It also records the login form's sizes at 1e74181 and why login/size.ts's
default build (1140 B) and forms/' (1158 B) differ.
…he plan

Section 01's receipt said every speed sentence on the page was a test fixed
before its data, "one run, published whatever it shows". That is true of the
homepage's benchmark in 05: one run of every library, 20 passes at one commit
(provenance.passes), with a 10-pass replication tallying each verdict. It
isn't true of the board, whose averages combine 13 suites measured at several
commits under a rule written before any average was computed. The sentence
now says each separately, and links Method (#best-average) instead of the
plan, which covers the docs' moltar claims rather than this page's.
The release post's dynamicSize claim and the size chart's dynamic bar
were measured at f29b8d1 (82292 and 82263 B gzipped). Since then,
checking UUIDs, IP addresses and ISO dates by their char codes
(e9e6ebc) added 779 B gzipped to every dynamic bundle, so the claim
understated it by 0.7 kB. Remeasured at 5acd851 by the same methods:

- `import { type } from "arktype"`, bench/bundle.ts's settings
  (esbuild 0.25.10, minify, esm, neutral, "production", gzip -6):
  82983 B gzipped, 260519 minified. 2.2.9 from npm, same settings:
  47797 / 156116, so "up from 47.8 kB" stands.
- the login form, login/size.ts (bundlejs settings, gzip -9): 82946,
  results/login-5acd85130.json. Only its dynamic row is cited; its
  precompiled rows are pruned and assert the form, unlike the chart's.

The method was already fair: same esbuild, entry and settings on both
sides, production conditions, tree shaking on, and nothing in the bundle
that `import { type }` doesn't reach. The CHANGELOG's figure moves with
it (83.0 kB, 261 kB minified).
The page titles (the homepage's and the layout's default), the default meta
description and llms.txt's summary said "TypeScript's 1:1 validator, optimized
from editor to runtime" while the homepage's h1 says "TypeScript's smallest,
fastest validator." They now read claims.headline, which claims.ts checks
against the benchmarks. llms.txt gives the headline its asterisk and the
footnote with Method, as the homepage does. The hero eyebrow and the footer
keep "TypeScript's 1:1 validator" as the identity line.
# Conflicts:
#	ark/docs/public/docs/precompile/llms.txt
…art shows the default build

Both lanes remeasured the login form. lane/launch-dynsize's
results/login-5acd85130.json, which includes arkregex's side-effect fix, is
now the single source for the size chart. The dynamic bar is 82946.

The chart's ArkType bar is now the default build, 1140 B gzipped, which is
what a user ships, instead of the "smallest" preset. loginSize, sizeSpec and
chartsSummary say "default build" / "with its defaults" to match. The three
numbers in question:
- 1313 was "smallest" at 8951070, before builds pruned by usage.
- 1037 is "smallest" today, pruned.
- 1158 in Method is the default build of forms/arktype.ts. It is the same
  form with the same esbuild settings, but its entry exports a function that
  asserts, where login/arktype.ts asserts once; login/'s default build is
  1140.
The README and the comment on sizeChart say each.

The CHANGELOG keeps the dynsize lane's 83.0 kB and this lane's production
sizes of the homepage apps (1.5 and 6.7 kB).
The release is precompilation, and the docs' nav has it as a tab, but the
homepage's nav went Docs, Playground, Blog, ArkRegex, Attest. Precompile now
follows Docs.
The homepage's og.png still showed the old tagline, "TypeScript's 1:1
validator, optimized from editor to runtime", over an autocomplete
screenshot. The release post shared the generic ogDocs.png.

- og.png is now the headline with its asterisk, the board's title
  ("Fast. Small. Quick to start. ~~Pick 2.~~ Pick 3.") and a card per
  category: ArkType 1st, its best average against the runner-up's (1.10 vs
  ata 1.25, 1.01 vs Typia 2.81, 1.36 vs Valibot 1.41), and how many libraries
  and benchmarks are ranked. A footnote says each is a best average of ratios
  to the best.
- image/blog/3.0.png is the post's title and description.

Both are 1200x600, as the metadata declares, in the dark theme's colors with
the site's Atkinson Hyperlegible Next and Cascadia Code. Every number was read
from claims.ts and leaderboard.json at 32c9e21, so regenerate og.png when
the standings change.

A docs page can now name its card with an `ogImage` frontmatter key (a path
under public/image), which also sets the card's alt text to the page's title.
The release post uses it. The homepage's card has the headline and board
title as its alt text.
# Conflicts:
#	ark/docs/public/docs/precompile/llms.txt
…xactOptionalPropertyTypes; format a stored result as prettier does

login-1e74181e4.json is login/size.ts's output reindented with tabs; its
values are unchanged.
The sentence under the Precompile page's charts said they "come from
different builds". Since the size chart switched from the "smallest" preset
to the default build, both charts are default builds of different
benchmarks, so it now says each chart names its benchmark and ArkType's
options.

Regenerated llms.txt (the headline and its footnote), llms-full.txt (the
FAQ's login size) and precompile/llms.txt (the size chart).
The speed card races its dots in; the other two now each get a beat that
says what their number measures.

Cold start snaps: every bar pops on after a wait in proportion to its
instruction count, so ArkType's bar and value appear first and Zod's last.
Bundle size packs: every bar appears at the full track width and compresses
down to its measured width with a slight overshoot, one lane after another.
Both end on the exact widths the data gives (checked against the
reduced-motion render), and reduced motion still shows only the final state.

Board.tsx owns the timing (snap and pack), giving each lane its --at delay
and each race the --won its place number and glint key off, replacing the
linear grow that every bar card shared.

Also hides a card's place number until its board plays: it used to show in
full while the board scrolled in, then vanish as its land animation started.
The scenario tooltip now puts ArkType's time on its own line.
# Conflicts:
#	ark/docs/lib/claims.ts
#	ark/docs/public/docs/precompile/llms.txt
David's notes: the asterisks read as a disclaimer, "one build" is
implied (Pick 3 couldn't hold otherwise) and the qualifying lines around
the headline dilute it.

The headline, the lede, the board's cards, the blog post's <Headline />,
llms.txt and the social card lose their asterisks. The hero ends its lede
with a quiet "how we measured ↓" linking the board's method line, the
blog with "How we measured ↓" linking The numbers, and the method notes
read as sentences ("Ranked by best average across public benchmarks…").

"One build" is never said: the board's stamp, its subtitle and the cards'
"default build" chips are gone, as are the blog's "from one build" and
"one ArkType build leads all three". claims.ts still throws unless one
build holds every category.

The hero's lede now opens "Quickest to start, too.", the third win after
the headline's two, so the first screen states all three before the board
strikes "Pick 2.". The board drops its runner-up deck, each card's
"on average across N public benchmarks" line and its claim paragraph:
the "1st"s, the Zod and Valibot margins and the bars carry it, each card
links its source and the method line sits under the cards. The blog
post's averages, sizeClose and the method note move from its opening to
The numbers. The social cards match: no asterisk, no footnote, "1st of N
libraries" per category.
…duction

A library that converts a validator to JSON Schema through Standard Schema,
like the MCP SDK or the AI SDK, read `~standard.jsonSchema` off a precompiled
validator and got undefined unless ark.json set
`"members": { "~standard.jsonSchema": true }`, failing later inside the
library ("Cannot read properties of undefined (reading 'input')"). Nothing
pointed at the fix; the eval agent without the docs found it by debugging.

Where the build checks what it built (creationChecks other than "none", as
in development and under the test drivers), a `~standard` without its JSON
Schema is now a Proxy whose `jsonSchema` throws:

  ~standard.jsonSchema isn't baked into this validator, which a library
  converting it to JSON Schema reads. Set "members": { "~standard.jsonSchema": true } in ark.json.

"jsonSchema" in it stays false, as a library may test for the extension
before reading it, and the existing tests assert. A production build is
unchanged and still reads undefined, as the development runtime already does
for the other introspection members.

Cost, measured with claim-all-work/finalrun.ts (sizes, sbsize, sury) on
built packages, gzip bytes, base -> this:

  form 1158 -> 1158, login 1478 -> 1477, moltar 1500 -> 1500,
  shop 6662 -> 6664, saas 11603 -> 11604, sury 1107 -> 1107, sb 2037 -> 2037

The minified bundles are byte-for-byte the same length in every row: the
helper is tree-shaken, and the +-2 is esbuild assigning other short names.
A getter on every production `~standard` instead cost +143 to +165 gz per
row (the message, mostly), so it is gated on the checks.

shadow mode copies `~standard` without reading `jsonSchema` unless it's
there, since reading it now throws.
…ting reference

The precompile pages changed with the build's new hints, --check reading the
plugin call and the JSON Schema error, so their Markdown for agents is
written again by the docs build. migrating.md also picks up migrating.mdx's
ark.json line and Zod row, which landed before it was last regenerated.
A file URL's pathname on Windows is /D:/a/..., which join then prefixed
with the drive again (D:\D:\a\...), so the compatibility job couldn't
read the skill's references.
Without a pattern, --check is documented to skip node_modules, dist, out and
build, but the filter only matched a path continuing past one of those names.
globSync tests a directory by its name and its relative path, and a file by
its name alone, so `dist` itself passed the filter and every file directly in
it was checked, as were files directly in a nested build directory.

In a project that runs `tsc` before checking, this listed each emitted module
in dist beside its source, doubling the site count. Found in the release dry
run, checking a `tsc && arktype-precompile --emit` project.

Matching the name at the end of the path as well excludes the directory
itself, so glob never descends into it, node_modules included.
… writes the schema

hono-openapi and @orpc/arktype convert a Type by calling
`toJsonSchema({ fallback })` with a fallback of their own (hono-openapi's
keeps a morph's base, oRPC's writes a Date as a date-time string), every
time, whatever the Type. A precompiled validator baked with
`members: { toJsonSchema: true }` takes only a target, so both threw
"takes only a target it was built with" for every Type, and an OpenAPI
document couldn't be generated from precompiled validators at all.

Where no fallback handler runs while arktype writes a schema, a fallback a
call passes can't change it: arktype merges the call's handlers over the
configured ones and only reads them for codes it falls back on. The build
now writes each baked schema once more, with handlers that delegate to
the configured ones and record that one ran, and passes
`bakedJsonSchema` a flag where none did, for the default write and each
target. A call's `fallback` key is then ignored. Where one ran (a Date
under oRPC's converter, a morph under hono-openapi's), the call still
throws, as the build can't run the library's function on nodes it no
longer has; the message now says a fallback is taken only where its
schema needs none.

Sizes: bakedJsonSchema ships only with the toJsonSchema member, so no
default build changes (claim-all-work/finalrun.ts sizes,sbsize,sury,
label ecosystem-fixes: form 1158, login 1477, moltar 1500, shop 6664,
saas 11604, sury 1107, sb 2037, identical to aoterrors-final1).

Tests: standalone.test.ts pins the equal and refused cases with the
exact message; unplugin.test.ts bundles the hono-openapi/oRPC call shape
with esbuild and compares it to arktype; the differential suite now
observes `{ fallback }` and `{ target, fallback }` for every Type, so a
baked schema that accepts a fallback must equal arktype's.
- shiki.ts's errorLineTransformer (and descendantIncludesText, which only it
  called): highlight.ts marks error lines itself, and nothing imported it.
- pageTree.ts's folderTrailOf, which nothing imported.
- home.css's .c-sky and .text-link rules and print.css's .copy-button: no
  markup carries those classes (the hero's other accent, .c-teal, stays).
- tokens.css's --s-1 to --s-10 spacing scale, --t-intent, --z-content and
  --z-dialog, which no stylesheet or component reads. They aren't in a
  Tailwind @theme namespace, so no utility reads them either.

Each was checked against every .ts, .tsx, .mdx and .css in ark/docs,
including class names components build from template strings.
…han a loop

An object's Allows that reads a key before checking data is an object was

    for (;;) {
        switch (data) { case null: case undefined: return false }
        data.first
        if (!(data instanceof Object || Object(data) === data)) break
        if (!(checks)) break
        return true
    }
    return false

It is now the switch alone, its default case holding every check as one
condition:

    switch (data) {
        case null: case undefined: break
        default:
            data.first
            if ((data instanceof Object || Object(data) === data) && (checks))
                return true
    }
    return false

The switch is the block the checks leave, so the loop goes, and the
constant returns stay apart from the condition: esbuild, oxc, terser and swc
all keep `switch(e){case null:case void 0:break;default:if(e.a,(...)&&...)
return!0}return!1`, as they kept the loop's. The read still comes first and
the object check right after it, so V8 folds the check as before. Other
Allows, which read no key first, keep the loop, and size builds their
returns.

Instructions per call (benchCount, median of 3), parent -> this commit:
- standalone, unminified: allows moltar 83.0 -> 82.0, invalid 102.0 ->
  101.9, parse moltar 99.9 -> 99.9, strict 372.9 -> 372.9, product 6236 ->
  6239, items 3110 -> 3130 (repeats 3084-3135 in both), tree 3442 -> 3444,
  rotating, discriminated and strip moltar unchanged
- JIT: allows moltar 83.2 -> 82.0, parse rows within 0.1%
- the artifact minified by esbuild / oxc: allows moltar 83.1 -> 82.0 /
  83.1 -> 82.0, parse moltar, strict, product, items within their repeats

Bytes, each size row's ArkType bundle from source (gz, parent -> this
commit): form 1135 -> 1134, login 1457 -> 1454, moltar 1480 -> 1470, shop
6619 -> 6584, saas 11493 -> 11434, schemabenchmarks' download 1993 -> 1987,
Sury's schema 1083 -> 1073, and its boolean check (.allows) 287 -> 279.

Built, by the size board's own tools (Rolldown for the download): form
1144 -> 1142, login 1456 -> 1454, moltar 1484 -> 1475, shop 6605 -> 6578,
saas 11499 -> 11437, download 1992 -> 1989, Sury 1092 -> 1081, .allows
289 -> 280.

Cold start (startup.ts, instructions less baseline, median of 20): form
1003.9k -> 998.4k, login 957.7k -> 957.2k, moltar 960.3k -> 959.8k, shop
10420.9k -> 10319.2k.

Writing a boolean's check as `typeof x === "boolean"` rather than
`x === false || x === true` would save another 4 gz a boolean, but costs
parse moltar 100 -> 107 instructions, so booleans keep their units.
96f09c0 named the archive relative to its directory, but GNU tar on
Windows also failed to open the backslashed -C directory ("C\:\\Users...:
Cannot open"). tar now runs in the directory it extracts to and reads the
archive by a forward-slashed relative path, so no drive letter reaches it.
…ready apply

Every stylesheet is global. Much of home.css restated code.css under .home:
the code cards' result lines, ^? blocks, squiggles and hover tokens, plus
.link, .ext, .decl and .tok. shell.css's .copy-face rules were shadowed by
code.css's later copies, so "Copy page" never used them. This keeps only the
declarations that still win something:
- .tok and the cards' .twoslash-hover keep border-radius, which beats
  global.css's :focus-visible radius
- .tok keeps its text-decoration, which beats .twoslash-hover:hover
- ^? blocks keep white-space: normal and min-width: 0 under the cards' own
  pre.shiki and .line rules

The two .board-note blocks are merged into one. CopyCommand's hidden labels
now use the site's .visually-hidden in place of a homepage .sr-only. That
.sr-only had also been picking up Tailwind's utility of the same name.

Checked by recording the computed style of every element, ::before and
::after, with hover and keyboard-focus probes. That covered the homepage,
playground and four docs pages, dark and light, at 1280 and 390 px. The only
difference from the build before this change is the hidden labels: they lose
Tailwind's -1px margin and clip, and stay visually hidden. Outside scripts,
the static HTML is unchanged apart from that class name. The built CSS is
147901 -> 142105 B (28409 -> 27918 gzipped).

shell.css set the copy face to place-items: center start and gap: 8px.
code.css's center and 6px overrode them, so the button never rendered them.
…with one helper each

The runtime and startup tables repeated the same <thead> and the same
ArkType-first <td> loop. The prerendered HTML is unchanged.
…defined

Six suites each defined the same inferred() helper, and two also defined
elementAt. Both now live in __tests__/utils.ts, following ark/sets and
ark/aot. validation.test.ts keeps its own inferred(), which returns
jsonSchema.infer directly rather than reading the result back through infer,
so its display snapshots stay as they are.
A discriminant missing two or more keys below data fails through
failsAtPath, which called ctx._in unguarded. A size build's Allows and a
build whose errors are "none" or written apart call a unit's Apply with no
ctx, so every operation on such a union threw "Cannot read properties of
undefined (reading '_in')", e.g. { v: {} } against
{ v: { kind: "a", x: string } } | { v: { kind: "b", y: number }, w: string }.
It now returns true without a ctx to report to, as failsAt does.

The default build's differential run passed, as its Allows never reaches
the miss; the CI matrix's mutatesInput, optimize: "size" and
optimizeErrors configs didn't. discrimination.test.ts's differential run
now has 0 disagreeing under each of the matrix's 10 configs.
…han a loop

An object's Allows that reads a key before checking data is an object was

    for (;;) {
        switch (data) { case null: case undefined: return false }
        data.first
        if (!(data instanceof Object || Object(data) === data)) break
        if (!(checks)) break
        return true
    }
    return false

It is now the switch alone, its default case holding every check as one
condition:

    switch (data) {
        case null: case undefined: break
        default:
            data.first
            if ((data instanceof Object || Object(data) === data) && (checks))
                return true
    }
    return false

The switch is the block the checks leave, so the loop goes, and the
constant returns stay apart from the condition: esbuild, oxc, terser and swc
all keep `switch(e){case null:case void 0:break;default:if(e.a,(...)&&...)
return!0}return!1`, as they kept the loop's. The read still comes first and
the object check right after it, so V8 folds the check as before. Other
Allows, which read no key first, keep the loop, and size builds their
returns.

Instructions per call (benchCount, median of 3), parent -> this commit:
- standalone, unminified: allows moltar 83.0 -> 82.0, invalid 102.0 ->
  101.9, parse moltar 99.9 -> 99.9, strict 372.9 -> 372.9, product 6236 ->
  6239, items 3110 -> 3130 (repeats 3084-3135 in both), tree 3442 -> 3444,
  rotating, discriminated and strip moltar unchanged
- JIT: allows moltar 83.2 -> 82.0, parse rows within 0.1%
- the artifact minified by esbuild / oxc: allows moltar 83.1 -> 82.0 /
  83.1 -> 82.0, parse moltar, strict, product, items within their repeats

Bytes, each size row's ArkType bundle from source (gz, parent -> this
commit): form 1135 -> 1134, login 1457 -> 1454, moltar 1480 -> 1470, shop
6619 -> 6584, saas 11493 -> 11434, schemabenchmarks' download 1993 -> 1987,
Sury's schema 1083 -> 1073, and its boolean check (.allows) 287 -> 279.

Built, by the size board's own tools (Rolldown for the download): form
1144 -> 1142, login 1456 -> 1454, moltar 1484 -> 1475, shop 6605 -> 6578,
saas 11499 -> 11437, download 1992 -> 1989, Sury 1092 -> 1081, .allows
289 -> 280.

Cold start (startup.ts, instructions less baseline, median of 20): form
1003.9k -> 998.4k, login 957.7k -> 957.2k, moltar 960.3k -> 959.8k, shop
10420.9k -> 10319.2k.

Writing a boolean's check as `typeof x === "boolean"` rather than
`x === false || x === true` would save another 4 gz a boolean, but costs
parse moltar 100 -> 107 instructions, so booleans keep their units.
…d beside it

The headline and the homepage's board now average only the suites we
didn't write: every qualifying suite but the homepage's benchmark,
startup.ts, the homepage's cold start and the homepage's apps, so
ArkType's own benchmarks don't decide its claims. valibot.dev's login form
stays, since its code is Valibot's. The rule is one predicate,
independentSuites in leaderboard.ts, applied to all three categories;
fullLeaderboards keeps every suite.

Standings at 64baee3's leaderboard.json:
- speed: ArkType 1.067, ata 1.255 (17.6%), robust to leaving out any suite
- startup: ArkType 1.000, Typia 8.962, over two rows that time creating a
  schema, which the default build does when it builds
- size: Typia 1.256, ArkType 1.297, ajv 1.317 (Typia on 2 of 3 suites);
  ArkType's allows-tip rows narrow it to 1.0%
- full board: speed and startup ArkType, size ajv 1.326 to 1.462

Of the uniform subsets computed (by library count, suites measuring all
three categories, no boolean-only rows, libraries on every suite, third-party
harness only), none put ArkType first on size on the committed rows without
unranking the runners-up; this one is the easiest for a reader to check.

The copy follows the standings, so a rerun that gives ArkType the size
lead flips it without edits:
- headline: "smallest, fastest" only while ArkType leads size, otherwise
  "TypeScript's fastest validator."
- the board's title says "Pick 3" only while it leads all three (as before)
- the blog's "Pick 3." is a claim, pending until then
- the guard reads the standing copy (headline, pickThree, pickThreeLine,
  boardTitle): naming a category's superlative, or "Pick 3", throws unless
  ArkType leads it. Hard-coding "Pick 3." throws today on size.
- headlineNote and boardNote state the rule, that startup's independent rows
  time schema creation, and ArkType's place on the full board ("2nd of 14
  for bundle size, 1.46 to ajv's 1.33")
- startupBasis (blog) says what the two startup rows measure and that our
  own whole-app startup runs keep ArkType 1st
- sizeClose names Typia's lead; benchmarkOwners counts ours on the full board

Method (README Best average): both tables, "The headline's suites" with the
rule, when it was chosen (after the standings were known) and what the other
subsets showed, and Objections updated.

The blog's frontmatter description still says "pick 3"; it can't be a claim.
- .btn .arrow.down: no markup marks an arrow .down.
- .pair2 + .pair2: a .pair-note always sits between the two pairs.
- .card-rest:empty: every card writes its unit or key there.
- .lane-bar.is-clipped: Board never marks a lane's bar clipped. Only the
  benchmark chart's bars clip.

Found by recording Chrome's CSS rule usage across every exported page, dark
and light, at desktop and phone widths, then checking each rule it never
matched against the markup that could produce it.
flat.ts's originOf took a module it never read: a binding carries its own
declaration. Artifact.writeFactory took a validator name it never read. The
factory is written by its index, and the caller exports it by name with
writeExport.
Six places spelled out the same configured option for a main.ts that imports
./config.ts. Only the fingerprint differed.
A discriminant missing two or more keys below data fails through
failsAtPath, which called ctx._in unguarded. A size build's Allows and a
build whose errors are "none" or written apart call a unit's Apply with no
ctx, so every operation on such a union threw "Cannot read properties of
undefined (reading '_in')", e.g. { v: {} } against
{ v: { kind: "a", x: string } } | { v: { kind: "b", y: number }, w: string }.
It now returns true without a ctx to report to, as failsAt does.

The default build's differential run passed, as its Allows never reaches
the miss; the CI matrix's mutatesInput, optimize: "size" and
optimizeErrors configs didn't. discrimination.test.ts's differential run
now has 0 disagreeing under each of the matrix's 10 configs.
…'s domain

A union discriminated by domain whose cases are one domain and a unit it
allows outright, as `number > 0 | null` or `string | undefined`, compiled
its Apply as a switch over `data === null ? "null" : typeof data` with a
case returning true. It's now one expression, `data === null || (typeof
data === "number" ? <the number case> : <the miss>)`, which reports the
same issues: the case's own where data has the domain, the union's miss
otherwise. An object case keeps the switch, since its domain test also
reads functions.

gzip, source mode, against afb4cee: sb 1987 -> 1975 (-12, its
discount), saas 11434 -> 11427 (-7); the form, login, moltar, shop and
Sury rows are unchanged. Only data a union rejects reaches an Apply, so
valid data runs the same code.

flat.test's fixture for inlining a function at its one call now unions
three cases, so its number case is still a switch case to inline.
… ctx

An Allows that never reads ctx kept the parameter in a default build, as
its `(data, ctx) =>` shares text with the unit's Apply, which gzip
prefers (d23b054 measured +67 B on shop dropping it there). A program
that never calls its validators, asserts or reads ~standard keeps no
Apply, so its Allows now drop the unread ctx as a size build's do.

Sury's check (.allows): 599/280 -> 589/278 min/gz. Every other row calls
its validators and is unchanged.
A site whose message is configured carried a closure, `text: issue => {
issue.problem = problem; issue.message = message }`, that _at called on
each issue. Its message is now the site's `x`, which the issue literal
reads in place of `message: undefined`, so textSiteOf is
`({ code, description, x: message })`. A site whose problem is configured
is problemSiteOf, which keeps a text closure for it, and writtenSiteOf's
text is unchanged. `x` is a name neither a JIT error context nor a size
build's checks hold (checks already use `t`).

An issue of a message-only site no longer holds `problem: undefined` as
its own key; its problem was undefined either way.

gzip, source mode, against 5fc6d12: form 1134 -> 1121 (-13: valibot.dev's
form configures every message); login, moltar and the Sury assert bundle
+1, shop +2, saas and sb unchanged, Sury's check unaffected.
… its own

A program configuring a message has sites that don't configure one too,
so it shipped both siteOf and textSiteOf, which differed only by the
message. siteOf now takes it as a third parameter, written where it's
set, and textSiteOf is gone. Every program pays the parameter in its
siteOf, so this trades a few bytes on programs configuring no message for
the helper programs that do configure one save.

gzip, source mode, against 02a14da: form 1121 -> 1110 (-11); login +3,
moltar +1, shop +9, saas +8, sb +3. On the size board's suites
independent of ArkType (the form, schemabenchmarks.dev, Sury's), the form
outweighs sb's three bytes; on all four suites it nets -0.1% too.
… test

Vite can report main.ts's error about the same rejected configuration in
the same failure, after config.ts's, depending on which transform finishes
first. Under optimize: "size" it did, and the snapshot of the whole message
failed. The test now compares config.ts's error alone, which is what it
names.
# Conflicts:
#	ark/docs/components/dts/schema.ts
On Windows CI, testShadow's packed app diverged on every failing validation:
the validator's errors read as its output, the symptom of a second arktype.
Its temp dir is an 8.3 path (RUNNER~1), and Vite resolves through
fs.realpathSync until a `net use` check finishes, so it keeps the short name
for the program's own imports of arktype and @ark/aot/shadow. The plugin
resolves each validator's runtime with realpathSync.native, which expands it,
so Vite loaded @ark/schema's runtime under both paths, and shadow mode's
`instanceof ArkErrors` checked another copy's class.

In shadow mode the plugin now resolves arktype's entries and @ark/aot/shadow
itself, as it does the runtime, so every module arktype loads is imported
from the same form of the path.

testShadow runs the packed app again through a symlinked root with
preserveSymlinks, where Vite keeps a path the plugin's resolver expands, as
on Windows. It fails as CI did without this fix on any platform.

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