Changelog

All notable changes to tjs-lang are documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

0.14.0 — unreleased

Release candidate: 0.14.0-rc.1 (2026-09-26), on the rc dist-tag (npm i tjs-lang@rc); latest stays on 0.13 until 0.14.0 is final. It carries everything below, including the admission work from the final re-reviews (the 8KB source cap, the deprecation of vm.run(source), and every run option read once and checked). ^0.14.0 does NOT admit a prerelease, so pin 0.14.0-rc.1 exactly to test it. Published through the OIDC workflow, vouched for by release-attestation.json.

Packaging (rc.1): typescript is now a declared OPTIONAL peer (^5) — tjs-lang/lang/from-ts imports it, and it was only a devDependency, so a Node consumer of that entry without it installed failed at import. Dev-only scripts (bin/*.ts, editors/build-*.ts, the CodeMirror demo component) and unreachable editor .ts sources are no longer shipped; every exported path is unchanged. Found by the new publish workflow's release-doctor, not by a user.

Published first as 0.14.0-rc.0 on the rc dist-tag, so tosijs-ui could verify against it before its peer range admitted 0.14 (tosijs-ui#182). It did: tosijs-ui 1.15.2 declares tjs-lang: ^0.13.1 || ^0.14.0, so npm consumers of both resolve cleanly. That rc verified PEER RESOLUTION; the Type-example and TjsStrict changes below came after it, through several review rounds (docs/reviews/0.14.0-*).

Untrusted AJS source is capped at 8KB by default (was 64KB) — and the real answer is to not accept source at all. maxSourceBytes for Eval, SafeFunction, vm.run(source), runCode and transpileCode now defaults to 8KB. Nine review rounds in this release each found another input shape that the AJS preprocessor or acorn parses super-linearly — nested destructuring took 19s at 60KB, a function head followed by whitespace 2.9s — and patching them did not converge. A quadratic cost shrinks with the square of the cap: at 8KB the worst known shape is ~455ms (densely nested destructuring, measured 2026-09-26). Raise it per call for trusted source, and transpile(source, { maxSourceBytes }) (new, opt-in) caps an in-process transpile. For source the GUEST builds (runCode/transpileCode), vm.run's maxSourceBytes can only lower the 8KB cap, never raise or disable it: that text can come from llmPredict output, and one option cannot speak for two trust domains. If you run agents from untrusted callers, take an AST instead: transpile on the caller's side (tjs-lang/browser or tjs-lang/lang) or in a worker or process you can afford to lose, send the AST, and run it with tjs-lang/vm-ast, which has no parser in it at all — the parse cost then lands on whoever sent the source, and what remains on the host is linear. (This project's own hosted endpoints still accept source; they get the 8KB cap when functions/ is bumped to 0.14 and redeployed — a 0.x minor is outside ^0.13.x — and moving them to ASTs is next.) ajs, createAgent, tjs and the exported parser functions are for TRUSTED source and take no cap.

Deprecated: vm.run(source) — the VM parsing AJS. It still works (capped at 8KB) and notes itself once in the flight recorder. The VM should never be the thing that parses: transpile separately (transpile from tjs-lang/lang, in a worker, another process, or on the caller's machine) and run the AST with tjs-lang/vm-ast, which contains no parser — so a bad payload can only take down the step that parsed it, never the endpoint. Eval and SafeFunction keep taking source: they are for source you trust, or a process you can afford to lose.

A language release. The tosijs-ui-hosted site was what 0.14.0 was originally reserved for; that work is real but lands separately, as a non-breaking change to build tooling that does not touch the published surface. The version number follows the narrative rather than a name reserved in advance.

If you only read one line: a runtime type now reports typeof === 'function' instead of 'object'. .check(), isRuntimeType(), Object.keys() and spread are unchanged, and serialisation improved from throwing to working — so unless you branch on typeof, that one moves nothing under you.

What may newly RUN — error propagation is decided at the type check (native .tjs and converted code). A MonadicError passed where it does not fit a parameter's type is still returned unchanged without running the body — including through overloads, arrays and options bags. But every validated function used to begin with a pre-check that returned ANY Error from ANY parameter, whatever its declared type:

What may newly REJECT something — each is a fix, and each can turn a value that used to pass into a returned MonadicError:

Changed

Added

Fixed

Added

0.13.13 — 2026-09-13

Read this first if you use unsafe

A patch release normally cannot take anything away. This one can, so it is named here rather than left for you to discover. var and eval were reachable in a .tjs file behind the unsafe marker; they are now refused outright, so source that compiled under 0.13.12 can fail under 0.13.13:

unsafe var x = 1      // 0.13.12: compiled     0.13.13: refused
unsafe eval(src)      // 0.13.12: compiled     0.13.13: refused

The full reasoning is under Changed below; briefly, eval could not be given a named replacement even in principle, because direct eval reads the CALLER's scope and any wrapper would silently be a different operation. unsafe itself is only deprecated — it warns and keeps working.

It is a patch deliberately: the version line stays on 0.13.x until the tosijs-ui migration and tjs doc land, and 0.14.0 is reserved for that release. Pinning ~0.13.12 will not protect you from this; pin exactly if you rely on either escape.

Added

Changed

Fixed

Security

0.13.12 — 2026-09-06

Two security-relevant fixes against published 0.13.11, and the end of a defect class.

Eval/SafeFunction returned plausible wrong values (#52) — spread was silently dropped and a dotted read came back as its own source text — and in the reference RBAC layer that inverted into a grant (#54). Both were reported by a consumer running against a known-good oracle, which is the only way either could have been seen: every wrong value had the right shape, so structural checks, typeof and length checks all passed. Separately, verifyPredicate was certifying impure functions as pure, so "verified" meant less than the badge claimed.

The rest is one class of defect, finally closed. Eight scanners that misread code merely MENTIONING the syntax they scan for — a test block quoted as data being executed and deleted, a comment containing export consuming the real one, a template's ${…} desynchronising the shared literal scanner, a quoted wasm function being compiled, a quoted Is operator being transformed. Each was found somewhere other than where it did its damage.

The dogfood behaviour gate went from 108 broken tests to zero. Every test suite we ship now converts to TJS, runs, and preserves every assertion — all three 1.0 self-hosting gates at zero, both ratchets pinned at 1.0. Roughly two thirds of that distance was defects in the gate rather than in the language, which is recorded at the baseline so the next bad number there is read as a question about the apparatus first.

Fixed — silent wrong values in Eval/SafeFunction

Security

Fixed — the emitted runtime and the TypeScript bridge

Self-hosting

Changed — the dogfood gate stopped discarding its own evidence

Fixed — the literal-blindness class

Added

Fixed — the dogfood gate itself (not shipped code)

Changed — cleanups

0.13.11 — 2026-09-04

Three findings from the 0.13.10 pre-release review, all pre-existing, none introduced by 0.13.10. Each was reproduced before being fixed and is pinned by a test.

Security

Fixed

0.13.10 — 2026-09-03

Changed — AJS parses through its own core, not parse() with a flag

The structural fix behind 0.13.7's test-block RCE. That vulnerability was one missing !options.vmTarget among ~30 source transforms of which exactly two checked the flag, so ~28 TJS transforms ran when compiling AJS. A gate fails open, and this one had for months.

AJS now parses through parseAgentSource() in the new src/lang/parser-agent.ts — four steps (hashbang, line comments, colon shorthand, param markers), because that is what AJS is: a JavaScript subset plus typed signatures. Adding a TJS transform means editing parser.ts, which AJS does not call, so there is nothing left to remember.

All seven previously-leaking TJS constructs — bang access, Is, inline wasm function, Type, Generic, extend, FunctionPredicate — are rejected on the AJS path now, closed as a group rather than one at a time. They were inert, but accepting syntax the language does not have is how the last one arrived.

vmTarget is removed from ParseOptions and PreprocessOptions. It was internal (both AJS entry points set it themselves) and is not part of the documented API, so no supported usage changes. parse() is TJS's parser and says so.

One visible behaviour change: two same-name top-level functions in AJS source are now rejected by acorn (Identifier 'x' has already been declared) rather than by TJS's polymorphic-merge pass (ambiguous signatures). Same rejection, from the JavaScript rule that actually governs a JavaScript subset.

Guarded two ways in src/lang/eval-no-transpile-execution.test.ts: an acorn-parsed pin on the AJS pipeline's import set, so a new transform fails at the import rather than waiting for someone to think of a construct that exercises it; and the existing behavioural ratchet, whose known-leak list is empty.

Scope of that claim, stated honestly. parser-params.ts is shared with TJS's parser, so "absent by construction" is true of the transform list, not of every behaviour reachable through it: TJS safety markers (!/? on params, :!/:? on returns) are still accepted and silently discarded on the AJS path. No execution, no capability, no fuel bypass — but an author writing function main(!apiKey: '') gets the opposite of what the marker documents. Tracked in TODO.md.

Shipped as a patch, with the breakage named. Three things could bite:

Neither the flag nor the constructs are a documented API, which is why this is a patch rather than a minor.

Fixed

[0.13.9] — 2026-09-03

SECURITY — a TJS example value is parsed, never executed

Second transpile-time escape, and a different path from 0.13.7's. A return-type annotation containing = was evaluated rather than parsed:

function f(a: 0): { x = (globalThis.PWNED = 1) } { return { x: a } }

tjs check on that file ran it. So did tjs emit, the bun .tjs plugin, the module loader and the playground — anything that transpiles source it did not write. Eight emitter sites used new Function(\return ${text}`)()` to turn an example into a value.

This is the ordinary tjs() path, so 0.13.7's vmTarget gate did not touch it. The AJS path rejects the carrier, so the VM and the hosted endpoints were not affected — the blast radius is developer and CI machines transpiling untrusted .tjs.

Fix: src/lang/literal-value.ts parses examples with acorn and accepts only literals — strings, numbers, booleans, null, regex, arrays, plain objects, signed numbers, and non-interpolated templates. No allowlist of dangerous names and no sanitising of source text: anything that can compute is not an example, and that is decidable from the AST. Ordinary return defaults are unaffected.

Found by the post-remediation review (reviews/0.13.8-post-remediation-review.md).

Fixed

Changed

Known

tjs-lang <= 0.13.8 all carry at least one transpile-time execution path. No GitHub advisory has been filed and prior versions are not deprecated: this project has no known consumers, so the automated-notification machinery would be ceremony rather than protection. Revisit if that changes.

[0.13.8] — 2026-09-03

SECURITY — 0.13.7 shipped the fix in src/ but not in dist/

If you installed 0.13.7 from npm and use it under Node, upgrade. 0.13.7's security fix (a VM-target transpile no longer executes the code it is transpiling) was real in source and absent from the published bundles: dist/ had been built 35 minutes before the fix landed.

Bun resolves this package to src/, so every local check passed. Node resolves it to dist/, so every Node consumer of 0.13.7 got the vulnerable build — including anyone importing tjs-lang/eval. Verified by fresh-installing the published tarball and running the exploit against it, which is the only check that would have caught it.

No source change from 0.13.7. This release is the same code, correctly built.

Added

[0.13.7] — 2026-09-02

SECURITY — a VM-target transpile no longer executes the code it is transpiling

If you call Eval, SafeFunction, or expose either over a network, upgrade.

parse() ran every test '…' { … } block with new Function(body)(). Eval and SafeFunction transpile the submitted string before vm.run, so the payload executed with full ambient authority before fuel, timeout, capabilities and the membrane existed:

Eval({ code: "test 'x' { globalThis.__PWNED__ = true } return 1", fuel: 10, timeoutMs: 1 })
// -> { result: 1, fuelUsed: 0.2 }     and __PWNED__ === true

timeoutMs: 1 was irrelevant and 0.2 fuel was charged, because the whole sandbox is downstream of transpilation.

It was a category error before it was a vulnerability. AJS has never had test blocks — they are a TJS feature, and the AJS path inherited them only by sharing parse(), where every other TJS-only transform is gated on vmTarget and this one was not. An agent language whose premise is that code travels as data and runs with no ambient authority was calling new Function on submitted source.

What changed: a VM-target transpile rejects test blocks as the syntax error they are. TJS inline tests are unaffected — no .tjs behaviour changed, and the global runTests default was deliberately not touched, because that would have altered documented TJS behaviour to fix a bug that only ever existed on the other path.

Verified two ways: every TJS construct a VM-target transpile still accepts was probed for transpile-time execution (none executes), and a source-level test asserts that test extraction is the only dynamic-execution site in the entire parse path.

Still open and tracked: a VM-target transpile accepts seven TJS constructs AJS does not have (inert, ratcheted). The structural fix — an AJS core that TJS wraps, rather than one shared function with flags — is planned. A gate fails open; layering fails closed.

Fixed

Changed

Added

Compatibility

Every file in zod, effect, kysely, radash, superstruct and ts-pattern now converts and parses — 1973/1973, up from 1951. All six projects' own test suites pass against our output.

[0.13.6] — 2026-08-26

BREAKING — defineAtom now defaults to effects: 'io'

If you define custom atoms, read this. It ships as a patch, deliberately, and the reasoning is worth stating because the obvious call is the wrong one.

Gating a security correctness fix behind a version bump means every adopter on ^0.13.x keeps the hole until they choose to move. Here the failure mode is the silent absence of protection, so the person who never upgrades is the one who stays exposed — while the person who does gets, at worst, a loud error telling them their atom was handing live host references to guest code. There are no bad surprises in that trade, only good ones, and they should arrive automatically. Breaking toward correctness, loudly, is a bugfix.

So: no bad surprises, but not silent ones either — hence this entry, the BREAKING marker, and the migration notes below.

effects defaulted to 'pure', and 'pure' skips the capability membrane. Not a lighter check — membraneValue has exactly one call site, inside if (atom.effects === 'io'), so an atom that didn't opt in bypassed the boundary entirely: host objects reached guest scope by reference, getters intact, with methodCall standing right there.

One default was serving two populations with opposite needs. Core atoms (len, jsonStringify, map) work on data already inside the VM, so 'pure' is right for them. Atoms defined through the public defineAtom exist to bring host data in — Firestore snapshots, Elasticsearch hits, SDK responses — which is precisely the data the membrane exists to sanitise, and precisely the shape that carries accessors. The default served the first and silently disabled the boundary for the second, whose authors are outside our audit surface.

It failed quietly, which is what settles it: nothing warned, nothing broke, the atom worked and the hardening was absent. snowfox-app upgraded specifically for the 0.12.0 prototype-strip and later found all four of its custom atoms untagged (#38). When the people who read the release note and acted on it still don't get the protection, documentation is not a control.

What changes for you. An atom you define without effects now has its return deep-copied through structuredClone before it reaches guest state. Three consequences:

If your atom really is pure, say so and nothing changes:

defineAtom('slugify', inSchema, outSchema, fn, { effects: 'pure' })

That is the honest fix, not a workaround — and it is now an explicit claim rather than something you get by forgetting. Core atoms are classified the same way, by an explicit sweep in both directions, so their class no longer depends on which default is in force.

Fixed

Added

Fixed (same change)

[0.13.5] — 2026-08-25

Fixes two defects in 0.13.4's asCompared, both found by a nine-lens review run after that release was published. If you use asCompared, upgrade. If you do not, the second one still affects you — the projection table is emitted into every file that uses ==, Is or truthiness, whether or not it declares a projection.

0.13.4 also went out without its tag being pushed, so the full-suite pre-push gate never ran for it. That is how it shipped.

Fixed

Changed

Documentation

[0.13.4] — 2026-08-25

Added

Fixed

[0.13.3] — 2026-08-24

Found by an ecosystem security sweep and by playing with the deployed playground — neither by a test here. Both defects were in published 0.13.2.

Fixed

Changed

[0.13.2] — 2026-08-21

A ninth pass then blocked this one on three more (report), two of them sibling-site misses in the fixes above: the symlink guard protected only the LEAF, so a symlinked output DIRECTORY still escaped and destroyed a file outside the named tree; and moving the #! line to the file-write seam fixed -o while silently regressing tjs emit bin.tjs > bin.js, which is the first example in --help. Both fixed here, with containment (a symlinked -o ROOT stays legal — that is a normal setup) and with stdout treated as the artifact sink it is. A fourth finding — that port.test.ts is red — was checked and refuted: isOurServer rejects the test runner under both relative and absolute argv.

An eighth review pass, run after 0.13.1 shipped (report), found that 0.13.1's hashbang fix had been applied at the wrong seam — breaking tjsx, a published bin. Six of eight rounds have now found a defect introduced by the previous round's fixes. That is the argument for the round, not against it.

⚠️ Upgrade from 0.13.1

Fixed

[0.13.1] — 2026-08-21

0.13.0 was published by mistake. It was meant to be a release candidate and the version was labelled 0.13.0; the publish also ran from the working tree rather than a pushed tag, so the full-suite gate in .githooks/pre-push never fired for it. v0.13.0 has since been tagged retroactively at the commit the registry actually has, and 0.13.0 will be deprecated on npm in favour of this release.

A seventh pass then reviewed this patch before it shipped and blocked it on three more, all introduced by the fixes below (report): tjs check had started silently SKIPPING symlinked source files (green because it did not look), emit/convert still followed symlinks and could write output derived from outside the tree they were given, and tjs emit silently stripped the #! line this release advertises as newly supported. All three are fixed here. Five of seven rounds have now found a defect introduced by the previous round's fixes — which is the argument for the round, not against it.

A sixth review pass, run AFTER publication (report), found the blocker below plus every major fixed here. Reviewing after shipping is not the plan; it is what caught this.

⚠️ Upgrade from 0.13.0

Fixed

Performance

Internal

Compatibility

[0.13.0] — 2026-08-19

Reviewed FIVE times before tagging, each pass over the full diff since v0.13.0-beta.1. The first (report) returned BLOCK on five blockers; the second (report) BLOCKed on two more that the first round of fixes had introduced; the third (report) BLOCKed on a parenthesised-arrow emit bug; the fourth (report) BLOCKed on four, two of them regressions introduced by the third round's own fixes — a maxHeapBytes bypass and a build failure that would have shipped a stale bundle; the fifth (report) BLOCKed on two — a let arrow with :? that crashed at module load (a regression from the fourth round's own fixes) and the shipped tjs binary hard-failing for anyone without the TypeScript compiler. All are fixed, along with every major and most minors each pass confirmed.

That pattern — a fix round introducing the next blocker — happened in FOUR of the five rounds, and is the honest argument for reviewing again after fixing rather than treating the last green run as the answer.

Links are absolute because docs/reviews/ is deliberately excluded from the npm package ("!docs/reviews" in files): relative links would be dead on npm and unpkg.

⚠️ Upgrading — read this first

11 changes alter behaviour. Most produce no type error, so recompiling does not catch them; one of them — a Type block that declares no example, predicate or default — is a hard compile error on source that used to transpile. All but the VM-budget change affect code that ran under 0.12.0.

(The count and the "the last one" pointer were both stale: bullets were appended to this list after it was written, so the positional reference had drifted off the item it named. Positional references into a list that grows are a standing trap; the item is named now.)

Two bodies of work. First, the language stabilised in its own direction: the guiding rule became a form that parses must mean something, and every construct that parsed while validating nothing was either built or removed. Second (below, from "Everything below came out of the full pre-release review"), the pre-release review of 0.13.0-beta.1.

The through-line is worth stating because it explains why so many entries are small: the language now claims less by accident and checks more on purpose, and everything it claims is asserted by something that runs.

Added

Changed

Fixed

Performance

Documentation

Everything below came out of the full pre-release review of 0.13.0-beta.1. Seven findings were blockers; the review also caught that the beta's own changelog entry had omitted four VM security fixes entirely (now written up in their own section, below, since they shipped there).

Changed

Fixed

Security

Fixed

Changed

[0.13.0-beta.1] — 2026-08-03

Beta. The language changed shape: all nine mode directives are gone and the file extension is the only gate. Escapes are per-construct, so an accidental use is still caught. Published as a beta because that shape change deserves real use before it is called stable.

Removed — BREAKING

Security

The four items immediately below shipped in 0.13.0-beta.1 and were omitted from this entry at the time — found by the pre-release review of the beta. They are recorded here, under the version that actually contains them, rather than backdated into [Unreleased].

Security / Chore

Added

Changed

Fixed

0.12.0 — 2026-07-20

Minor bump with breaking changes — see Changed. Lands the TjsDictDefaults mode and five VM-security fixes from a two-round adversarial review. Closes zero open GitHub issues (this release is security + dict-defaults, both internally driven); the per-mode opt-out those security/dict-defaults changes make more acute is tracked as #7, still open.

Security

Affected versions: the SSRF, ReDoS, capability-membrane, methodCall, and scope-name fixes below address vulnerabilities present in 0.11.0 and all earlier releases. Pinned consumers (VM embedders) should upgrade.

Added

Changed

0.11.0 — 2026-07-18

Minor bump — two new entry points (./import-resolver, ./import-resolver/worker), no breaking changes. This is the release tosijs-ui's doc system builds against.

Added

Documentation

0.10.1 — 2026-07-17

Patch — a critical fix, no API changes. One behavior change (Is() on cyclic graphs now answers instead of crashing), noted under Changed.

Fixed

Changed

0.10.0 — 2026-07-16

Minor bump — additive features and fixes, no breaking changes.

Added

Changed

Fixed

Performance

Documentation

0.9.1 — 2026-07-11

No breaking changes.

Added

Changed

0.9.0 — 2026-07-06

Added

Fixed

Changed (mildly breaking)

0.8.7 — 2026-07-01

Fixed

0.8.6 — 2026-06-30

Fixed

0.8.5 — 2026-06-30

Added

Fixed

0.8.4 — 2026-06-26

Added

Changed

(No 0.8.3 was tagged — the version was skipped.)

0.8.2 — 2026-06-24

Added

Fixed

0.8.1 — 2026-06-10

Fixed

Changed

0.8.0 — 2026-05-14

Added

Entries below 0.8.0 are backfilled coarsely from the git tags and log (they predated this changelog). Only v0.2.0, v0.7.6, v0.7.7, v0.7.8 were tagged before 0.8.0, so the long 0.2.0 → 0.7.6 span is summarized as one entry rather than split across untagged versions.

0.7.8 — 2026-04-30

Added / Fixed

0.7.7 — 2026-04-27

Fixed

0.7.6 — 2026-04-26

The long feature-accretion phase (202 commits since 0.2.0, no intermediate tags — coarse summary):

Added

Changed

0.2.0 — 2026-01-29

The foundational release: the TJS→JS transpiler (runtime type metadata), the TypeScript→TJS converter, the AJS gas-metered VM (fuel metering, capability injection, monadic errors), the builder API, stored procedures (AST-as-token), Eval() safe eval, proportional fuel charging for memory-allocating ops, and the playground + editor integrations (Monaco / CodeMirror / Ace, linter, autocomplete POC).

Changed