Compatibility¶
Implementation¶
SBCL only. The runtime uses
cl-concurrent-kit's unbuffered
CSP channels (themselves built directly on sb-thread) for the generator coroutine's
suspend/resume hand-off, and constructs IEEE-754 specials from raw bit patterns through
SBCL-specific entry points. There is no
portable fallback path and none is planned; the org's
coding standard
asks packages to name the implementation they target rather than claim portability that
CI does not verify.
Language coverage¶
The lexer and parser cover the modern language, including editions well past ES2015:
- Syntax —
let/const, arrow functions, classes (including private#fields, static members, getters and setters), destructuring with defaults and rest, spread, template literals, tagged templates, optional chaining (?.), nullish coalescing (??), logical assignment (&&=,||=,??=),for...of,for await...of, generators,async/await, ES modules (import/export,import.meta,new.target), labelled statements,usingdeclarations, numeric separators, BigInt literals, and regular expression literals. - Builtins —
Object,Array(through the ES2023 change-by-copy methodstoReversed,toSorted,toSpliced,with, andfindLast/findLastIndex),String(through ES2024isWellFormed/toWellFormed),Math,JSON,Map,Set(including the ES2024 set operationsunion,intersection,difference,symmetricDifference,isSubsetOf,isSupersetOf,isDisjointFrom),WeakMap,WeakSet,WeakRef,FinalizationRegistry,Symboland the well-known symbols,Promise(includingwithResolvers),Date,RegExp, TypedArrays (including the ES2025Uint8Arraybase64/hex conversions), the ES2025 iterator helpers, andTemporal.
The authoritative list is the API reference, which is generated
against the actual :export list.
Deliberate simplifications¶
These are known and intentional. They are recorded here because the alternative is that each one is rediscovered as a bug.
Promises are synchronous. A promise is always already settled by the time JavaScript
can observe it, so .then, .catch and .finally run their handler immediately rather
than queuing a microtask. Code whose correctness depends on microtask ordering — for
instance interleaving between two promise chains, or a then callback observing a
mutation made later in the same synchronous block — will not behave as it does in a real
engine.
Async generators are not truly asynchronous. %js-make-async-generator delegates to
%js-make-async, following from the promise model above.
FinalizationRegistry never fires. Registrations are tracked so unregister has
ECMAScript-compatible observable state, but cleanup callbacks are not run. WeakRef
targets are retained, so deref never returns undefined.
Date string parsing always treats a date-time string as UTC, never local time.
Real JS treats a date-ONLY ISO string ("2024-01-15") as UTC, but a date-TIME string with
no explicit timezone suffix ("2024-01-15T10:00:00", no Z/+HH:MM/-HH:MM) as the
host's LOCAL time — a well-known, frequently-cited parsing subtlety. %js-date-parse-string
does not distinguish the two cases (nor does it read an explicit timezone suffix at all,
silently ignoring one if present) — every parsed string is encoded as UTC via
encode-universal-time's zone argument fixed at 0. Deferred alongside the Invalid Date
gap above since a correct fix needs the same host-timezone projection Date.prototype.
getTimezoneOffset and the Temporal runtime already use (runtime-temporal.lisp), applied
conditionally based on which ISO string form was actually given.
Intl is a stub. Constructors exist and are callable, so feature detection and
construction succeed, but formatting is not locale-aware. Date.prototype.toString and
toLocaleDateString are likewise simplified (YYYY/MM/DD).
Temporal's IANA time zone support covers instant → local projection, not local →
instant resolution or zone-aware arithmetic. Temporal.Now.timeZoneId reports the
host's actual IANA zone name (via
cl-date-kit, reading TZ or resolving
the /etc/localtime symlink) instead of a hardcoded "UTC", and
Temporal.Now.zonedDateTimeISO, Temporal.Instant.prototype.toZonedDateTimeISO, and the
Temporal.ZonedDateTime epoch-nanoseconds constructor project an absolute instant into
any IANA zone cl-date-kit's tzdata copy recognizes, producing the correct local
wall-clock fields and UTC offset for that instant. That direction is unambiguous — an
instant has exactly one local reading in a given zone — which is what keeps it in scope.
Left unimplemented, deliberately: constructing a ZonedDateTime from local wall-clock
fields in a non-UTC zone, which is the reverse, potentially ambiguous direction that
requires a daylight-saving gap/overlap disambiguation policy; zone-aware add/subtract
arithmetic (arithmetic stays plain UTC-second math even when a ZonedDateTime's
displayed zone is not UTC); Temporal.Now.plainDateTimeISO/plainDateISO/plainTimeISO,
which still ignore their timeZone argument and always report UTC calendar fields; and
Temporal.ZonedDateTime.from() on a string, which still ignores any [Zone] bracket in
the input and reports "UTC". All of the implemented paths degrade to the pre-existing
UTC-only behavior, rather than erroring, whenever cl-date-kit has no readable IANA
tzdata for a zone name — routine inside a Nix build sandbox, which has neither TZ set
nor a /usr/share/zoneinfo to read (see checks.default in flake.nix, which points
TZDIR at nixpkgs' tzdata package precisely so the zone-aware test coverage still runs
there).
Date.prototype.getTimezoneOffset() shares the same host-zone discovery as Temporal,
with the same UTC fallback. It reports genuine minutes-west-of-UTC for the host's
discovered IANA zone (negative east of UTC, per JS's own sign convention — the opposite of
a "+09:00"-style offset string) instead of a hardcoded 0, but only when a zone is
actually discoverable; it degrades to 0 under the exact same conditions Temporal does.
Proxy is simplified. The constructor returns a wrapped object rather than
installing real traps.
crypto is a stub. Do not use it for anything security-relevant.
Automatic semicolon insertion is simplified. ASI consumes a semicolon when one is present rather than implementing the full restricted-production rules, so a program that relies on the corner cases of ASI may parse differently than in a browser.
An abandoned generator leaks a thread. Generators are real coroutines: the body runs
on its own thread and hands a baton back and forth with the driver, so exactly one of
the two runs at a time and .next(value) genuinely resumes at the suspended yield.
The cost is that a generator which is never drained — for example one left behind by a
break out of a for...of — leaves its body thread blocked on the next hand-off
forever. Nothing calls .return() on early loop exit to collect it, so it is reclaimed
only at process exit.
String.prototype.match/matchAll have a string-pattern path separate from the
regex engine path (%js-string-match-regex, %js-string-match-all and friends).
The regex engine compiles a pattern to a single-pass, non-backtracking matcher
closure in runtime-regex.lisp, not a binding to a host engine. Each quantifier
commits greedily to as many repetitions as it can get and never retries with fewer, so
a pattern shape that genuinely needs backtracking to match (a*ab against "aab") can
fail to match where a real backtracking engine would succeed. Lazy quantifiers (*?,
+?, {n,m}?) are a simplification of this same shape: they stop right after their
minimum required repetitions (0 for *?, n for {n,m}?) rather than truly
backtracking to find the shortest match the rest of the pattern needs. \b/\B word
boundaries and (?=expr)/(?!expr) lookahead are implemented as zero-width
assertions. Several other pieces the file's own header comment used to claim as
supported were not, discovered and corrected here rather than left as a silent mismatch
between the comment and the code:
- Capturing groups, numbered and named, are extracted.
(expr)and(?<name>expr)each record their matched span in agroupsvector threaded through every matcher closure, indexed by the order their opening parenthesis appears in the pattern (nesting doesn't change this —((a)(b))numbers the outer group1, then2,3left to right).match(/(\d+)-(\d+)/)[1]returns the first captured number as a string; a group that didn't participate in the match (the losing side of an alternation, or an unexercised iteration under?/*) reportsundefined, matching JS. A group repeated under a quantifier ((a)+) captures its last successful iteration, the same rule JS itself uses — this engine has no backtracking to begin with, so there is only ever one "last" attempt to record.(?<name>expr)populates the match object'sgroupsproperty (a null-prototype object,undefinedwhen the pattern has no named groups) in addition to its own numbered slot. $&,$$,$1-$99, and$<name>replacement placeholders are implemented.regex-replace-placeholdersexpands all four against the match object%js-regex-execbuilt; an out-of-range group number, an unterminated$<name>, or a$followed by anything else is copied through literally, per spec.$`(pre-match) and$'(post-match) are not implemented — no pattern in this codebase's own test suite or documented usage needs them yet.- Lookbehind
(?<=expr)/(?<!expr)is not implemented. Unlike lookahead and named groups, which only needed distinguishing<from a bare capturing group's opening(, lookbehind needs variable-length backward matching, which this forward-scanning engine has no support for regardless of the<parsing question. {n}/{n,}/{n,m}bounded-repetition quantifiers are implemented, including their lazy?suffix (with the same simplification as*?/+?above).{...}that doesn't parse as a well-formed quantifier — not all digits, a missing minimum before the comma, an unclosed brace — falls back to matching its characters literally, the same Annex-B-style leniency real JS engines apply outside Unicode mode:a{3}matches threeas, buta{,3}(no minimum) matches the five literal charactersa,{,,,3,}unchanged.\xHH(2 hex digits) and\uHHHH(4 hex digits) hex/unicode escapes, and\f(form feed), are recognized. These were missing until 2026-07-31:\xHH/\uHHHHfell through to the generic escape handling's "self-denoting" fallback (\x61matched the literal two-character textx61, not the charactera), and\fwas missing from the same fallback's exception table (matched literalf, not a form feed) — both brokeRegExp.escape's fundamental contract, since it emits exactly these forms for punctuation, control characters, and a leading alphanumeric character.new RegExp(RegExp.escape(s)).test(s)now always holds.
No strict-mode restrictions are enforced. Octal literals, duplicate parameter names,
assigning to eval/arguments, and the other ECMAScript strict-mode-only rejections all
parse successfully; there is no "use strict" directive handling and no separate strict
parse mode. import/export syntax parses unconditionally too — parse-js-module is a
plain alias for parse-js-source, not a validating module parser (see
Quick Start). js-exception (see Conditions) would be
the signal for a real violation if strict-mode validation is implemented later.
Class method default parameter values are parsed but never applied.
class C { m(a, b = 1) {...} } parses b as an ordinary required parameter
and silently discards the = 1 — calling new C().m(5) (omitting b)
leaves b as undefined, not 1. Destructuring parameters in a method's
parameter list have the same gap. Regular function declarations and arrow
functions apply defaults correctly; only class methods/getters/setters/
constructors go through a separate, simpler parameter parser
(%js-parse-method-params-body) that was never extended to match. The
syntax itself always parses without error (as of this fix — see the
release notes; a
nested-call or bracketed-literal default like
m(a, b = foo(1,2)) previously corrupted the parser entirely instead of
merely ignoring the default's value).
A static field or static { ... } block initializer cannot reference the
class by its own name. class C { static { C.x = 1; } } signals an
unbound-variable error. The class's own construction call
(%js-make-class(...)) evaluates every static field/block initform as one
of its own arguments, before the result is ever bound to the class's name —
unlike a method body, which is a lambda invoked lazily, well after that
binding exists. Referencing the class by name from inside a method works
fine; only a field/block initializer that runs during construction itself
is affected. Static field/block initializers currently work correctly as
long as they don't need to see the class object under construction (plain
literals, or references to anything already in scope from outside the
class).
for...in only enumerates OBJ's own enumerable string keys, never inherited ones.
Real JS for...in also walks the prototype chain, enumerating inherited enumerable
properties. In practice this rarely matters: real JS class methods are themselves
non-enumerable on the prototype by spec, so iterating a class instance with for...in
is unaffected — the gap only shows up for manual Object.create(protoWithEnumerable
OwnProps)-style prototype chains where the prototype itself carries enumerable data
properties, an uncommon pattern. Object.keys/values/entries are unaffected by this
note — they are own-properties-only by spec, exactly what this runtime already does.
Resource limits¶
*js-max-parse-depth* bounds expression and statement nesting at 2500. Past that the
parser signals a parse error rather than overflowing the control stack. This is a
deliberate guard against adversarial input; raise the parameter if you have legitimate
input that is more deeply nested.
Stability¶
The package is at 0.1.0 and has no release tag yet. Nothing here is covered by a
compatibility promise, and the %js-* runtime bridge in particular is the compiler's
internal ABI — it changes whenever the code generator changes. See
Versioning for
the org-wide policy.