Repository navigation
v0.2.0
Let a namespaced rule reach a translator, and hand it the rule's arguments
Namespacing a rule (array.minLength, date.after) took it out of
STANDARD_RULES, which is what gated the translator lookup — so every one of
them silently stopped being translatable the moment it was namespaced. The
gate now asks the catalogue instead, which is the actual definition of "a rule
this package ships".
The arguments were worse. Only min and max, and only for four hard-coded
rule names, were ever passed to the translator. A translator that validates its
variables — rosetta's does — does not degrade on a placeholder it was not
given, it THROWS: a translated array.minLength blew up in the middle of
validating. Every argument the rule carries goes through now, and {{ field }}
resolves to the same last path segment the default templates use, instead of
the full dotted path on one route and the segment on the other.
Found by executing the documented i18n snippet rather than reading it. Three
mutations, each felling its own test.
Close the coverage gate, and the three format bugs chasing it exposed
The publication gate was failing on three of its four thresholds (statements
90 < 91, functions 91.32 < 94, lines 92.38 < 94). The deficit sat almost
entirely in code added without tests of its own: the ported helpers surface
and the normalize-url transcription.
Covering them was not bookkeeping. Running each uncovered helper against the
reference implementation on the same inputs turned up three real defects, and
all three were in the RULES, not just the helpers:
ascii()accepted an empty string. A scan over "" is vacuously true, so a
field with nothing in it passed a format check.url()refusedftp://whilehelpers.isURL()accepted it — the same
string got two verdicts depending on which half of the package was asked.
The allow-list still refusesjavascript:,data:andmailto:, which is
what it is for.coordinates()refused(12.34, 56.78), the shape a map widget hands back.
Two answers that differ from the reference are kept and now named in tests:
an encoded reserved character survives the query sort (decoding %2F to /
changes what a URL means), and malformed percent-encoding is made well-formed
rather than passed through broken.
534 tests. Coverage now 92.53 / 83.96 / 95.57 / 95.01, all four above their
thresholds. Five more mutations, each felling its own test — one of them fell
nothing at first, which is how the url() RULE turned out to be reachable
only through a path no test took.
Give every default message one catalogue, keyed by what the rule reports
A default message was written inline at each rule, already formatted, and a
second copy of the same text lived in the Rust engine. Three consequences,
each of them user-visible:
- the text never named its field ("Must be a string"), which is unreadable
once six fields render their errors in one list; - a rule shared between types reported the bare name, so
minLengthon an
array both said "characters" and made a message written against
array.minLengthunreachable — the rule name IS the provider's lookup key; - the message depended on which engine ran, because a simple schema goes to
Rust and Rust had its own strings.
The catalogue in src/defaults.ts now owns the text and is the sole source:
the TypeScript path interpolates it, and the native path is re-rendered from
it so the engine's own copy can never surface. Rules shared between types are
prefixed by the type that owns them (array.minLength, record.maxLength,
date.after, nativeFile.minSize); rules that are not shared are left alone.
Exposed as @c9up/rune/defaults so a caller can read the keys they write
against.
Fixes a bug none of this set out to find: field.report() pushed its text
verbatim, so a messages provider reached a value rule but NOT a .use() one.
Every cross-field rule — sameAs, notSameAs, confirmed, the date comparisons —
and every rule built with createRule was permanently English, untranslatable
and unoverridable, while a value rule on the same chain honoured the provider.
Reports now resolve through provider, then translator, then the catalogue. An
explicit .message() still wins over all of it.
distinct carried its compared property as an arg named field, which
overwrote the {{ field }} token and made the message name the property
instead of the failing field; it is keyed fields now.
Also drops the reference implementation's name from the package: comments,
doc-comments, test labels and six test filenames now say "upstream". Parity is
shape, never product identity. Every occurrence was a comment or a test label —
no identifier and no runtime string — and the third-party licence file keeps
both reproduced MIT notices untouched, since neither belongs to that project.
Three test labels left in French were translated on the way past.
Verified against the reference implementation run side by side: 41 message
divergences down to 3, all three deliberate and named. 515 tests, 14 Rust,
typecheck, lint. Eight mutations, each felling its own test.
Port the whole helpers surface and align the coercions with upstream
rune.helpers carried 10 of the 39 predicates a custom rule is meant to reuse,
and four of the ten answered differently from upstream. Verified by running
@vinejs/vine 4.4.0 side by side: 63 of 64 behavioural checks now agree, the
last being rune's own wider country tables.
- 29 helpers added. Most are re-exports of predicates rune already had, under
the names a rule copied from upstream expects. New primitives: isSlug,
isDecimal and per-version isUUID, transcribed from validator.js 13.15.35. - getNestedValue reads the PARENT for a bare name. Reading
datareturned
undefined for every rule inside an array item or a nested object. - isDistinct compares by identity, and counts a null-valued key as a value.
Two rows that both left a field empty passed as distinct. - The boolean lists are exact: no trim, no case folding. "TRUE", " true ",
"off" and "yes" were all being swallowed. The Rust engine had its own copy of
the lax table, so the same schema decided differently depending on whether the
native binary loaded — both sides now read one list. - accepted() is case-sensitive, so "YES" no longer passes a consent checkbox.
- enum() takes a native TypeScript enum. It compiles to an object, and
iterating it as an array threw a TypeError. - withMetaData() exposes compile() beside create(), and the wrapper copies
property descriptors instead of spreading — a spread flattened errorReporter
and messagesProvider into dead plain properties. - A validator captures the messages provider and error reporter installed when
it was built, so a later global swap cannot reach back into it.
Named deviations, each with its reason in the code: asDate instead of asDayJS
(no date library in the public contract), null instead of a throw for an
unknown country code, an empty string refused by number() rather than read as
0, accepted() returning the true its own type promises, the Standard Schema
issue path staying an ARRAY as that spec requires, and the provider capture
yielding to a later global when none existed at build time — which is the order
a service provider installs the i18n one in.
Name the deviation behind rune's /testing subpath
VineJS ships the same capability as /factories. rune keeps the capability
and drops the name, because every package here puts its test surface on
/testing — a lone /factories would make rune the exception. The mapping is
now written down next to the helpers, along with why there is no VineString /
VineNumber equivalent to export: rune has no per-type class, and those names
are upstream's product identity.
Honour the async spelling Vine documents, and scope the messages provider
VineJS documents createRule(fn, { async: true }) but its implementation reads
only isAsync, so a rule written from the documentation is built synchronous
and its Promise is dropped: the payload validates while the rule is still
refusing it. Verified against @vinejs/vine 4.4.0, which returns the value
unchanged. rune now honours both spellings.
A validator can carry its own messages provider, as VineJS does with
validator.messagesProvider. It sits between the per-call provider and the
process-wide one, on both the sync and the async path.
~standard.jsonSchema.input() now reads the Standard JSON Schema target and
refuses a dialect rune does not emit. openapi-3.0 spells nullability
nullable: true rather than a type array, and tuples use prefixItems,
which is draft-2020-12 only — a silent best-effort would describe the validator
wrongly.
The /testing helper dropped the third argument of field.report(), so a rule
blaming another field was reported against the current one — the helper
disagreed with the runtime it stands in for.
Await an async rule that never said it was one, and hand the provider a context
An async validator passed to createRule() without { isAsync: true } was
run as a synchronous rule: its Promise was dropped, the run answered
valid: true, and the rule reported its refusal afterwards, into nothing.
A silent bypass of whatever that rule was guarding. It is detected now, the
way it is upstream — the option stays isAsync, and an async function is
routed whether or not it was passed. A validator that merely RETURNS a
thenable cannot be detected before it runs, so the sync path refuses it
loudly rather than reporting a pass nobody checked.
The messages provider was handed the field PATH where the contract says
FieldContext, so a provider reading getFieldPath(), name or wildCardPath got
undefined three times over. Rosetta had already worked around it by
synthesising a context from the string — which is why its wildcard message
keys never matched an array item.
The field-label lookup was one step where it is three: a label keyed by the
bare name covers every path ending in it, so { link: 'some link' } labels
auth.profile.link without writing the path out.
And an array item's name is its INDEX, a number. It was the string "0", so a
rule branching on typeof field.name === "number" to tell an item from a
property had an unreachable branch.
New: @c9up/rune/testing. A rule built with createRule() is a plain object
with a run(value, field), and there was no supported way to test one — you
wrapped it in a whole schema, or hand-rolled a context and tested the rule
against a shape no real run produces. runRule / runRuleAsync / fieldContext
build the real thing.
Build before packing, and stop the docs describing the old validate()
pnpm pack runs prepack and prepare; it does NOT run prepublishOnly.
rune declared only the latter, so packing shipped whatever dist happened
to be on disk — an audit reading that tarball found code from before the
last three commits. Proven and fixed: dist/Schema.js carried zero
occurrences of reportedFailure before, the packed tarball carries three
after.
The published package was never affected — dist is gitignored, the
publish job checks out clean and builds — but a tarball is how anyone
verifies the published shape, so it has to be built from the source it
claims to be.
The README and the demo called validate() as though it returned a
result. It returns a Promise, so result.valid was undefined and the
demo answered 400 to every valid request. Both use validateResult() now,
and the README shows what the throwing form is for.
validateResult was documented as "never throws". It throws for exactly
one reason: a schema carrying unique/exists/useAsync cannot be decided
synchronously, and answering "valid" without running those rules would
be the silent pass this package exists to prevent. Said so.
Make tryValidate honour the reporter, and correct two stale comments
validateOrThrow() handed back the error a bound reporter built and
tryValidate() threw it away, so the two entry points disagreed about the
same run: moving from one to the other silently lost the reporter's
error shape.
The tuple's first slot stays a validation failure. A reporter error that
is NOT one is thrown rather than returned — widening the tuple to carry
an arbitrary Error is what would make tryValidate worthless, and it is
where VineJS draws the line too: its tryValidate is a try/catch that
re-raises anything that is not its own ValidationError.
The nativeFile()/mimeTypes() doc-comments still said rune never reads
bytes. It does, and has since declaring a MIME list started arming the
magic-number check — the kind of stale internal doc that invites the
regression back.
Port the locale tables and both normalisers in full
rune validated a hand-picked subset and called it coverage: 41 mobile
numbering plans out of 169, 48 postal codes out of 70, 30 passport
countries out of 60, 29 VAT countries out of 68. Asking for a locale
outside the subset threw, so a schema that worked elsewhere refused the
number here. The four tables are now transcribed in tables.ts, with the
two entries rune had and the upstream tables do not (postal TR, passport
NO) and Greece under both EL and GR.
normalizeEmail knew only Gmail, and had its rules OPT-IN — so calling it
returned an address Gmail does not deliver to. It now carries the five
provider families and their thirteen options, all on by default.
normalizeUrl shipped every option defaulting to OFF, which made it very
nearly the identity function. It is now a full transcription: www.,
utm_ parameters, query sorting, repeated slashes, data URLs, the encoded
reserved characters that sorting would otherwise re-encode.
The JSON Schema said things the validator does not do. A length rule on
an array emitted minLength, which JSON Schema ignores on an array — so
every array and record length constraint was silently dropped. hexCode
emitted format: "color", which is not a format anything enforces. A
non-strict boolean claimed type: "boolean" while accepting "true" and
"on". alpha()'s options never reached its pattern. The root object never
said it refuses undeclared keys. And ~standard.jsonSchema.output()
returned the INPUT schema, which is a guess dressed as an answer — it
refuses now.
Two smaller ones found on the way: the Swiss UID check wrapped the wrong
way and refused valid numbers whose weighted sum left a remainder of 10,
and ipAddress() could not be called with a bare version.
Verified by differential testing against the implementations themselves:
57 450 cases for the tables and normalizeEmail, 840 for normalizeUrl,
zero divergence; 31 of 33 JSON Schema shapes identical, the two others
being deviations named in the code. Both sources are MIT and their
notices are reproduced in LICENSE-THIRD-PARTY.md.
Changes since v0.1.16.