Releases: mgilbir/ktecma262
Release list
ktecma262 0.3.0
Everything a consumer needs to tokenize and to compute with dates, and the
last of the issues filed against 0.2.0.
Added
-
ECMA-262 21.4 time value arithmetic, in
io.github.mgilbir.ecma262.date
(#7).makeDay,makeTime,makeDate,timeClipandmakeFullYearare the
specification's own decomposition, kept as separate steps because the rolling
rule is where aggregate arithmetic goes wrong: month 12 is the thirteenth
month, day 0 is the day before the first, hour 24 is the next midnight, and
nothing is clipped untiltimeClip, which is what lets the rolling work.
makeFullYearis the two-digit rule, where 99 means 1999 and 100 means 100. -
parseDateTimeString(text, zone)- the Date Time String Format, 21.4.1.32.
Its one asymmetry decides which day a value lands in: a date-only string is
UTC, a date-time string with no offset is local time. -
EcmaTimeZone, the seam that keeps a time zone database out of this library.
It is consulted for exactly one case - a date-time string with no offset - and
it takes a time value carrying the wall clock rather than an offset, because
the offset depends on the date and the instant is what the caller is trying to
compute. That is the specification'sLocalTZA(t, isUTC = false), and it is
java.time'sZoneRules.getOffset(LocalDateTime)one for one, so a JVM
implementation is a three-line delegation. The spring-forward gap and the
autumn overlap both resolve with the pre-transition offset, as JavaScript
does; a JVM test pins that against node across both 2024 transitions.Anything outside the grammar returns NaN.
Date.parsemay fall back to an
implementation-specific parser and V8 readsMarch 1, 2024,2024/03/01, a
lowercasezand four fractional digits; none of that is portable, and
guessing at it would agree with one engine and disagree with the next.Fourteen planted bugs are caught, and a fifteenth exposed a gap in the fuzzer
rather than in the library: the generator could not produce24:30, so a
missing end-of-day check survived it. The generator now reaches that case. -
scanRegExpLiteral(text, from)inio.github.mgilbir.ecma262.lexer- finds
where a regular expression literal ends (#6). Not a search for the next/:
a backslash escapes what follows, a/inside[...]is an ordinary
character, and a line terminator may not appear in the body at all, which is
what stops an unterminated literal swallowing the rest of a file. An empty
body or a leading star is a comment rather than a literal.It deliberately does not decide whether a
/starts a literal - that depends
on the preceding token, so it belongs to the host grammar - and it does not
validate flags, whichRegExp.compilealready reports properly. -
decodeEscapeSequence(source, backslashAt)in the same package - one
EscapeSequenceorLineContinuation, offset in and offset out (#8). The
three rules worth having a table for: a zero escape is NUL only when no digit
follows, a braced unicode escape may need a surrogate pair, and a line
continuation consumes CR LF as one break and produces nothing. Legacy octal is
rejected rather than guessed at, matching strict mode. -
Double.toEcmaInt32()andtoEcmaUint32()-ToInt32andToUint32,
ECMA-262 7.1.6 and 7.1.7 (#9). These were already implemented privately for
clz32andimul; they are what every bitwise operator coerces its operands
with, so a consumer implementing&,|,^,<<,>>or>>>needs them
first. Not a cast: NaN and the infinities become zero, the value truncates
toward zero, and the rest wraps modulo 2^32. -
isEcmaWhiteSpace(Char)andisEcmaLineTerminator(Char)are public, in
io.github.mgilbir.ecma262.text. The first was internal and the second did
not exist, so a consumer lexing a JavaScript subset was hand-copying a table
this library already holds (#5).They are kept as the two disjoint productions the grammar defines rather
than one predicate for both. A tokenizer has to tell them apart: a line
terminator may not appear in a regular expression literal's body, and it ends
a single-line comment, where whitespace does neither. Trimming and the numeric
literal parser use the union, which now lives in one place so those two cannot
drift apart.Both sets are checked over the whole BMP - 21 and 4 characters, disjoint, and
together exactly whattrimremoves. The sets came from asking node rather
than reading the table: a line terminator ends a single-line comment, and
whitespace separates tokens in a declaration. The declaration form matters, as
an arithmetic probe reports-as whitespace,1 - + - 1being 2.
Fixed
-
\cXinside a character class was rejected under thevflag. The
class-set path had no branch for it at all, so[\cf_]was a SyntaxError
while every engine accepts it and matches U+0006.uand Annex B classes
were unaffected, as was\cXoutside a class.Found by the nightly fuzzer on
/=XwXd[\cf_]bX/vi, one case in 500,000.This is the second bug of exactly this shape -
\0was the first, in 0.1.1 -
so rather than fix it and move on, the two class paths were compared branch by
branch. They now handle the same escapes,vadditionally handling\q,
which is the only one that belongs to it alone. The recorded corpus covers
control escapes inside classes in all three modes.
Available from Maven Central as io.github.mgilbir:ktecma262:0.3.0.
ktecma262 0.2.0
The library stops being only a regular expression engine.
Four more corners of ECMA-262 arrive, each one something no Kotlin target
reproduces on its own: JavaScript's number formatting and parsing, the four URI
escaping functions, Unicode normalisation, and identifier validation — plus the
handful of places Kotlin returns a different answer rather than an error.
Nothing in the regular expression engine changed behaviour, so upgrading from
0.1.4 is safe; the version is 0.2.0 because the surface grew rather than
because anything moved. The JVM artifact grows from 176 KiB to about 269 KiB,
almost all of it the normalisation tables. Shrinkers remove what you do not
call — the tables are a single leaf class with one inbound reference — and on
plain JVM an unused class is never even loaded.
Added
-
String.isEcmaIdentifierName(),isEcmaIdentifier()and
isEcmaReservedWord()inio.github.mgilbir.ecma262.text- ECMA-262 12.7,
reusing the Unicode tables already compiled in.The two questions are different and both are useful: a property key needs an
IdentifierName, a binding needs anIdentifier, and keywords are the first
but not the second. Words reserved only in some contexts -letand the
strict-mode set,awaitin a module,yieldin a generator - are parameters
rather than assumptions.Verified at two levels: every code point against the composed rule from 12.7,
and a sample against what node's parser actually accepts. The second caught a
mistake in the first oracle:o.a-bparses as(o.a) - b, so testing member
access reporteda-bas a valid name. An object literal key is the correct
probe.Five planted bugs are caught. One more showed that an explicit lone-surrogate
branch was dead code, since a surrogate is in neitherID_Startnor
ID_Continue; it has been removed rather than left as decoration. -
String.ecmaTrim(),ecmaTrimStart(),ecmaTrimEnd()andEcmaMathin
io.github.mgilbir.ecma262.textand.number. These are the places Kotlin
disagrees with JavaScript by returning a different answer rather than an
error, on every target including Kotlin/JS.Trimming differs on five characters, measured rather than assumed: Kotlin
strips U+001C to U+001F where JavaScript keeps them, and keeps U+FEFF where
JavaScript strips it. U+00A0 is stripped by both, contrary to a first guess.
Which characters count is verified over the whole BMP.EcmaMathcovers only the exactly specified functions —round,trunc,
sign,clz32,imul,fround— since the rest ofMathis
implementation-approximated and there is nothing to be correct against.
kotlin.math.roundrounds ties to even; JavaScript rounds them toward
positive infinity.Two implementations that look obvious are wrong:
floor(x + 0.5)answers 1
for0.49999999999999994, andtoFloat().toDouble()is a no-op on
Kotlin/JS, where aFloatis a JavaScript number. The second was caught by
the JS target failing while JVM and native passed.The whitespace predicate is shared with the number parser, so the two cannot
drift apart. Five planted bugs are caught. -
String.normalize()inio.github.mgilbir.ecma262.text— ECMA-262 22.1.3.15,
which defers to UAX #15, in all four forms.java.text.Normalizeris JVM-only
and Kotlin/Native has nothing, so multiplatform code comparing user-entered
text has been comparing sequences that look identical and are not.Tables are generated from the UCD, with decompositions stored fully expanded
so normalising is a lookup rather than a recursion, and Hangul left out
because its mappings are arithmetic.Checked against Unicode's own
NormalizationTest.txt: 20,034 rows whose
expectations no implementation produced, each verified against the invariants
the file states — all five columns must agree under each form. Generation
fails if node disagrees with any row. Every one of the 1,112,064 code points
is then checked individually in all four forms against node.Six planted bugs are caught: unstable canonical ordering, ignoring the
composition blocking rule, dropping a Hangul trailing jamo, NFKC using
canonical decomposition, and — in the generator — ignoring
Full_Composition_Exclusionand storing decompositions one step deep instead
of fully expanded. -
String.encodeUriComponent(),encodeUri(),decodeUriComponent()and
decodeUri()inio.github.mgilbir.ecma262.uri— ECMA-262 19.2.6. Common
Kotlin has no equivalent, andjava.net.URLEncoderis a different algorithm
(application/x-www-form-urlencoded) that is wrong for URIs.Encoding is verified over every one of the 1,114,112 code points against
node, not a sample: escaping is what stops untrusted text from changing a
URI's structure. Unpaired surrogates throwUriError.Decoding rejects what UTF-8 forbids — overlong forms, encoded surrogates,
code points past U+10FFFF, truncated escapes — since accepting them turns a
decoder into a filter bypass. Its input is text and cannot be enumerated, so
./gradlew uriFuzzcovers it: 600,000 cases clean, about half of them
rejections, and it runs nightly on three seeds.Five planted bugs are caught: a wrong unescaped set, accepting overlong
encodings, accepting encoded surrogates, decoding reserved escapes in
decodeUri, and substituting U+FFFD for a lone surrogate instead of throwing. -
Double.toEcmaString()inio.github.mgilbir.ecma262.number— JavaScript's
Number::toString, ECMA-262 6.1.6.1.20. No Kotlin target produces it:
Kotlin/JS does because it is JavaScript, but Kotlin/JVM and Kotlin/Native
print1.0,1.0E21and4.9E-324where JavaScript prints1,1e+21and
5e-324. Over 200,000 random doubles the JVM's string differs 98.4% of the
time.Almost all of that is layout — JavaScript stays positional out to 10^21 and in
to 10^-6 — but not all of it: a JDK 21Double.toStringis not shortest for
the smallest subnormals, so reusing the platform digits and re-laying them out
would be wrong in exactly the cases hardest to notice.The specification defines the result rather than an algorithm, which makes two
properties equivalent to it: the output round-trips, and no shorter decimal
does. Both are checked over random doubles, every power of two, the subnormal
range and short decimals, so the tests hold any implementation to the
specification rather than to this one. Four planted bugs — an extra digit, a
missing round-up, ignoring round-half-to-even, and dropping the asymmetric gap
below powers of two — are each caught by the property that should catch them.Ties escape both properties, since both candidates round-trip and are equally
short. Rounding them down instead of to the even significand costs 48 values
in 231,948; the differential fixture and an explicit test carry that rule.Implemented with the exact rational method of Steele & White as presented by
Burger & Dybvig: big integers, no lookup tables. Correctness first — it is 63x
slower thanjava.lang.Double.toStringat the extremes of the exponent range
and 6.8x slower for everyday values. A table-driven method would close that. -
String.toEcmaDouble()—StringToNumber, ECMA-262 7.1.4.1.1, the
conversionNumber("…")performs. The whole string must be a numeric
literal, so this is notparseFloat;0x/0o/0bliterals are accepted but
take no sign, and an empty or all-whitespace string is+0. All 68 grammar
and rounding cases are taken from node.Correctly rounded, which can turn on the 767th significant digit. Bounded
against the inputs that have historically hung decimal parsers: significant
digits are capped with the rest folded into a sticky flag, the exponent is
clamped as it is read, and out-of-range magnitudes resolve before any big
integer is built.Three planted bugs are caught — ties rounding up rather than to even,
accepting trailing garbage, and ignoring the sticky flag. The third initially
was not: no test exercised a value truncated at the cap that was also an
exact tie, because a genuine tie never needs more than 767 digits.
digitsBeyondTheCapBreakTiesconstructs one — the exact midpoint between 1
and the next double, padded past the cap, with a single digit beyond it that
decides the result. -
Formatting and parsing are now checked as inverses against each other, so the
round-trip property no longer leans on the host's decimal parser. -
Double.toEcmaString(radix)for radices 2 to 36. Unlike everything else
here this is compatibility rather than conformance: ECMA-262 calls the result
for a radix other than 10 implementation-approximated and defines nothing
further, so V8's behaviour is what is implemented and the 21,280 recorded
strings are the contract rather than a check on one. Radix 10 delegates to the
specified path. Four planted bugs are caught — dropping the round-half-to-even
step, removing the floor on the error term, skipping the zero fill above 2^53,
and stopping the fraction a digit early. -
Double.toEcmaFixed(),Double.toEcmaExponential()and
Double.toEcmaPrecision()—Number.prototype.toFixed,toExponentialand
toPrecision(21.1.3.3, 21.1.3.2, 21.1.3.5). Checked against node over a grid
of 4,054 values crossed with arguments from 0 to 100: 97,296 strings.All three round ties up, where
toStringrounds them to even. The
specification asks for exactly that difference, and a planted swap to
round-half-even is caught. So is losing the rounding carry —(99.995)
.toFixed(2)is"100.00", and taking the decimal exponent from a rounded
first digit instead of an unrounded one gave"999.95"until the grid caught
it — and getting thetoPrecisionexponential threshold off by one. -
Grisu3 as the fast path for
Double.toEcmaString(), with the exact method
kept as the fall...
ktecma262 0.1.4
Republishes 0.1.3 complete. The library is unchanged; 0.1.3 reached Maven
Central with four of its seven modules.
Fixed (publishing)
-
Publishing no longer uploads each publication separately to the OSSRH
Staging API for the server to assemble into a deployment. That assembly is
what failed: all seven publications uploaded without error, into a single
staging repository, and the deployment the Portal built from it contained
four. Nothing on the upload side reported a problem.The build now stages every publication into one directory, zips it, and
uploads that single bundle to the Central Portal API. What is uploaded is
what is published, with no server-side assembly step in between. -
./gradlew verifyCentralBundlechecks the bundle before it is uploaded:
every publication present, each with a POM, Gradle module metadata and a
detached signature. -
After uploading, the release workflow compares the modules it bundled
against the components the Portal reports for the deployment, and fails if
any are missing. Run against 0.1.3's actual deployment, this reports
exactlyktecma262-iosarm64, ktecma262-iossimulatorarm64, ktecma262-js.
Available from Maven Central as io.github.mgilbir:ktecma262:0.1.4.
ktecma262 0.1.3
Caution
Deprecated — incompletely published.
Only four of seven modules reached Maven Central: ktecma262, ktecma262-jvm, ktecma262-linuxx64 and ktecma262-macosarm64. ktecma262-js, ktecma262-iosarm64 and ktecma262-iossimulatorarm64 are absent, while the root module still declares variants pointing at them — so resolving 0.1.3 for JS or either iOS target fails outright.
JVM, Linux and macOS consumers are unaffected. The cause was in publishing, not in the build: all seven publications uploaded without error and the portal assembled a deployment containing four. The library code is identical to 0.1.4.
Use 0.1.4 instead.
Adds native targets and fixes a parser bug found by the nightly fuzzer.
Added
macosArm64,iosArm64,iosSimulatorArm64andlinuxX64targets. The
engine is purecommonMainKotlin with noexpect/actual, so the sources
are unchanged; all 153 tests run on Kotlin/Native exactly as they do on JVM
and JS, including the ~42,750-case recorded differential suite.
Fixed
-
Annex B's fallback for an invalid
\cconsumed thec. Both
ExtendedAtom :: \ [lookahead = c]andClassAtomNoDash :: \ [lookahead = c]
denote the backslash alone, leaving thecto be parsed as the next
atom, so anything binding to it bound to the wrong thing:- a quantifier covered both characters, making them jointly optional —
/a\c*/matched a bare "a", where the pattern isa,\,c*and
requires a literal backslash; - in a class the
ccould not open a range, so[\c-z]was the three
characters\,c,-rather than\plusc-z.
Found by the nightly differential fuzzer on
/a\c*{?/ig, one case in
500,000. The class-range half was not reached by the fuzzer and came out of
reading the grammar while fixing the first. - a quantifier covered both characters, making them jointly optional —
Testing
- A second direction of the known V8 modifier-scoping defect is now recognised
and skipped. The presence of a modifier group makes V8 drop the case
extension from a negated word class —/(?-i:a)?[^\w]/vimatches the long
s, which folds to "s" and so belongs to\w— while a bare\win the same
pattern keeps the extension, so V8 contradicts itself. Previously only an
addediwas recognised, on the belief that(?-i:…)scoped correctly.
This engine's behaviour is unchanged and matches V8's own answer once the
modifier group is removed;ModifierGroupTestnow pins both directions.
Broadening the skip costs 180 of ~42,750 recorded cases. - The recorded corpus covers the
\cfallback in all three modes, and grew
from 41,271 to 42,753 cases.
Release process
None of this changes the library, but all of it is why 0.1.2 was unusable for
native consumers.
- Releases are now built and published from macOS. It is the only host that
can compile every target: Apple targets need Xcode, and Kotlin/Native
cross-compiles the Linux target from macOS. ./gradlew verifyPublishedVariantsfails when a declared target would not
actually be published. Kotlin creates a publication only for targets the
host can build, while the root module lists a variant for every declared
target — so publishing from the wrong host uploads a module referencing
artifacts that do not exist.- CI builds every target on macOS on each push, rather than discovering Apple
breakage on release day. - The GitHub release page is created by the release workflow, with notes taken
from this file and the jars attached.v0.1.1andv0.1.2were tagged and
published without one. The workflow fails early if the changelog has no
section for the version being released, rather than after the artifacts are
already immutable. - The Central deployment is released automatically (
publishing_type=automatic)
instead of waiting for a manual Publish. Pushing a tag is now the point of no
return.
On Maven Central as io.github.mgilbir:ktecma262:0.1.3, but only the common, JVM, Linux and macOS modules — see the notice at the top.
ktecma262 0.1.2 (no native variants)
Caution
Deprecated — no native variants.
Published with the common, JVM and JS variants only. A Kotlin Multiplatform build that declares any native target cannot resolve it.
It is also actively harmful to work around locally: because Gradle takes the first repository holding a coordinate, a 0.1.2 on Maven Central shadows a 0.1.2 you published to mavenLocal, silently replacing your build with one that has fewer variants.
Use 0.1.4 instead.
No library changes: the compiled artifacts are byte-for-byte identical to
the 0.1.1 tag. This version exists only because the 0.1.1 release never
completed.
Its release run hung for over an hour in the differential fuzz step. The
fuzzer deliberately generates patterns that are catastrophic for a
backtracker; this engine bounds them with a step budget, but the node
process used as the oracle has no such limit and cannot be interrupted
from JavaScript, so V8 ran at full CPU until the run was cancelled.
Fixed (test infrastructure only)
- Cases that exceed this engine's step budget are now screened out before
being sent to the oracle. The comparison already skipped them, so no
coverage is lost, and the failing seed now completes in 37 seconds. - A watchdog kills the oracle after two minutes without output and names
the case it stopped on. - The oracle streams results instead of buffering them until close, so
progress is no longer lost when it is killed. - The oracle's stderr is inherited rather than left in an undrained pipe,
which could have blocked it. - The oracle process is destroyed on shutdown; a cancelled run previously
left node spinning indefinitely.
v0.1.1 remains as a tag but was never published.
0.1.2 has JVM, JS and common variants only. A Kotlin Multiplatform build
that declares a native target cannot resolve it, and — because Gradle takes the
first repository holding a coordinate — it will shadow a locally published
build of the same version. Use 0.1.3.
ktecma262 0.1.1 (never published)
Caution
Deprecated — never published.
This tag exists, but nothing was ever released to Maven Central under it: the release run hung in the differential fuzz step and was cancelled. There is no 0.1.1 artifact to depend on.
Its content — the \0 character-class fix — shipped in 0.1.2 and later.
Use 0.1.4 instead.
Fixes a parser bug found by the differential fuzzer immediately after 0.1.0
was tagged.
Fixed
-
\0inside a character class is the NUL escape in every mode. Both
Unicode-mode class paths rejected it: theupath rejected every digit
escape outright, whereCharacterEscape :: 0 [lookahead ∉ DecimalDigit]
keeps\0valid, and thevclass-set path had no digit branch at all.
[\0]and[+\0d]now match NUL underuandvas they already did
under Annex B, while[\01],[\1]and[\9]remain SyntaxErrors in
Unicode mode.Found on
/st[+\0d]&+/vsby the fuzzer's unstructured-pattern mode — a
shape the grammar-driven generator cannot produce. The recorded corpus
now covers digit escapes inside classes in all three modes.
0.1.0 is affected by this bug and should not be used.
ktecma262 0.1.0
Caution
Deprecated — parser bug.
\0 inside a character class was rejected as a SyntaxError under the u and v flags, where ECMA-262 keeps it valid as the NUL escape. Patterns such as [\0] and [+\0d] failed to compile. Fixed in 0.1.1.
Use 0.1.4 instead.
Warning
Superseded by 0.1.1.
This release rejects \0 inside a character class under the u and v
flags, where it is the NUL escape in every mode — so patterns such as
[\0] and [+\0d] fail to compile. Use 0.1.1.
An ECMA-262 (JavaScript) regular expression engine in pure Kotlin, for Kotlin
Multiplatform. Patterns and match results behave as they do in JavaScript: the
same flags, Annex B syntax, capture semantics and UTF-16 offsets.
Highlights
- Flags
i g m s u v y d; Annex B by default, strict ECMA-262 opt-in - Named groups including ES2022 duplicates; backreferences including forward
references; lookahead and lookbehind, including variable-length lookbehind
with right-to-left capture semantics v(UnicodeSets): nested classes,&&and--,\q{…}string literals, and
properties of strings (\p{RGI_Emoji}and its constituents)- Regexp modifiers
(?i:…)andRegExp.escape(ES2025) - Unicode 17.0.0 compiled in, so
\p{…}does not vary with the host JDK - JVM output is Java 17 bytecode, checked by reading the emitted class files
Safety and performance
Backtracking runs on an explicit stack with an undo log, so deep backtracking
cannot overflow the call stack — java.util.regex throws StackOverflowError
on inputs this engine handles. Matching is bounded by RegExp.maxSteps
(default 1,000,000, as PCRE's backtrack limit), and the budget spans a whole
operation rather than each match, so cost cannot scale with a match count the
input controls.
On a Ryzen 9 6900HX: literal scan over 100k chars 41 µs (vs 81 µs for
java.util.regex), a three-capture date match 174 ns (vs 170 ns).
How correctness was established
Differential testing against a real JavaScript engine rather than readings of
the specification:
- ~40,000 recorded node results replayed on JVM and JS on every build
- Millions of live fuzz cases covering
exec,findAll,replaceandsplit,
including unstructured patterns the generator's own grammar cannot produce - Test262's generated
v-flag conformance cases - All 439 Unicode properties verified against node code point by code point, and
RegExp.escapeverified over all 1,114,112 code points
Deliberate divergences from V8
Three, each documented in the README with a test pinning the specified
behaviour: match positions that split a surrogate pair under /u,
single-character \q{} folding under /vi, and modifier scoping leaking into
\w. Each was confirmed as a V8 inconsistency rather than a reading of the
grammar.
Installing
Maven Central publication is pending namespace verification. Until then, build
from source:
git clone https://github.com/mgilbir/ktecma262 && cd ktecma262
./gradlew publishToMavenLocalthen add mavenLocal() to your repositories and
implementation("io.github.mgilbir:ktecma262:0.1.0") — or take the jars below.
The -sources jars carry the KDoc; the artifacts here are the same ones
publishToMavenLocal produces.