A strict-only ECMAScript engine, written from scratch in Zig.
Cynic targets non-browser hosts — edge runtimes, Workers, server-side JS — and omits the web-compatibility surfaces by design:
- No sloppy mode. Every source is parsed as strict. The strict
reserved-word set, restricted assignment to
eval/arguments, and the absence ofwith, labels, legacy octal, HTML-like comments, and Annex B language extensions (sloppy-mode-only function-in-block,for-ininitializer, …) are baked in at the language level. - No web-compatibility built-ins.
escape/unescape, the 13String.prototypeHTML wrappers (anchor,bold, …),Date.prototype.{getYear, setYear, toGMTString}, and theString.prototype.{substr, trimLeft, trimRight}aliases aren't shipped. The canonical modern names (trimStart/trimEnd,toUTCString) are the only spelling. - No runtime code construction.
eval,new Function(string),new GeneratorFunction(string),new AsyncFunction(string). Aligns with SES / Hardened JavaScript. - SES-hardened by default. Every modern edge runtime is
"SES-friendly" — meaning user code can call
lockdown()to harden primordials. Cynic skips the call. Realms boot with every intrinsic frozen ([[Extensible]] = false, non-writable / non-configurable descriptors),harden()shipped as a native global (recursive deep freeze, matches@endo/ses), and the override-mistake fix in place (obj.x = 2shadows a frozen prototype's data slot instead of throwing TypeError).--unhardenedopts the whole posture out atomically for code that genuinely needsOrdinarySetsemantics. Compartments are deferred until multi-realm lands. Seedocs/ses-alignment.md.
Pre-alpha. Lexer + parser + Lantern (T0 bytecode interpreter) +
Metla (mark-sweep GC) ship, alongside Perlex (the native §22.2
RegExp engine), the native §3 Unicode tables, and the
hardened-by-default realm-boot pipeline. The runtime is filling
in §19-§28 one bucket at a time. The JIT tiers (Bistromath,
Ohaimark) and generational GC are future work. See
docs/ROADMAP.md for the thematic breakdown.
Current scores, history, and per-bucket breakdown live in
test262-results.md. pass% is
(passing + expected fails) / total — a "correctly
handled fail" is a fixture Cynic fails by design (Annex B not
shipped, strict-only, no Intl, eval-off, or SES throw). The
failing column is real engine work left. The unit-test suite
(zig build test) runs alongside.
- Native CI (gating):
x86_64-linux-gnu,aarch64-macos(Apple Silicon). Full battery — build + unit tests + SES coverage. - Cross-compile CI (build-only, gating):
aarch64-linux-gnu,x86_64-linux-musl,aarch64-macosfrom Linux. Catches platform-specific compile breaks pre-merge. - WASM:
wasm32-freestandingpowers the playground. - Not yet: Windows (POSIX carve-outs in
src/runtime/heap.zig+tools/test262.zig), Android (NDK +build.zigsysroot plumbing), iOS (Xcode SDK forwarding). Tracked separately.
The shape, in broad strokes — the per-bucket numbers live in the
test262-results.md scoreboard.
- Parser — every §13 expression form, classes (private members, static blocks, getters/setters), generators + async + async generators, ES6 modules in all forms, destructuring in every position the spec allows.
- Statements & control flow — the usual
if/switch/while/for/tryfamily, including TDZ enforcement and per-iteration closures forfor-let.for-ofwalks any@@iterator-bearing iterable; iterator-close fires onbreakper §7.4.6. - Functions & classes — closures,
arguments,bindchains,extends/super, default-ctor synthesis, only-via-new, instance + private + static fields, static blocks, getters and setters. - Built-ins —
Object,Array,String/Number/Boolean/BigInt/Symbol(real primitives, not polyfills),Math,JSON,Map/Set/WeakMap/WeakSet,Reflect, ES2025Setops (union/intersection/ …),Iteratorhelpers (map/filter/take/drop/flatMap/ etc.),Proxy(most traps),Date(UTC-only), the URI globals, the standard error hierarchy witherror-cause. - TypedArrays —
ArrayBuffer,DataView, the typed-array family backed by the canonical%TypedArray%.prototype. - RegExp — full ECMA-262 via Perlex, Cynic's own native
engine (named groups, lookbehind,
u/vflags, indices). String methods dispatch through it. - Promises & async/await — full chaining (settled and pending),
pending-await suspension via
JSGeneratorcapture, async generators with promise-reaction chaining,Promise.tryandPromise.withResolvers. - Proper Tail Calls (ES2015 §15.10) — calls in tail position
reuse the caller's frame instead of pushing a fresh one;
function f(n) { return f(n - 1); }recurses without growing the dispatch stack. Second engine shipping spec-mandated PTC alongside JavaScriptCore. - Tooling —
cynic parse | eval | runplus a parallel test262 harness with--threads=N,--only-failingcache, and a per-area scoreboard.
Internals: NaN-boxed values, Ignition-style register-file +
accumulator bytecode, stop-the-world mark-sweep heap fired on
allocation pressure (the heap stays bounded under any allocating
loop / recursion / promise chain — see
docs/handbook/gc.md for the trigger and
the HandleScope contract for natives).
git submodule update --init vendor/test262 # one-time; needed for `zig build test262`
zig build # build cynic into zig-out/bin/
zig build test # run all unit tests
zig build test262 # test262 conformance (parse + compile + execute; --write-results also runs each pre-Stage-4 feature phase)Requires Zig 0.17-dev (master). The Zig project skipped a stable
0.16, so CI tracks master via
xyzzylabs/setup-zig. If
your local zig version reports an older dev tag, bump it.
zig build test262 accepts forwarded flags after --:
--filter=<substring>— run only matching paths.--list-failures=<n>— print the firstnfailing paths after the tally.--phase=<spec>— pin the harness to a single sweep.--phase=mainis the headline ECMA-262 sweep (pre-Stage-4 fixtures excluded);--phase=feature:<name>(e.g.feature:joint-iteration,feature:upsert) runs only that proposal's dedicated isolated sweep. Default: just main, unless--write-resultsis set — then main + every tracked feature run in sequence.--quiet/--verbose— progress noise dial.--no-harness— skip thesta.js+assert.jspreamble (for measuring the no-harness floor).--threads=<n>— worker count (0= auto,1= sequential,>1= pool).--only-failing— skip-as-pass any path in.test262-pass-cache.txt. After a full sweep populates the cache, the next iteration runs only the ~7 k failing/skipped fixtures — ≤ 30 s vs ≤ 100 s. Don't use for score rows; use it for per-fix verification.--gc-threshold=<n>— per-fixture allocation-pressure GC threshold (default 32,768; engine default 16,384).0falls through to the engine default. The engine also has a 16 MiB byte trigger so allocate-and-discard patterns GC promptly regardless of count.--write-results— updatetest262-results.mdwith today's row. Re-running on the same date replaces that day's row rather than appending. The default run never touches that file.- Memory / leak instrumentation:
--gc-stats(per-cycle pool counts + bytes),--mem-summary(end-of-sweep totals: cumulative bytes, max charged peak, GC cycles),--top-rss=<n>(top-N fixtures by process RSS delta ≥ 8 MiB),--top-alloc=<n>(top-N by cumulative bytes allocated ≥ 64 KiB — catches GC-cleaned thrash that RSS hides),--leak-check(route per-fixture bytes allocator throughstd.heap.DebugAllocator; stack trace per unfreed allocation),--max-rss=<mb>(abort with the offending path when RSS crosses budget).
The Unicode tables under src/unicode/ are generated and committed:
ident_tables.zig (lexer ID_Start / ID_Continue),
property_tables.zig (RegExp \p{…} property escapes),
case_fold_tables.zig (RegExp /iu / /iv case folding),
case_conv_tables.zig (String.prototype.toLowerCase / toUpperCase),
normalization_tables.zig (UAX #15 NF{C,D,KC,KD}). Currently
Unicode 17.0. ECMA-262 §3 references unicode.org/versions/latest,
so we track upstream: drop the refreshed UCD files into
vendor/unicode/ and run zig build gen-unicode to regenerate.
After zig build, the CLI is at zig-out/bin/cynic — put it on your
PATH or run ./zig-out/bin/cynic. The examples use cynic:
cynic lex path/to/file.js # tokenize and print
cynic parse path/to/file.js # parse a Script
cynic parse --module path/to/file.js # parse a Module
cynic parse path/to/file.mjs # .mjs ⇒ module
cynic eval '1 + 2 * 3' # evaluate an expression
cynic run path/to/file.js # run a script
cynic run a.js b.js c.js # multiple files share one realm
cynic repl # interactive REPL (persistent realm)The cynic CLI keeps pre-Stage-4 / experimental TC39 proposals off
by default — embedders see only stable ECMA-262. Opt in:
cynic --list-features # show available proposals
cynic --enable=joint-iteration eval '...' # one feature
cynic --enable-experimental run foo.js # all tracked featuresSee src/runtime/features.zig for the set and
docs/ROADMAP.md for what each proposal ships.
Contributors — human or AI agent — should read
AGENTS.md for project conventions (tests-first,
prior-art surveys, spec-faithful naming) and pointers into the
engineering handbook under docs/handbook/.
Security policy, in-scope / out-of-scope, and disclosure channel:
see SECURITY.md.
Cynic is MIT-licensed. Bundled third-party data under
vendor/ keeps its own license:
vendor/unicode/— Unicode Character Database files (UnicodeData.txt,SpecialCasing.txt,CaseFolding.txt, the Derived / PropList / Scripts / emoji set, andNormalizationTest.txtfor the conformance test). All under the Unicode, Inc. License Agreement. Upstream: https://www.unicode.org/license.txt.vendor/test262/— ECMAScript Test Suite, BSD-3-Clause (with Ecma International notices). Git submodule pinned totc39/test262.