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 thercdist-tag (npm i tjs-lang@rc);lateststays 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 ofvm.run(source), and every run option read once and checked).^0.14.0does NOT admit a prerelease, so pin0.14.0-rc.1exactly to test it. Published through the OIDC workflow, vouched for byrelease-attestation.json.Packaging (rc.1):
typescriptis now a declared OPTIONAL peer (^5) —tjs-lang/lang/from-tsimports 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.tssources 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.0on thercdist-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 declarestjs-lang: ^0.13.1 || ^0.14.0, so npm consumers of both resolve cleanly. That rc verified PEER RESOLUTION; theType-example andTjsStrictchanges 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:
- A function whose parameter admits errors now RUNS with one.
describe(e: Error),isErr(x: unknown),log(msg: '', detail: any), unsafe!functions, and every catch-handler helper used to hand their argument straight back; their bodies now run. - A plain
Error(fromnew Error()or acatch) where something else is expected is a new TYPE ERROR — it used to be passed through as if it were a TJS error, identity intact. - A
MonadicErroris never an object shape, soo: {}or an all-optional shape propagates it instead of running on it; an error inside an options bag ({ x: err }) propagates the same way in every parameter form. vm.runarguments cross the capability membrane, like capability returns (so doEvalcontext values andSafeFunctionarguments, which become them). They arrived LIVE, andmethodCall's allowlist filters method NAMES, not owners: a class instance whose class definesslicehad it invoked by guest code (svc.slice(0)ran host code). Migration — what is now refused, loudly, with anAgentErrornaming the argument:- A CLASS INSTANCE (any prototype but a plain object's,
null,Array,Date,Map,Set,RegExp,Erroror a typed array). Copying only its own data would thin it silently: a Firestore Timestamp'ssecondsgetter read asundefined, and a negated rule over it flipped deny to allow. Convert it to plain data first (ts.toMillis()). The same rule now applies to what a capability RETURNS. - An own function or getter, a Proxy (including a raw tosijs state proxy — pass
.value), aURL, and anything elsestructuredClonecannot copy. Arguments are metered and capped. Admission walks the data before any atom runs, so it is budgeted by the run's own fuel — about 8,000 bytes per unit, the rate binding the same data costs — and capped by the newargsMaxBytesrun option, default 4MB, the same as the capability direction. The cap bounds that pre-budget work absolutely (about 180ms at the walk's worst, whatever the fuel). What crosses is charged to fuel, and so is a refusal. A host passing large arguments must pass fuel in proportion (a 1MB string needs about 250) and, above 4MB, raiseargsMaxBytes.
- A CLASS INSTANCE (any prototype but a plain object's,
vm.runrefuses invalid budget options. Afuel,timeoutMs,argsMaxBytes,membraneMaxBytesormaxHeapBytesthat is not a non-negative number is anAgentError("Invalid run option fuel: …").fuel: 'abc'made the argument budgetNaN, which no size ever exceeds, so the walk it bounds ran unbounded.- Admission is one module (
src/vm/admission.ts), and every entry path goes through it. The release cycle blocked five times on one class — work proportional to caller input, done before any budget could stop it — because each fix guarded one door. Now:vm.run(source)is capped likeEval(newmaxSourceBytesrun option, default 8KB), and its options are checked before the source is even resolved; cost, timeout and quota overrides are validated too (a negative cost override MINTED fuel; aNaNquota read as unlimited; aNaNtimeout disabled the timeout;timeoutMs: Infinityfired after 1ms and now means no timer; a run-leveltimeoutMs: 0still means the deadline has passed).src/admission.test.tsruns every hostile shape through every entry path and asserts each is cheap — a new entry path belongs in that table. - The AJS preprocessor is linear on hostile input. Every quadratic pass the release
reviews measured is fixed at its cause, not bounded: two look-backs that ran per character
over blanked comments (64KB of
//: 54s → ~12ms); an unmatched(rescanned to EOF from every later((60-90s; now each partner — or "never closes" — is recorded as it is proven, held equal to a fresh scan at every(of a real-file corpus); the method-head regex tried at every character;trimEnd()of all output on every/; a class-heritage scan to EOF per header; and the ternary-colon test, which walked back over the whole expression for every:and is now one forward pass (held equal to the old walk at every:of the corpus). AJS paren nesting is bounded at 64 by the recursion itself (the deepest in 3,372 real files is 19); the TJS compiler, which takes the author's own source, has no limit. After five consecutive review blocks each found another such scan, the AJS transform also carries a deterministic work budget (64× the source length, shared by every recursion level; real code uses a median of 2×, at most 8.8×) — fuel for the parser, so a super-linear scan anywhere in it is refused at a bounded cost instead of trusted to be linear. The lexical layer's regex scan remembers which (position, state) pairs it has proven fail, so a line of/[/[…or/\/\…no longer rescans per/.src/admission.test.tspushes every hostile shape it knows, a GENERATED grid of 41 tokens and a grid of 289 token PAIRS, each repeated to the cap, through the source entries; the worst measured at the old 64KB cap was ~114ms for the gridded shapes (see the 8KB cap above for the shapes that were not). - An aborted run takes no further step. Every atom now checks the run's abort signal
before it runs, so after a deadline or a caller's abort no capability is called and no
guest code continues —
vm.runused to stop WAITING while straight-line steps carried on unobserved. A run-leveltimeoutMs: 0fails before the first step (0.13.x let a compute-only agent complete), and a callersignalthat is ALREADY aborted stops the run (it was ignored: anabortlistener never fires for a signal already aborted). A caller's abort is reported as "Execution aborted by the caller", no longer as a timeout. EvalandSafeFunctiontakeargsMaxBytes, so a host can raise the new 4MB argument ceiling through the safe-eval API (strings count two bytes per character).- Every budget option is validated, including outside a run.
compilePredicateandemitVerifiedPredicatereadfuelraw, sofuel: NaNremoved the runaway guard (--fuel < 0is never true for NaN — an exponentially recursive predicate ran unbounded) andemitVerifiedPredicatespliced the unvalidated value into emitted source. Both now refuse afuelthat is not a non-negative number (Infinitymeans no limit), andcompilePredicatesplices only verified names into its generated function.defineAtomrefuses an invalid statictimeoutMswhere it is written, and an atom withtimeoutMs: Infinityno longer makes every run on its VM unbounded. Refusals name the bad value (NaN, notnull). Quota tables are checked over exactly what the VM reads. ANaN, negative or stringquotaUsedcounter switched the quota off (NaN >= 3is false;-100granted a hundred extra calls), and so did aquotasorquotaUsedtable whose bad value was inherited, non-enumerable or behind a getter — validation walked own enumerable keys whiletable[op]reads through the prototype.quotas,costOverrides,timeoutOverridesandquotaUsedmust now be plain objects of data properties (aMapor class instance is refused; aMapnever worked —map[op]isundefined, so it silently meant "no quota").vm.runreads its options exactly once: each option is read a single time (an accessor on the options object is refused — a getter could answer the check one way and the run another), checked, and frozen, and the run reads only that record. The three static tables are built from the entries the check saw, so changing them aftervm.runstarts has no effect.quotaUsedis shared by design, so it is the caller's own object, checked at every read: a corrupted value refuses the next step, a frozen or read-only counter is refused up front, and a run never counts BELOW its own tally — a shared counter can raise a run's count (that is how a quota holds across nested runs) but not lower it. Across nested runs the shared counter is trusted: it is host code. A quota slot is spent before the fuel check, so a call can never happen uncounted. A custom atom named like anObject.prototypemember (toString) is no longer charged by that member's function. Everyvm.runoption is classified in a table keyed by the options type, so an option added without saying whether it is a budget fails to compile — the class blocked this release five review rounds running.src/budget-funnel.test.tsis a second line for budgets read outside a run, and names its blind spots.new AgentVMnow throws on a hand-built atom (one not made withdefineAtom) whosetimeoutMsis not a number or a function — in 0.13 anullquietly took the 1000ms default andNaNdisabled the atom's timeout; build the VM inside a try if its atoms come from configuration.compilePredicatethrows on an export name the verifier did not certify;emitVerifiedPredicaterefusesfuel: Infinity, because emitted code runs in someone else's program. runCodeandtranspileCoderefuse guest-built source over the source cap (8KB) before the host's transpiler sees it, and charge per character. Transpilation is super-linear and runs before fuel or timeout can stop it: 160KB of generated comments took 284 seconds.- Emitted code uses an installed
globalThis.__tjsonly if it speaks the same runtime ABI (abi, now 2); otherwise it uses its own inline runtime. A 0.13 runtime'stypeErrorignores the propagation argument, so 0.14 code running under one replaced the caller's error with a fresh "got object" error.versionsCompatiblenow follows semver below 1.0, so 0.13 and 0.14 count as incompatible. Embedders that pin tjs-lang in more than one place (tosijs-ui pins it twice) should bump every pin together.
What may newly REJECT something — each is a fix, and each can turn a value that used to
pass into a returned MonadicError:
Typeexamples mean what they say. A float example (0.0) no longer means integer, a+Nis non-negative,'' | undefinedis a union (it was bitwise OR,0), type names and references to other types are checked, and a recursive type is checked all the way down. A value your example only matched by accident may now be rejected; one it wrongly rejected (9.99 against0.0) now passes. A recursive type is checked to any depth (a 50,000-deep list, on Node as on Bun) in time linear in the data; cyclic data is accepted when every node satisfies its type.default:inside aTypeblock is a transpile error. It was never read. WriteType T = 0(orType T = 0 { … }).- An
example:that cannot be read, and a type defined only in terms of itself (Type T = T,A = BwithB = A), are transpile errors. The first used to become a type that accepted everything; the second accepts every object under coinduction. - A
Typewith a predicate enforces its example with no runtime installed. Standalone emitted code used to skip the example and run only the predicate, so a predicate wider than its example now rejects what the example rejects. TjsStricton converted TypeScript now validates arguments. A TSobjectparameter is checked only loosely (anything is accepted): TJS has no "non-primitive" type yet, and under-checking is the safe direction.Type X = …reads the whole default expression.Type Opt = '' | undefinedused to emitType(…, '') | undefined— bitwise OR, soOptwas the number 0 — and an object default stopped at its first}.- A
.tjstemplate literal is no longer rewritten. The first-assignment auto-constran over raw source, so a template line likeRed = 'red'gotconstwritten into the STRING. - An anonymous
export default functionis reported asdefaultinresult.types(it wasanonymous, attached to a binding that did not exist), with its return type inresult.typesand the.d.ts, anddefaultin its error paths. - Size, measured against the published v0.13.13 (KiB, zlib default — the README's method):
tjs-lang/langgrew 6.2 KB gzipped (98.2 → 104.4), the fulltjs-langentry 7.5 KB,tjs-lang/browser6.2 KB,tjs-lang/lang/from-ts1.7 KB,tjs-lang/eval1.0 KB andtjs-lang/vm0.9 KB. (An earlier draft of this entry said +2.1 KB, measured against a build that already had some of the growth.) In EMITTED code: a file whoseTypeexamples carry a marker (0.0, a union,+0) carries ~1.7 KB (0.7 KB gzipped) more inline runtime, and only a file with a recursive reference also carries the solver, ~5.8 KB (2.1 KB gzipped) in all; a file with no marker carries nothing. Per call, a marked non-recursive Type measured ~15% slower than an unmarked one (0.039 vs 0.034 µs), and a recursive Type ~5× an unmarked one — the price of checking a value that can recurse.
Changed
The documentation is organised as a book-shaped site. Every doc now carries its place in a navigation hierarchy and in one or more books, for the tosijs-ui doc site that will replace the playground:
section contents TJS the language guide, TJS for JS developers, and the examples, grouped AJS the AJS guide, Safe Eval, the LLM prompt, and the examples TypeScript TJS for TypeScript developers, and 14 TypeScript examples as pages The TJS Language why TJS exists, its principles, Declarations, the syntax reference, and the design notes from docs/TypeScript: the Good, the Bad, and the Ugly the measured comparison with TypeScript, plus two design notes; being written Four books build from the same source: the whole site, The TJS Programming Language, TypeScript: the Good, the Bad, and the Ugly, and AJS and Safe Eval.
Two chapters are new. Declarations grows the old "Type Declarations" example into a chapter: what each of
Type,Enum,Union,GenericandFunctionPredicatedeclares, why each is a callable predicate, and why aFunctionPredicateis a function's type rather than a value predicate. Safe Eval gathers what was spread across the README and two guides behind one argument:evalis too powerful to allow, so the goal was the most powerful eval that is still safe, and it ended up the one part of a program guaranteed to halt.Renamed files (the doc site identifies pages by bare filename, and these collided):
guides/benchmarks.md→guides/performance.md,guides/examples/ajs/error-handling.md→ajs-error-handling.md,guides/examples/tjs/dictionary-defaults.md→dictionary-defaults-example.md, andguides/examples/tjs/type-declarations.md→declarations.md. Agent instructions, TODO lists and working notes are marked hidden and will not be published on the site.tosijs-ui1.14.1 → 1.15.0,tosijs1.6.1 → 1.10.3 (devDependencies; no effect on your install). 1.15.0 fixes two of the three defects that were blocking the doc-system migration:SiteConfiggainedignoreDocPaths, andextractDocs's frontmatter match is anchored to line start — soCLAUDE.md, which documents the format, is no longer classified as a playground example by its own illustration. The third (docsJsondefaulting outsideoutputDir) is documented rather than fixed, which is workable since we set it explicitly.The
tosijsupgrade tightenedComponent's typing and caught two long-standing misuses in our demo: readingcontentVisible(a static initAttribute) instead of the intendednavVisible, and assigning a raw boolean to aBoxedScalar. Both had "worked" only because the old types permitted arbitrary property access.Runtime types are now FUNCTIONS with properties, not objects with a callable
check.Type,Enum,UnionandFunctionPredicateall return callables:const Age = Type('Age', 0) Age(5) // true — callable, the natural JS idiom Age.check(5) // true — the SAME function; one implementation Age instanceof Predicate // true — a real prototype chain, no Symbol.hasInstance Age.name // 'Age' — introspection, for autocomplete and messages Age.example // 0 — a Type is a Predicate carrying a witnessThat block is library code (
import { Type, Predicate } from 'tjs-lang'). Types declared in a.tjsfile are callable,check-is-the-function and named the same way — but they come from the file's inline runtime, which does not share the library's class, so an emitted type is notinstanceof Predicate, and has no.example(the stub calls it__ex). Both are recorded indocs/type-identity.mdunder "Surface, not decisions" and pinned by tests; joining emitted types to the brand is tracked inTODO.md.Additive in practice.
.check()still works,isRuntimeType()still recognises them, and object-shaped types from older emitted output still match — the twotypeof === 'object'guards were widened rather than replaced. Emitted types are file-localconsts that never cross a module boundary, so there is no old-stub/new-shape skew to migrate.The point is not ergonomics. A verified plain function becomes a predicate by attaching properties — no wrapping, no lifting, no two populations to reconcile — and
checkbeing the function means one implementation that cannot drift from itself.nameis set deliberately because it is real introspection;lengthis inherited fromFunctionand says nothing.Runtime types now serialise, which they never did.
JSON.stringify(Type('Age', 0))previously threw — "Maximum call stack size exceeded", because a Type carries aschemaobject with internal cycles. Making types callable turned that throw into a silentundefined(JSON drops functions), which is how the gap was noticed; atoJSONnow emits the type's FACTS:JSON.stringify(Type('Age', 0)) // {"description":"Age","example":0,"default":0,"__runtimeType":true}In emitted code too — and that is not a footnote. The
toJSONfirst landed only in the real runtime, while emitted files call the inline__predstub through their own__tjs_rt, so the stub always wins and is the shipped semantics (docs/type-identity.md). For a release cycle the claim above was true of the library and false of every emitted.tjs, whereJSON.stringify(Age)returnedundefinedandJSON.stringify({field: Age})returned{}— the key vanishing with no error, which is exactly the silent data loss thetoJSONexists to prevent. The stub now serialises to byte-identically what a pre-0.14.0 plain-object type produced, so this is a restoration rather than a new format, and the emitted and realEnumserialise to the same facts.One measured divergence remains and is pinned rather than glossed: the stub names a Type's witness
__exwhere the real runtime saysexample. Same information, two names, soAge.exampleisundefinedin emitted code. Unifying them changes the serialised shape, so it is tracked inTODO.mdas a deliberate change rather than made in passing.New capability rather than restored behaviour — worth stating precisely, because it looks like a regression fix and is not.
The one genuine break is
typeof: a runtime type reports'function'where it used to report'object'. Everything else a consumer is likely to touch is unchanged —.check(),isRuntimeType(),Object.keys(), spread — and serialisation improved from throwing.The inline stub emits callables too — all five constructors, not just
Type— so emitted code and the real runtime agree on shape as well as on decisions. Pinned bysrc/lang/predicate-callable.test.ts, whose library and emitted assertions are driven from one shared table, so a form covered on one side must pass on the other.Predicateis exported fromtjs-langandtjs-lang/css, and is claimed through a shape-versioned global slot (theMonadicErrortreatment,docs/runtime-fusion.md) so it is the same class across bundles — without that,isColor instanceof Predicatewould be false for a consumer importing the brand from one entry and the predicate from another.Verified predicates are
Predicates too —isColor instanceof Predicateistrue, andtjs-lang/css's nineteen unary validators all carry the brand.This is the payoff of predicates being functions rather than objects:
isColorwas already an ordinary function, so becoming aPredicatemeant attaching facts, not lifting it into a different shape.isColorandType('age', 0)are now the same kind of thing, which is what lets one concept cover both in$predicate, in the documentation, and in editor introspection.A bare predicate takes the defaults and claims no structure —
toJSONSchema()returns{ $predicate: … }, and it carries neitherexamplenorvalues, because it has neither. That is the progressive-enhancement story exactly: a naive validator sees "anything", an aware one runs the predicate.isStyleValueFor(prop, val)is deliberately not branded: it is a binary relation, and a predicate's contract ischeck(v)over one value. Branding it would degradeinstanceof Predicateto "callable and boolean-ish".That rule is now enforced in
brandPredicate, which declines any function declaring more than one parameter and hands it back untouched. It previously existed only as a hand-written carve-out insrc/css/index.ts, whilecompilePredicate— the public API that produces functions of exactly that shape — branded every cluster export unconditionally. One rule, two addresses, drifted at birth.It was not cosmetic.
checkTypedispatches on the presence ofcheck, and branding setscheckto the function itself, so a branded relation took the runtime-type branch and was invoked with one argument, its second parameter silentlyundefined. Measured:compilePredicate('function differsFrom(a, b) { return a !== b }', …)thencheckType(v, differsFrom)returnednullfor everyv— a validator that always says yes, with no diagnostic. Rearranged, the same bug fails closed instead, or throws.The brand also erased the evidence:
compilePredicatewraps each export in a rest-args fuel closure, sofn.lengthread0, removing the one defence a careful consumer had and leaving the rule nothing to act on. The wrapper now carries the underlying function's arity and name, so a declined relation is still properly introspectable. Arity0is branded —(...args) => …and() => …both report it, so zero means unknown, not not a predicate. Pinned bysrc/lang/predicate-arity.test.ts, which includes a sweep asserting no binary export anywhere intjs-lang/csscarries the brand.
Added
The AJS AST carries a format version —
{"$ajs": 1, "op": "seq", …}— and the VM acts on it: an AST declaring a version this build does not understand is refused, not executed.An AST is a persisted artifact —
procedureStoremapsproc_…tokens to stored ASTs, and consumers serialise agents — so data written today is read by code written later.To be precise about the deadline, since an earlier draft of this entry overstated it: "absent means 1" is a total, unambiguous rule, so adding the field late would have been perfectly sound provided the format had not changed first. The real hazard is shipping a v2 format without the field, at which point absent becomes genuinely ambiguous and permanently so. The window is "before the format first changes", and this is comfortably inside it — cheap insurance bought early, not a catastrophe averted. The standing obligation: never change the AST format without bumping the version.
It does not eliminate unversioned ASTs — already-persisted ones have no field and keep working. What it does is stop the population growing, turning an unbounded set into a finite, shrinking one.
That claim holds only if every producer stamps the field, and one did not: the transpiler stamped it while
TypedBuilder.toJSON()did not, soAgent.take(…)…toJSON()minted an unversioned AST on every call. The builder is not a lesser path — its output goes tovm.runandstoreProcedureon identical terms, so it is persisted on identical terms, and a single unstamped producer falsifies the claim outright rather than partially. Now stamped, at the root only: nested branches splice.stepsrather than re-serialising, so an innerseqnever carries a field that would describe nothing there. Guarded the same way the boundaries are, bysrc/vm/ast-version-producers.test.ts— both producers asserted behaviourally, plus a scan that fails on any file constructing aseqroot without the stamp, so a third producer lands there by name. The assertion is'$ajs' in ast, neverastVersionOf(ast) === 1: the latter passes for exactly the unversioned ASTs it is meant to catch, since absent reads back as the legacy default, which is 1.The refusal is the part that makes the field real rather than decorative: running an AST whose format we cannot read means guessing at the meaning of untrusted code. The version is checked before the root shape, so a newer AST reports its version rather than "Root AST must be
'seq'" — a diagnosis that would send the reader to entirely the wrong problem.The gate applies at every boundary an AST enters through, not just
AgentVM.run():resolveProcedureToken(which is howagentRunreceives one) andstoreProcedureare gated too.storeProcedurematters most — it validates at storage, because persisting an AST this build cannot run defers the failure to whoever resolves the token later, who did not write it and has no context for the error.The general defect was that nothing made the boundary set enumerable, so the next entry point would have been missed the same way while every existing test stayed green.
src/vm/ast-version-boundaries.test.tscloses that: it drives a future-version AST through each boundary, asserts the gate is present in each function body, and flags any other reader ofprocedureStorethat is ungated — so a fourth boundary nobody listed fails there. Mutation-tested: removing any one gate turns it red.Groundwork for
docs/ajs-native-vm.md(a Rust → wasm VM), where a second implementation reading the same ASTs makes versioning load-bearing rather than merely prudent.
Fixed
Converted TypeScript that opts into validation now accepts valid data. Found by running the TypeScript examples, which had only ever been compiled: three promised "invalid calls return error objects" and printed
Hello, 42!, and "The Full Picture" threw on its own bad-input demo. Three defects stacked, each hiding the next:TjsStrictdid not turn on input validation. Documented as "opts TS-originated code into full TJS; .tjs has this already", it set every mode and left converted code'ssafety nonein place — so the one opt-in a TypeScript author can write validated nothing. It now restores the native default; an explicitsafetydirective still wins.- A
Typeexample lost everything the source said that its value cannot. Examples are evaluated and matched by value, so0.0 === 0madeType Price = 0.0reject 9.99 (narrowed to integer),{ count: +0 }accepted -1,'' | undefinedevaluated as bitwise OR (to0), andstringor a later-declared type was aReferenceError. Parameter types never had the problem — they read the AST.Typeexamples now do too, with the same rules: floats,+N, unions (an all-literal union is still a closed set), sound type names, optional members (T | undefinedmay be absent), and references to other types, read lazily so forward and recursive references work. Native TJS is affected as well —Type Price = 0.0was wrong in every file. An example with none of these emits exactly the code it did before. fromTSwrote interface examples that rejected valid data.any, and every reference to another interface, becamenull("must be null"), soProduct[]accepted only arrays of nulls; optional members were required. Interface and type-alias examples now sayany, name the referenced type, and mark optional members — which also makes converted code read like the TypeScript it came from.
The TypeScript examples now run in CI, and the four that demonstrate a rejection must show it while accepting their valid input. A
default:member inside aTypeblock — never read, soType T { default: 0 }accepted every value — is now an error naming the real spelling,Type T = 0.A Generic's parameter defaults are types, too. A default like
<T = number | bigint>became0.0 | 0n— bitwise OR, which throwsCannot mix BigInt and other typesat load. It now gets the same reading as aTypeexample. (A Generic instantiation,Box(0.0), is still ordinary call code, so a float argument there still narrows to integer — deciding that a call argument is a type needs scope analysis; see TODO. UseBox(1.5).)An anonymous
export default function (…)could not be imported. Its metadata was attached to a binding namedanonymousthat does not exist. It now gets a local binding, exports the same hoisted function, and keeps the.nameJavaScript gives it,'default'.Four
fromTSdefects that kept zod from loading at all, found by running zod's own suite for the first time (it had been skipped on every run for want ofpnpm):export enumlost itsexport;- an interface whose name the file uses as a value — zod's
interface Filebesideinstanceof File, the global — was promoted to a runtimeTypethat shadowed it; - a default import beside named ones was dropped (
import config, { A }→import { A }), andimport def, { type T }vanished whole; - plus the Generic default and anonymous default export above.
Zod now runs 1959 of its tests, all passing; before, 105 of its suites failed to load. That was invisible because the compat harnesses counted only failed TESTS: a suite that cannot load contributes none, so zod reported "551/552 passed". All five vitest harnesses now fail on a failed suite.
Evalcontext values were invisible to atoms.Evalwrapped code in a function with no parameters, socontextreached plain expressions but not atoms:items.filter(…)failed with "items is not an array". Context keys are now declared parameters. Found by running the README's own example, which the rewritten Safe Eval chapter now executes along with every example in it. The keys are imported as variables at the AST level (avarsImportstep), never spliced into source: declaring them as parameters put caller-controlled text into the transpiled source, wheremaxSourceBytesnever measured it, and 80k keys took over a minute to transpile before fuel or timeout applied (the hosted endpoints pass request arguments as context). Only keys the code uses as IDENTIFIERS are imported — read from the parse, after the size gate, so a name inside a template interpolation counts and a string literal does not. A key never rebinds a builtin or a global value (Math,JSON,NaN,undefined…), and code may declare a local (letorconst) that shares a key's name, which shadows it. A non-stringcodeis an error result, not a throw, and an expression containing a callback withreturn(xs.map(v => { return v * k })) is treated as an expression.AJS: a block-bodied callback works.
[1, 2].map(v => { return v * 3 })failed the whole run with "Agent must return an object" — the callback'sreturnwas held to the AGENT's rule — and a returned object was dropped ([null, null]), becausemapandreduceread only theresultan expression body binds. Only expression-bodied arrows worked. A callback body now runs as a function: itsreturn, early or not, is its value.AJS:
constis per binding, per scope. A block-levelconstcould not shadow ANY outer binding (redeclaration was checked up the whole scope chain), and an innerconst xmade an unrelated outerxunassignable for the rest of the run (const-ness was one set of names). Redeclaration is now refused in the same scope only, and reassignment only when the binding a write resolves to is aconst. Still open: aconstinside awhilebody fails on the second iteration, because the body does not get a scope of its own — part of the AJS AST v2 work (TODO), with assignment to an outer variable from inside a block or loop.AJS: a
for...ofbody is a loop, not a callback. Areturninside it now ends the agent, as in JavaScript: an object return (return { allowed: false }) was silently swallowed and the agent carried on to the code after the loop. The transpiler marks the nodeloop: true;mapwithout it keeps callback semantics.AJS:
memoizeandcachebodies are callbacks too — a scalarreturnis the value, not an agent-rule violation — and a sub-agent orrunCodestarted inside a callback is held to the agent rule again (the callback's exemption leaked into it).fromTSclass metadata keeps what the code erases: OVERLOAD signatures — of methods, static methods AND constructors — andabstract. Both are rightly erased from the emitted code — neither exists at runtime — but metadata is where TJS keeps types, and both were being lost there too. Members were recorded by name, so the implementation silently overwrote each signature:m(string): stringandm(number): numbercollapsed to the implementation'sm(a: any). Methods and abstract classes now carryabstract: true. Read fromfromTS(…).classes.One rule for the shape, documented on
FunctionTypeInfo.overloads:overloadsis the full list of CALLABLE signatures, present only when there is more than one; the entry is a summary — the implementation, or the FIRST signature when there is none (ambient or abstract). Renderoverloadswhen present. Note for consumers of a group WITHOUT an implementation: the entry used to be the LAST signature and is now the first, matching declaration order.Scope, stated plainly: this is
fromTS's metadata. TJS's OWN introspection — runtime__tjsmetadata, the playground's autocomplete — comes from TJS source, which has no way yet to express an abstract member or an overload signature, so those facts do not survive TS → TJS → JS. Carrying them through needs TJS syntax for a signature-only member; tracked inTODO.md.fromTSno longer fabricates runtime values from ambient declarations.declare function f(…)becamefunction f(…) { }anddeclare enum Ea realEnum— values TypeScript never emits, which shadow the real one that lives elsewhere. Twoexport declare function fsignatures became twoexport function f: a duplicate declaration that an ES module refuses to load. Ambient VALUE declarations (function,enum,const/let/var,namespace) now contribute metadata and no code, matchingdeclare class. Ambient TYPE declarations (declare interface,declare type) are unaffected — in TJS a type IS a runtimeType, anddeclareadds nothing to one.fromTSnow THROWS on constructs it refuses, where it used to return (lossy) output: decorators anywhere in the file,accessor, and any modifier or class member kind it has no rule for. If you convert a whole tree, catch per file. A refusal is aFromTSRefusalwithcode: 'FROMTS_REFUSED', so it can be told apart from a converter bug; the message names the file, line and a remedy. Thetjs convertCLI already reports per file.fromTSno longer silently drops class constructs it does not understand. It rebuilds a class from parts, from a closed whitelist, and anything off the list simply did not come out — no error, no warning. Measured, every one converting "successfully":TypeScript was now decorators — class, method, property, param dropped, behaviour deleted refused, with the line and a remedy static { … }dropped — the code never ran converted export default class Fexport class F— default imports breakexport default class Fabstract m(): Tan empty method m() { }erased, as TypeScript erases it method overload signatures one extra empty method each erased — one method declare class X { … }(ambient)a fabricated runtime class erased — it describes a class that exists elsewhere accessor xdropped refused constructor(override a: number)this.a = adroppedconverted — overridemakes a parameter propertyDecorators are refused rather than converted because they cannot be converted faithfully:
fromTSreads no tsconfig, so it cannot tell legacyexperimentalDecoratorssemantics from TC39's (the TypeScript 5 default) — which call them differently — and TJS cannot carry@syntax. The class transform now classifies every member kind and modifier — on the class, its members, and constructor parameters — and refuses anything it cannot classify, so new syntax fails loudly instead of vanishing. Found through a permanently skipped test whose reason ("does not parse") had quietly become false, and whose assertion would have gone GREEN on the lossy output had anyone simply unskipped it. The ambient-class case was found by the compat corpus (kysely) the moment the modifier table existed.agentRunwith an inline AST, andrunCode, now apply the AST version gate. Both executed an AST of a format this build cannot read —{op:'agentRun', agentId: <a $ajs:99 AST>}ran its body, with no capability required, andrunCoderan whatever a host transpiler returned. Harmless today (AST_VERSIONis 1) and wrong-semantics execution of untrusted code the day a v2 format ships. Every execution of an AST that arrived from outside is now gated, andast-version-boundaries.test.tssweeps every such site, so a new one fails there by name.Emitted
Genericinstances are callable, like every other runtime type.Box(0)was a plain object in emitted code —Box(0)(v)threwis not a function— while the library's is a callablePredicate. The factory itself is still not a predicate, in both.Object.keys()and spread really are unchanged from 0.13.13. Serialisation support addedtoJSONas an ordinary enumerable property, which added a key and made a spread copy carry atoJSONbound to the ORIGINAL — so{...Age, description: 'X'}serialised asAge. It is now non-enumerable;JSON.stringifystill finds it. Pinned against the key list the published 0.13.13 produces.A non-constructor in
Predicate's global slot is no longer adopted. If something other than a class sits underglobalThis.__tjs_Predicate_1, this bundle uses its own class and records a warning, instead of breaking every predicate at once. Any constructor found there is still adopted, unchecked — the same trust model asMonadicError(docs/runtime-fusion.md§7).A present-but-unreadable
$ajsfield is refused, not read as version 1.{"$ajs":"2"}— a v2 AST whose number a JSON codec stringified in transit — was read as legacy and executed, the exact misreading the field exists to prevent. Reading a field as legacy is not distrusting it; legacy means "run it as v1". Absence proves an AST predates versioning, but a present-and-malformed field proves the opposite: something that stamps versions wrote it. Now: absent → legacy, a positive integer → itself, anything else → refused, with a message naming the likely codec cause.package.jsonsideEffectsnamed one module; five have module-scope effects. Everything an allowlist omits is asserted pure, so a bundler honouring it could skipsrc/index.ts,src/vm/index.tsorsrc/lang/eval.ts— each wires the transpiler at load — and hand a consumer a VM that refuses source, visible only in a production bundle. Worse,tjs-lang/bun-plugin, whose documented usage is a bareimport, which is the exact import an allowlist lets a bundler drop. The list is now derived:src/side-effects-allowlist.test.tsscans every module for module-scope calls over a literal-masked view and fails on any that is neither listed nor exempt with a reason. Deriving is what found the plugin; the review that prompted this named only the other three.Emitted
UnionandExactlynamed themselvesf. Their inline stubs called__predwithout the name argument thatTypeandEnumpass, so.namewas the stub's closure name, in stack traces and autocomplete alike. The real runtime names both from the description; the emitted code now does too.expect(str).toContain(nonString)reported a comparison that never happened —Expected "12345" to contain 234for a needle refused on type. It now says so:toContain on a string needs a string needle, got number (234).expect(…).toContain(…)now does substring on strings. It was array-only — the guard read!Array.isArray(actual) || …, so a string could never pass however plainly it contained the argument, and the failure was reported asExpected "a.tjs:17:sized.opts.x" to contain "opts.x": a content message for what was really a type refusal. Every comparable harness (bun:test, jest) does substring, so the spelling that works everywhere else failed here and misdescribed why.A non-string, non-array haystack now says
toContain expects a string or an array, rather than describing a comparison that never happened.Found by writing a documentation example, not by a test — which is the argument for examples being executed rather than illustrative.
Added
tjs-lang/vm-ast— the VM with no parser in the bundle. The sameAgentVM, built without the transpiler wiring: 56 KB against 221 KB (measured at the time; ~65 KB raw / ~21 KB gzipped at 0.14.0 rc), and no acorn. It takes an AST; transpile on the caller's side withtjs-lang/langand send the AST.import { transpile } from 'tjs-lang/lang' // where the source is YOURS import { AgentVM } from 'tjs-lang/vm-ast' // where the guest code runs const { ast } = transpile(source) await new AgentVM().run(ast, args)The size is the smaller half of the argument.
AgentVM.run()accepts source, and resolving it meant a static import of the transpiler — so a sandbox shipped a parser, reachable from untrusted input, upstream of fuel, timeouts, capabilities and the membrane. That is the position thetest-block leak occupied in 0.13.10; the leak was closed, but the shape that permitted it survived. This removes the shape, by not shipping the code.It also makes good on what "code travels to data" already claimed: if the AST is the wire format, the string never crosses the boundary and the far side has no reason to read one.
Purely additive —
tjs-lang/vmis untouched and still accepts source. The transpiler is now injected (setTranspiler), and every batteries-included entry supplies it explicitly.The guarantee, stated precisely: no parser is present in this bundle — not "this object refuses source even when a parser is loaded". The binding is module-level, so an app importing both entries shares it. That is the guarantee worth having (anyone importing
tjs-lang/vmalready has the parser), but it is not the stronger one, and the difference is load-bearing: the test asserting refusal has to run in its own process, because it passed alone and failed underbun testonce another file imported the main entry.
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
/# … #/— a doc comment TJS actually owns. Multi-line, nestable, and able to quote any syntax without escaping./*# … */is an ordinary JavaScript block comment, so it inherits the rule that block comments do not nest and ends at the first*/inside it. A doc comment that cannot contain*/cannot document comment syntax — which is exactly what a language's own documentation must do. Escaping works and is what this repo did; an escape you must remember is a trap that fails as a parse error some distance from the cause.Claiming this syntax takes nothing away from anyone, and that is measured rather than estimated:
const re = /# comment #/ parses — a regex matching #comment# const re = /# one two #/ SyntaxError: Unterminated regular expressionA regex literal cannot contain a raw newline, so the multi-line form is already illegal JavaScript. The newline requirement is not a style rule — it is the whole subset-preservation argument (
PRINCIPLES.mdinvariant 1), and single-line/#…#/stays a regex forever. Underdialect: 'js'the syntax is left entirely alone.Doc comments are blanked at the first point any pass touches the source, which is the structural payoff: a doc comment exists to QUOTE syntax, so it is the place in a file most likely to contain the constructs every scanner is hunting for. Blanking it up front means the ~30 downstream passes cannot see into it at all — a structural fix for this project's dominant defect class rather than one more scanner that has to remember. A
test '…' { … }, awasm function, anunsafemarker or aTypedeclaration written inside one is inert./*# … */is retired as a doc comment but remains a perfectly good block comment, and stays the convention in.tssources, where/# … #/cannot parse.AJS has them too. The bar for adding a step to AJS's parser is "does AJS have this construct", never "is it harmless" — that distinction exists because seven TJS constructs once leaked onto the AJS path and one executed submitted source. A doc comment clears it: AJS source deserves to carry its own documentation exactly as TJS source does, and blanking a comment is lexical rather than semantic. Withholding it would have broken the subset in the awkward direction — a documented
.ajsfile would be legal TJS and illegal AJS.All 34 TJS examples are migrated (47 doc comments), and
src/rbac/rules.tjs/src/linalg/index.tjsare the first real sources to use it..tjsfiles now publish their own doc comments into the corpus, so a.tjsfile is a literate program on its own terms — the documentation is in the language, and rendering it does not depend on anyone's build system.One thing the migration exposed:
/*# … */doc comments were shipping into emitted JavaScript, because they are valid JS.js-footgun-fixesemitted 265 characters, all of them comment and none of them code — and a test assertingcode.length > 0had been passing on that padding. It now emits nothing, correctly, and the assertion tests what it meant to.unsafeis deprecated. Every remaining use now warns, naming its replacement. The marker is the only escape in this language that does not say WHICH rule it suspends, and it is what drags/* @tjs-unsafe */along as a comment channel.varandevalare now refused outright rather than offered an escape, and their diagnostics say so and say why.evalin particular could not have been given a named replacement: directevalis a syntactic form that sees the CALLER's scope, so any wrapper would silently be a different operation — reaching the caller's scope is precisely the part that is dangerous.The reasoning is now a principle (
PRINCIPLES.md, "An on-ramp and an off-ramp. No craters."): TJS provides a total file-level on-ramp and a total off-ramp, and a per-construct hole in an otherwise-native file is a third kind of thing that relieves the pressure to graduate properly. Strict mode and ESM set the precedent — measured, they still permitvarandeval, so the lesson is not which constructs went but that where something was removed it was removed outright.LegacyDate(x)— a named confession replacingunsafe new Date(x). RawDateis banned in native TJS (mutable, timezone-dependent) andTimestampis the remedy, but a deliberate exception has to be expressible. Until now the only way to say it was the generic markerunsafe, which is the odd one out in this language: every other escape —DangerousLegacyEquals,LegacyExactly,LegacyDefault— is a named, greppable, deliberately-ugly callable that announces what debt it is taking on.unsafesays only "some rule does not apply here" without saying which.The diagnostic teaches the new form and still points at
Timestampfirst — the escape is not the advice, and there is a test pinning that ordering, because the diagnostic is the teaching moment.Prism language definitions for TJS and AJS —
tjs-lang/editors/prism/tjsand/prism/ajs, generated bybun run build:grammarsfrom the sameeditors/tjs-syntax.tsthat already feeds VSCode, Monaco, CodeMirror and Ace. A fifth emitter, not a new grammar, so a keyword added to the source reaches all five.A colon example is highlighted as a VALUE, not a type, and that is the point rather than a detail. In
function greet(name: 'Alice')the'Alice'is a real string that survives to runtime, and reading it as a type annotation is the single most common mistake people make with TJS. Prism renders non-executable fences in doc systems and is being baked into printed and ePub output — where a wrong token colour is permanent and highlighting is the main thing a reader uses to decode unfamiliar syntax. So it gets its ownexample-valuetoken, aliased tostringso existing themes already render it correctly.Also distinguished: safety-marked returns (
:!/:?), block constructs (test,mock,wasm,given,extend),/*# … */doc comments, the declaration forms, forbidden keywords, and the deliberately-ugly escapes (unsafe,DangerousLegacyEquals,LegacyDefault) — which are markedimportantbecause looking alarming is their job.Guarded by
editors/prism.test.ts, which asserts TOKEN TYPES rather than rendered HTML (the class names are what a theme styles against), and pins that every keyword in the source of truth reaches the generated definition.tjs-lang/rbac— the RBAC rule primitives are now importable.interpretRuleResultand the role/shortcut helpers had no built output at all (dist/src/rbac/carried a lone.d.ts), so they were reachable only by readingrules.tjsout of the tarball. That is how #54 arrived: as a fail-open in a reference implementation nobody could import — which also meant nobody could receive the fix by upgrading. They now ship asdist/tjs-rbac.js, verified from a Node consumer.Scope is the RULE layer only.
src/rbac/index.tsimportstosijs/rbacandtosijs/store, andtosijsis a devDependency here, so exporting that entry would ship a subpath that cannot resolve in a consumer's install.rules.tjsis self-contained, which is what makes it publishable../package.jsonis exported. Node refusesrequire('tjs-lang/package.json')without it, and several tools read the manifest (bundler plugins, version probes). Found while verifying the 0.13.12 tarball, when my own check hit it.
Changed
The build compiles
.tjsentry points in-process. esbuild has no.tjsloader andbuildSyncrefuses plugins, so a.tjsentry is transpiled and written to a scratch file the bundler then reads. Deliberately NOT the shell formfunctions/uses (tjs emit "$f" > "${f%.tjs}.js"), because>truncates the target before the command runs — a failed transpile there once left an EMPTY module that bundled and shipped. Writing only after a successful transpile means this cannot half-succeed.Incidentally now dogfooding: the build of
tjs-langcompiles.tjswithtjs-lang.markedmoved 9 → 18 (a devDependency; it does not affect your install). tosijs-ui peer-requires^16 || ^17 || ^18and we had 9.1.6, so the renderer behind our doc system was a version tosijs-ui does not claim to support — and the doc system is what the next release migrates onto.Nine majors is a big jump, so it was measured rather than assumed: all 109 docs were rendered through both versions. 31 byte-identical, 75 differing only in whitespace between block elements, and 3 substantive — two of which were marked 9 getting our prose wrong. It treated a pair of single tildes on one line as strikethrough, so
~0.13 ms (~8K themes/s)rendered as<del>0.13 ms (</del>8K themes/s). Our performance docs use~for "approximately" throughout. Real~~strikethrough~~is unaffected in both.
Fixed
tjs-lang/rbacpromised TypeScript declarations that no build step produced. The new export'stypescondition pointed atdist/src/rbac/rules.tjs.d.ts, and nothing generated it:tsc -p tsconfig.build.jsononly sees.ts, andrules.tjsis not a TypeScript file. A TS consumer of a brand-new export got no types at all, while the build reported success.Caught by
scripts/prepublish-check.ts, which resolves every pathexportsnames — but only at publish time. The reason no test caught it is worth recording: the guard inpackage-exports.test.tsexcludedtypesconditions and excluded./dist/paths, so atypespath pointing intodist/was invisible twice over. Both exclusions are now covered by tests that were mutation-checked (delete the file, watch them go red).The build now generates declarations for any
.tjsentry point whosetypescondition names one, driven off the exports map rather than a list, so the next.tjssubpath is handled without anyone remembering.demo/docs.jsonwas ordered by the filesystem, not by its inputs. The.tjsdoc walk used barereaddirSync, which returns APFS order locally and ext4 order in CI, so two entries could trade places with no content change. That made the committed-artifact check fail for anyone whose filesystem enumerated differently, reporting a stale artifact that was perfectly current. Sorted at both levels; verified by generating twice and comparing.The AJS grokkability lane could never run cold, and was scoring repaired output. Two problems in the one harness:
Its
beforeAllprobes every downloaded model against bun's default 5s hook timeout, so a cold run died before measuring anything — and presented as "pin model not loaded", the harness blaming the environment for a question it never got to ask.It also ran
fixCommonMistakesover model output before scoring, making every published rate a post-repair rate wearing a raw rate's label. Two of its three repairs were rewriting: string→: ''and: number→: 0— spellings the language has accepted for some time viaTYPE_NAMES. So the harness had been repairing something already fixed, which is exactly why the fix could never appear in the number meant to measure it. The rate is now the RAW rate; repairs are named and report separately what they would have recovered. First honest run against the pin: 20/20.Bare type names are now pinned deterministically by
src/lang/ajs-type-annotations.test.ts, asserting the validator REJECTS the wrong type — accepting the annotation and inferringanywould pass a parses-without-throwing test while validating nothing.
Security
All 11 dependency advisories cleared; both trees report clean. In the DEPLOYED Cloud Functions tree,
qs(6.15.3 → 6.16.0) anduuid(9.0.1 → 11.1.1) were runtime scope, so "dev-only, not shipped" was never a mitigation for them. At the root:flatted→ 3.4.4,form-data→ 2.5.6,protobufjs→ 7.6.6,esbuild→ 0.28.2. None of these reach a consumer's install — the published runtime dependencies (acorn,acorn-loose,acorn-walk,tosijs-schema) carry no advisories and are unchanged.bun auditnow runs in CI, because Dependabot structurally cannot cover this repo's main tree. Measured, not assumed: Dependabot had produced alerts for exactly one manifest ever (functions/package-lock.json), and GitHub's dependency graph held 307 packages with none of the root's own devDependencies — the root hasbun.lockand nopackage-lock.json, so it is invisible to the graph. At that momentbun auditfound 9 root advisories (3 high) that Dependabot reported as 0. The gate was previously reachable only from the pre-tag lane, sincetest:fastsetsSKIP_AUDIT=1, so between tags nothing checked.AUDIT_EXEMPTIONSis now empty, and the reason is worth recording: all seven entries were dated2026-10-27and every one was already fixable, six with a published fix available for months. A dated exemption reads as "handled" — the gate stays green, and green is indistinguishable from fixed. The file had already recorded this exact lesson aboutbrace-expansionand it repeated verbatim; both times the trigger was accidental. The guidance now prefers anoverridesentry, which fixes an advisory, over an exemption, which only agrees to ignore it.
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
Eval/SafeFunctionreturned plausible wrong values (#52). Two defects, both silent, both shipped in 0.13.11. Neither was a runtime bug — the AST emitter built an AST that did not represent the source:return data.a -> {"op":"return","value":"data.a"} <- a bare STRING return data["a"] -> {"op":"return","value":{"$expr":"member",...}} <- correct return { ...d } -> {"op":"return","value":{}} <- spread DROPPEDSpread is now compiled instead of skipped. Both object/array literal handlers matched
Propertyonly, so aSpreadElementfell off with no error:{ ...doc, rev: 1 }returned{ rev: 1 }and lost every original field, and[...a]returned[null]— length 1, the hole presenting as a value. It now desugars to the call it means (Object.assign({}, …),[].concat(…)) and recurses, so the whole path is the one already tested and there is no second implementation to drift. Source order is preserved, which is the semantics:{ a: 1, ...d }letsdwin and{ ...d, a: 1 }does not.DOCS-AJS.mddocuments spread under "What's Allowed", so refusing it would have made the doc wrong — the doc was right and the emitter was not.A dotted read returns the value, not its source text. Non-computed member access in value position emitted a dot-path string; the computed branch a few lines above already emitted a proper node and said why — "so the runtime evaluates the index rather than treating it as a string path" — the same reasoning simply had not been applied. The string reached
resolveValue, failed to resolve (the root came fromcontext, i.e. args, and the traversal only checks state), and fell through to "return the literal string". So the caller got back the characters they had written, as data.That one line explains every asymmetry in the report:
typeof data.a,data.a * 2anddata.a.valueOf()were all correct because they build real nodes; only the bare return substituted. Pure boolean predicates were unaffected, which is why a permissions layer looked fine.The string-path optimisation survives where it is provably safe. It is deliberate and has a test to its name, and it is correct whenever the root is a local or a parameter — those land in state, where the traversal looks. The emitter now asks (
TransformContextcarrieslocals/parametersup a scope chain) instead of assuming.resolveValue's literal fallback is recorded, not changed. It cannot simply become an error: a hand-built AST legitimately saysvalue: 'obj.prop'(the builder API, 35+ call sites) and a program just as legitimately says'not.a.path'meaning a string — once both are strings they are indistinguishable. So the semantics stand and the near-miss is now reported to the flight recorder, which is what it is for.Guarded by
src/lang/eval-value-fidelity.test.ts. Found by tosijs-platform against a known-good oracle — worth recording, because both bugs returned values of the right SHAPE, so every structural check passed and only a differential comparison could see them.
Security
A rule returning a non-boolean granted access (#54).
interpretRuleResultin the reference RBAC layer ended withallowed: !!result, so every truthy non-boolean was coerced to a grant. Combined with #52 — a dotted read returning its own source text — the most obvious rule anyone would write inverted:return doc.published // doc.published === false -> 'doc.published' // a non-empty string (#52) -> !!'doc.published' // true -> ACCESS GRANTED // no error, no warningBracket access,
!doc.published,doc.published === trueandif (doc.published)were all unaffected, which is exactly why a test suite can miss it — every case in the reporter's own baseline happened to use a surviving shape.A non-boolean result now denies, and the object form requires
allowto be a boolean rather than coercing it. The reason string distinguishes "the rule said no" from "the rule did not answer", because a denial that reads like an ordinary policy decision hides a broken rule.Fixed separately from #52 and on its own terms, even though #52's fix means that particular input can no longer arrive: "the input cannot be corrupted any more" is not the same as "the interpretation is correct". A security property must not depend on the language never having a bug — a nullish-coalescing chain, an accidental object or a forgotten
awaitmust all fail closed. This also restores a claim the surrounding design already made, and which this function was the one place not to honour: fuel exhaustion, a thrown error, or a non-boolean return all evaluate asfalse.No known exposure: reported by tosijs-platform with no ajs-backed endpoint exported yet. Guarded by
src/rbac/fail-closed.test.tsand by inlinetestblocks inrules.tjs, so the guarantee travels with the code.verifyPredicatecertified impure functions as pure. The verifier checked calls — effectful globals, unknown methods, ReDoS — and never looked at assignments or at references to bindings outside the function. All of these were reportedsafe: true:function f(a) { globalThis.hit = a; return true } // writes a global function f(a) { window.hit = a; return true } // writes a global let count = 0 function f(a) { count += 1; return count > 0 } // mutates outer scope const seen = [] function f(a) { seen[0] = a; return true } // mutates outer scopeSo "verified pure" actually meant "calls nothing effectful" — a far weaker claim than the badge makes.
redos-lint.test.tsstates this file's doctrine and it applies unchanged: over-flagging only costs the badge; certifying a dangerous pattern is a broken promise. This was that, in a dimension nobody had checked.Purity is the entire contract behind the badge: a verified predicate compiles to native JS and is trusted without further checking, and
docs/type-system-north-star.mdhas predicates travelling to other runtimes as serialized ASTs, where being pure is precisely what makes them portable. An impure predicate does not port, and nothing said so.Not rated a vulnerability, because predicates come from source the developer wrote rather than from untrusted input — but it is a promise this project makes and did not keep.
The check is scope-correct, not name-based, because the cheap version fails both ways: a parameter named
countshadowing a module-levelcountis local and must keep the badge, while a nested arrow's own binding must not make an outer one writable.Mutability is keyed on whether a module-level binding is ever written, not on its declaration keyword. The first implementation used
kind !== 'const'and rejected 115 tests insrc/css/— the library that is the main consumer of the badge — because its keyword tables ship asvar CSS_NAMED_COLORS = [...]and are never touched again. The keyword is wrong in both directions: thatvaris constant in every sense that matters, whileconst rows = []followed byrows.push(…)is aconstthat mutates.Found while sizing the purity gate for the
:!promotion (TODO.mdA4c), which needs the same analysis — and which cannot use this verifier for a second reason recorded there: it is also too STRICT, rejectingforloops and.push()on a local array.Guarded by
src/lang/predicate-purity.test.ts, which pins both directions — the seven impure shapes above, and eleven ordinary pure ones that must keep the badge.
Fixed — the emitted runtime and the TypeScript bridge
The emitted runtime no longer declares its helpers in your namespace (#39). The preamble declared
Eq,Is,IsNot,NotEq,TypeOf,Type,Generic,Enum,Union,FunctionPredicate,MonadicError,tjsEqualsand theLegacy*family at module scope. Those are ordinary JavaScript bindings in the author's own module, so a file that imported one and also used the syntax that generates it declared the name twice:import { Eq } from 'tjs-lang/runtime' export function same(a: 0, b: 0): true { return a == b } -> SyntaxError: Identifier 'Eq' has already been declaredNode refuses to LOAD such a module, so nothing in it runs — no error from your code, because your code never started. #39 named five of these; the dogfood gate found thirteen.
The inline runtime now lives inside one
__tjs_rtIIFE and generated code calls__tjs_rt.Eq(…). The bodies are untouched: they still call each other by bare name inside the IIFE, so this is a scoping change, not a rewrite of the semantics.The rename has to happen at generation time, which is the whole reason this was not a one-line fix. Once the preamble and your code are one string, a compiler-generated
Eq(and a hand-written one are the same five characters — no later pass can separate them.The ambient surface is preserved.
Is(a, b),DangerousLegacyEquals(a, b),LegacyDefault({…}),Exactly('a')and the type constructors are documented as simply available, and they were available because of those module-scope declarations. Emitted files still bind them — but only when you have not bound the name yourself, which is a question about your AST and therefore decidable, unlike the one above.Measured on the dogfood behaviour gate: 1076 assertions lost → 313, and 38 broken tests → 30. The disparity between those two numbers is the interesting part: ten suites were failing at LOAD time, so the gate had been reporting six "broken tests" for what was really ~760 lost assertions. A load-time failure is under-reported by any metric denominated in tests, by roughly the size of the file.
Guarded by
src/lang/rt-namespace.test.ts, which pins all three properties — no collision, ambient names still work, and nothing inside the IIFE is unreachable.A quoted
wasm functiondeclaration was COMPILED.extractWasmFunctionsdetected on raw source, so awasm functionwritten inside a string was compiled to WebAssembly and the fixture replaced by the JS wrapper it was supposed to produce:const SRC = `wasm function total(a: Float32Array, n: i32): f64 { … }` -> const SRC = `function total(a, n) { return globalThis.__tjs_wasm_total(a, n) }`Four suites were being corrupted this way, and only ONE of them showed up as a conversion failure. That is the part worth keeping: a scanner that mangles a fixture is visible only when the mangling breaks the parse. When it produces valid code — as it did here, four times — the file converts cleanly and lies at runtime instead, asserting against a fixture that is no longer the fixture. One had a real compiled WebAssembly module emitted into the test file itself.
Its regex also opened with
^\bagainst a SLICED string, where\bis satisfied by the first character being a word character — so it never looked at what preceded the match, and the comment claiming it stoppedmywasmwas wrong. Same pair of defects, in the same order, asextractAndRunTests.A quoted
Is/IsNotoperator was transformed.transformIsOperatorswas two plainsource.replace(…)calls, soa Is binside a string became__tjs_rt.Is(a, b). It was the last failure standing on the dogfood behaviour gate — ineval-no-transpile-execution.test.ts, whose entire job is to hold TJS constructs as data and check which ones reach the AJS path. A scanner that edits its own fixtures makes that suite measure itself.Masking is safe for the operands even though the pattern accepts string literals:
maskLiteralsblanks a literal's interior and keeps its quotes, soc Is 'lit'still matches while'x Is y'no longer does.A quoted
/* @tjs-unsafe */annotation was applied as a real one.applyUnsafeAnnotationswas acode.replace(…)over raw source, so it rewrote the annotation wherever it appeared — including inside a string that merely quotes it. The damage is delayed and lands nowhere near the cause:unsafeis TJS-only syntax, so once it is inside a TypeScript fixture that a test feeds back totsc, the parser reads it as a bare expression statement and ASI splits it from the expression it was meant to mark.`const d = /* @tjs-unsafe */ new Date(x)` -> `const d = unsafe new Date(x)` -> const d = unsafe; new Date(x);The annotation's own test suite carries exactly that fixture, so the file documenting the feature was the file it broke. Matched over
maskLiteralsKeepCommentsnow — comments intact (the annotation IS a comment), literals blanked. Rows added toliteral-blindness.test.ts.
Self-hosting
The dogfood behaviour gate reached zero on all three of its 1.0 gates.
original : 4251 pass, 0 fail, 9703 assertions converted: 4251 pass, 0 fail, 9703 assertionsEvery test suite we ship converts to TJS, runs, and preserves every assertion.
KNOWN_CONVERSION_FAILURESis empty. Both ratchet rates are now pinned at exactly 1.0 — there is no slack left to give back, so any regression fails immediately.It read 108 broken tests two days ago. The honest accounting of that distance: roughly two thirds of it was defects in the GATE, not the language — a
testblock quoted as data being executed and deleted,relocate()rewriting import paths inside template literals,relocate()not rewritingrequireat all. The language defects were real (the__tjs_rtnamespace collision, and three scanners reading fixtures as code) but the measurement was the bigger liar. Recorded at the baseline so the next person reads a bad number here as a question about the apparatus first.
Changed — the dogfood gate stopped discarding its own evidence
The dogfood behaviour gate stopped throwing away its own work list. Three things, all of which had cost real time every session the gate was worked:
relocate()did not rewriterequire('./x'), onlyimport. All 15 failures inlang/features.test.ts— the largest remaining cluster by a factor of seven — wereCannot find module './parser'from test bodies reaching for un-exported helpers. 313 assertions lost → 16, and 30 broken tests → 5.testsBrokenwas printed as a bare integer, which is the defect the block above it had already fixed for the baseline ("a number with no name attached"). It now prints the failures clustered by suite, which is the axis that has carried the signal every time.DOGFOOD_KEEP=1leaves the converted tree in place, and the temp directory is created inside the test rather than in thedescribebody — a describe body runs at collection time, so merely loading the file made a directory that any filtered run then left behind. Twenty-two empty.dogfood-*directories had accumulated in the repo.
That
relocate()gap is the third time this gate's own defects dominated its output, and they are now the majority of every movement it has ever recorded. A bad number here is a question about the apparatus first.
Fixed — the literal-blindness class
A comment mentioning
exportconsumed the real one (#51, reported by tosijs).fromTSdecided whether an arrow-function const was already exported withtjsFunc.includes('export ')— a substring test over the whole rendered function, leading comments included. So a body comment quoting`export interface Sub extends …`satisfied the guard and the declaration's ownexportwas never added:export const withAttributes = <A …>(…) => { … } -> function withAttributes(…) { … }No error, no warning; the failure surfaces at LINK time in a consumer's bundler, and only if something imports that name. Rewording the comment brought the export back, which is what "comment text changes the emitted code" looks like from outside. Now decided from the declaration.
The report says it could not be reduced to a small file. It reduces fine — the reduction was measured by looking for the FUNCTION, which is still emitted. Only the
exportis lost.A
test '…' { … }quoted as DATA was executed and deleted.extractAndRunTestsdetected on RAW source, so a test block written inside a template literal or a double-quoted string was taken for a real one: its body ran at transpile time vianew Function, and the text was removed from the emitted output. Both silent.tjs(`test 'uses toBe' {\n expect(1).toBe(1)\n}`) -> tjs(``)Any
.tjsfile that quotes a test block — every fixture in this repo's language tests, and any documentation example — lost the quoted text. Detection now runs overmaskLiteralsKeepComments: literals blanked, comments INTACT, because/*test … */is a deliberate TS-compat spelling. Body extents come frommatchingBrace, retiring the fifth hand-rolled literal scanner in this codebase.Measured effect on the dogfood behaviour gate: 108 broken tests to 59. Three of the four worst-affected suites are language tests whose fixtures are TJS source held in strings, so the gate had been reporting their deleted fixtures as "conversion loses assertions" — 52 of 99 failures were this defect and nothing to do with conversion. Assertion preservation 87.2% → 88.0%, tests 88.9% → 90.2%; baseline ratcheted down.
testwas not actually required to be a whole word. The check wassource.slice(i).match(/^\btest\s+/), where\bsits at the start of the sliced string and is therefore always satisfied, somytest 'x' { }matched.tjs-playground --port N --forcecould have signalled a developer's test run.OUR_SERVERSmatchedcli/playgroundas a bare substring, so it also matchedsrc/cli/playground.test.ts— and since the second identity rule only additionally requires the argv to reference this checkout, any process whose command line merely NAMED that file was identified as our server. Test runs genuinely hold ports (several suites callBun.serve). The generic entry markers now have to end at a token boundary, so a path that merely contains one no longer qualifies. Same defect as theOUR_ROOTanchor one level in: a marker identifying a file that exists rather than a process that is running.Found sitting in the dogfood gate's baseline, which spawns
bun test <every suite path>and so is the only invocation that builds that argv. The gate printed1 failon the unconverted corpus and neither named it nor failed on it; it now does both, and the conversion-damage figures are no longer measured against a baseline that is allowed to be red.A template literal's first
${ … }desynchronised the bang-access transform for the rest of the file.transformBangAccesshand-rolled its own literal tracking, and its${arm switched to code and never switched back. From the first substitution onward the remainder of that template was scanned as code, so its closing backtick was read as an opening one and every literal after it carried inverted parity — a!.quoted as data was then rewritten in place:['bang access', 'function f(o) { return o!.a }'] -> ['bang access', 'function f(o) { return __tjs.bang(o,'a') }']which injects bare quotes into a single-quoted string and fails the parse tens of lines from the cause. The scanner now keeps one frame per template, so
${ … }returns to the template body at its matching}(correct through nesting), and it skips regex literals, which could open a phantom string the same way. Bang access inside a substitution —`${o!.a}`— still transforms, now including nested templates, where previously only the first substitution behaved.This is the literal-blindness class (
docs:src/lang/literal-blindness.test.ts), and it is the last entry but one on the dogfood conversion ratchet:lang/eval-no-transpile-execution.test.tsnow converts, takingKNOWN_CONVERSION_FAILURESfrom two back to one.The shared literal scanner had the same defect, with twelve consumers.
scanLiteralsended a template at the next bare backtick, so`${ x === "`" ? a : b }`ended at the quoted one — and from there the mask inverted: code masked, later literals exposed. Template ends are now found past their own${ … }substitutions, through nesting, strings and comments. What consumers see masked is unchanged: a substitution's interior stays blanked, which is a separate and much wider decision. Regex literals inside a substitution remain untracked, and the limitation is now stated at the function rather than left implicit.
Added
- The dogfood converter gate now checks that every exported value survives conversion, as
the #51 report suggested. A dropped export passes conversion, compilation and graduation, so
none of the three existing stages could see it. Validated across the compat corpus first
(2,085 real files, zero violations once ambient
export declare constis excluded), so the bar is not one this codebase happens to clear. Pinned at 100%, not ratcheted — unlike graduation, there is no legitimate reason for it to be below.
Fixed — the dogfood gate itself (not shipped code)
The dogfood behaviour gate's own relocation step was literal-blind.
relocate()rewrote import specifiers with a regex over raw text, so it also rewrote the ones inside TEMPLATE LITERALS that suites write out as fixture modules at runtime — repointing them at a source directory where no such file exists. Those suites then failed and were scored as conversion losing tests. It now asks acorn which string literals are import sources, because masking cannot help here: an import specifier IS a string literal, so blanking literals blanks the very text to rewrite.59 broken tests → 38. Combined with the
test-block fix above, the gate went from 108 to 38 in one day, and two of those three movements were defects in the gate rather than in conversion.
Changed — cleanups
extractAndRunTestsno longer callssource.slice(i)once per character, and no longer carries its own literal scanner. Both are cleanups, not a speed-up: measured against the previous implementation on a 459KB file the two are indistinguishable (2926ms vs 2940ms), because JS engines representstr.slice(i)as an O(1) view rather than a copy. Recorded because the opposite was the obvious guess, and the compat scan's fourpreprocess is quadraticskips still stand — whatever makespreprocessblow up past ~1MB is somewhere else and is still unidentified.
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
The VM resolved inherited names to host values. Every lookup keyed by a guest-controlled name used
in/ bare bracket access on a plain object, andinwalks the prototype chain — soconstructor,toString,valueOf,hasOwnPropertyand seven otherObject.prototypemembers resolved to real host functions, past an allowlist that only ever enumerated own keys. With no capabilities and 400 fuel:return { v: constructor('abc') } -> { "v": "abc" } // the host Object, CALLED return { v: toString() } -> "[object Undefined]"No escalation to
Function(assertSafePropertyandSAFE_METHOD_NAMEShold) and no prototype pollution, so this was not an escape — but host functions crossing into guest scope is the wrong side of the boundary. Fixed at all three lookup sites;builtinsandunsupportedBuiltinsareObject.create(null)as well.Guest scopes are prototype-linked on purpose (
Object.create(parentState), so a child write shadows), so the fix walks the scope chain and stops atObject.prototyperather than demanding own properties — a plain own-property check severs lexical scoping, which is how the first attempt at this failed.Eval/SafeFunctionnow cap source length (maxSourceBytes, default 64 KB), refused before transpiling.fuelandtimeoutMsare properties ofvm.run, and transpilation happens first, so neither bounded it: with{ fuel: 10, timeoutMs: 1 }, 500 KB took 3.9s and 1.8 MB took ~145s, charging 0.2 fuel throughout. Measured in bytes, not string length. Set0to opt out for trusted source. The hosted endpoints name the limit explicitly.
Fixed
The two
transpile()entry points gave OPPOSITE input contracts for the documented AJS entry shape. Forfunction agent({ apiKey: 'sk-example' }),tjs-langproducedrequired: ['apiKey']and rejected a missing input, whiletjs-lang/langproduced norequiredandvm.run(ast, {})returned{ apiKey: 'sk-example' }— the example value, credential-shaped for a parameter calledapiKey, silently substituted for an input the caller never supplied.The emitter's required-parameter scan inspected only top-level
AssignmentPatternparams, so a destructuredObjectPattern— the documented AJS entry shape — matched nothing; and because that branch was anelse if, the name-based fallback was skipped too. It now descends into destructured members, and falls back whenever the scan yields nothing, since an empty result means "could not index this shape", not "nothing is required".
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:
- If you passed
vmTargettoparse()orpreprocess(), it now throws, namingparseAgentSource()as the replacement. It is not ignored: the flag used to suppress transpile-timenew Function(), so silently dropping it would have re-enabled execution for a caller who had explicitly asked for the protection. TypeScript only ever caught the inline object-literal case, so a JS consumer or anas anyneeded a runtime refusal. - If AJS source you ship contains one of the seven constructs, it now fails at transpile instead of parsing. They failed later and less clearly before; a transpile error is louder.
- Two further acceptance narrowings, found by the pre-release review and not by the seven-
construct list: a typed
let(let x: 0 = n) and a leadingsafety nonedirective now fail with an acornUnexpected tokenon the AJS path. Neither is documented AJS.
Neither the flag nor the constructs are a documented API, which is why this is a patch rather than a minor.
Fixed
tjs-lang/vmis 23% smaller andtjs-lang/evalis 40% smaller (283→218 KB and 164→99 KB raw; 88→66 KB and 54→32 KB gzipped). Not an optimisation — the VM had been bundling ~26 TJS-only source transforms it never legitimately ran, and giving AJS its own parser dropped them. Less code on the path that compiles untrusted input is a security property before it is a size one. The README table is updated and re-measured.
[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
- Two self-hosting canaries had been red on
mainfor five CI runs. They transpile ~275KB of parser modules at ~79% of bun's 5s default and carried no explicit budget. A known-red baseline hides every real failure behind it.
Changed
- The structural guard in
eval-no-transpile-execution.test.tsnow scans the emitters andpredicate.ts, not only the four parser files. It had asserted that test extraction was "the only dynamic-execution site in the entire parse path" — a claim broader than what it checked, which is the failure that test exists to prevent. Widening it surfaced the eight emitter sites above plus two legitimate ones, each now named with its justification.
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
src/dist-freshness.test.ts— the committeddist/bundles must not be older than the source they are built from.editors/**has had this guard for months;dist/is larger, is published, and had none. The mechanical release check had already reportedartifact freshness — no build scriptas a SKIP, on a tool whose summary says "skips are NOT passes"; it was read as a pass anyway.
[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
- An annotated arrow after a keyword failed to parse.
return (x: 0) => x,export default (x: 0) => x,throw (x: 0) => xand curried(a: 0) => { return (b: 0) => a + b }all raisedUnexpected token. A guard read the single preceding character, so thenofreturnread as a callee. Introduced in this cycle; not present in 0.13.6. - A TypeScript optional parameter no longer acquires a runtime default.
fromTSemittedname?: T, and TJS's?:is documented as "same asname = value" — so every optional gained its type example as a default. For a predicate type that meant a truthy object where TypeScript givesundefined, and radash'suniquethrewTypeError: toKey is not a function. Optionals convert toT | undefinedagain, andrequired: falseis reported correctly. - Converted TypeScript overloads no longer change behaviour for unmatched input.
TypeScript erases overload signatures, so the implementation receives every call. We
emitted runtime dispatch variants that rejected input matching no signature —
inRange(null, 0, 20)returned an error where TypeScript returnsfalse. Conversion now emits the implementation and suggests the upgrade instead of performing it. The variants could never have earned their cost: with one body, every variant was a pass-through, so dispatch routed nothing and only gated. It also leaked...__argsinto the generated.d.ts, producing a declaration you could not legally call. - Same-named functions in different scopes are no longer merged. The polymorphic merge grouped by name across the whole file, so two ordinary local helpers were rejected as "ambiguous variants" — legal JavaScript that TJS refused, a subset violation.
- A type-only class field no longer absorbs the next member.
readonly get: Tabovemodify(f) {…}emitted a bareget, producing a getter namedmodify. Private (#x) fields are still emitted — they are load-bearing, not type-level. $is a legal identifier character.function $constructor(…)andArray$were not recognised as declarations at all, so their parameters were never transformed. Also fixed in type-name position (: Record$).- A ternary's
:is no longer read as an arrow's return type.flag ? ((r) => f(r)) : (r) => {…}had its alternative deleted. - A generic type-parameter list is split depth-aware.
<Config = { a: X, b: Y }>was cut inside its own braces, emitting a brace closed by a bracket. A default containing=>was silently dropped. - A generated diagnostic can no longer be terminated by the code it quotes. TypeScript
type text often contains
*/, which closed the comment early and turned the remainder into stray code. convert --emit-tjsno longer reports success for output it cannot read. It now validates its own result, exits non-zero, names the file, and does not write the artifact.givenno longer warns you to usegiven. It lowers to aswitchbefore parsing, so the advice fired on it — quoting the internal lowering back at the author, and failing--max-warnings 0.- A reasoning model's answer is read from whichever channel holds it. Asking for
structured output can leave
contentempty with the answer inreasoning_content; six call sites readcontentdirectly.
Changed
MonadicErroris shared across modules via a shape-versioned global slot, soinstanceofnow works across a module boundary.isMonadicErrorremains the contract for anything crossing one. Seedocs/runtime-fusion.mdfor the rule this establishes: code fuses, data unions.- README bundle sizes remeasured — they had drifted 17–23%.
Added
tjs-lang/langexportsisTernaryColon(src/lang/expression-context.ts), the first syntactic primitive above the lexical layer. Seedocs/parser-primitives.md.docs/parser-primitives.md,docs/runtime-fusion.md.bun run test:compat-scan— a ratchet over the whole compatibility corpus.
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:
- Identity is not preserved. The guest gets a copy. If you relied on handing through a live reference, that stops working — deliberately.
- Non-cloneable returns are now rejected with a
MonadicError(Capability boundary rejected the return of '<op>') instead of silently succeeding. Functions and accessor properties are refused; build the object literally, naming each field.{ ...someResponse }is not the fix and fails silently — aResponsekeepsok/statuson its prototype, so the spread is{}. - The atom is no longer callable from a verified predicate, since predicates may only call pure atoms.
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
convertstrippednewfrom locally-declared classes (#37). Converted modules threwClass constructor X cannot be invoked without 'new'and could not be imported at all — astatic zero = new Thing(0)field fails at module evaluation time, before any of the module's own code runs. Regressed in 0.13.0; 0.12.x and earlier are correct.Dropping
newis only safe where a class is callable, and in native.tjsit is (the emitter Proxy-wraps it). But everyfromTSoutput carries the/* tjs <- … */annotation, and that annotation means JS semantics — no Proxy wrap — so thenewbeing removed was load-bearing. The transform now runs at graduation (where the annotation is stripped and the class becomes callable), which is the step whose job it is.This was also corrupting converted test suites: preserved assertions 0.88 → 0.898, preserved tests 0.89 → 0.912. Both dogfood ratchets raised to lock it in.
Added
A type can declare
asCompared()on itself (#33), beneath theextend/global registries in the projection chain. A registered projection still wins — the registries are keyed byconstructor.name, which is a third party describing a type it does not own, whereas a method is the type answering for itself.This is the layer a Proxy can reach, and nothing above it can be. A proxy over
new Number(0)has no internal slot (and slots are not forwarded to the target), so the slot read throws whileinstanceof Numbersays yes; and it reports its target'sconstructor.name, so the only registerable key is'Number'— which would claim that key for every boxed Number in the process. Agettrap can serve a method.class Money { constructor(c) { this.cents = c } asCompared() { return this.cents } } new Money(500) == new Money(500) // true if (new Money(0)) // falsyContainment is unchanged: the probe and the call are both fail-soft, and a projection that is not a primitive is ignored rather than honoured.
Fixed (same change)
- A literal union disagreed with
==about the same value.__oneOf(the check emitted formode: 'a' | 'b') walkedunwrapBoxedalone while every comparator walked the projection chain first — so a value could satisfyv == 'b'and fail the check'a' | 'b'. The emitter had listed unions as a reason to emit the projection table since 0.13.4; the union check just never read it.
[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 useasCompared, upgrade. If you do not, the second one still affects you — the projection table is emitted into every file that uses==,Isor 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
A projection declared in one module silently changed another module's
if. The projection table was at MODULE scope rather than per-runtime, andextendfed it unconditionally — so a module with noextendat all had its control flow change when an unrelated module loaded:B before A loads: truthy B after A loads: FALSY ← B has no `extend`Four shipped documents asserted the opposite, including this changelog's own 0.13.4 entry ("one module's projection cannot reach another's comparators"). The global write is gone.
The structural half, which is why the first attempt got it wrong:
==andIsare emitted bare and read the file-local table, but truthiness went through__tjs.toBool— the SHARED implementation, which cannot see it. So a value could be falsy without being equal tofalse, inside one module. The emitted runtime binding now overrides its owntoBoolto consult the file-local table, so all three comparators read one table and it is this file's.An attacker-controlled
constructor.namereachedObject.prototype. The emitted table was a bare{}and the lookup was__ac[k], so a key fromJSON.parse— which creates a real ownconstructorproperty — walked the prototype chain.__ac['toString']resolved toObject.prototype.toString, which returns a conforming primitive for any object, so two distinct objects with different contents compared equal under emitted==:eq(JSON.parse('{"constructor":{"name":"toString"},"x":1}'), JSON.parse('{"constructor":{"name":"toString"},"x":2}')) // was trueThe table is now
Object.create(null)with an own-property check, and the resolved value must be a function. Verified againsttoString,valueOf,hasOwnProperty,isPrototypeOfandtoLocaleString.wasm { } fallback { }threw instead of falling back when the engine has no wasm compiler (#36). The async retry lived inside thecatchof the sync attempt with nothing around it —WebAssembly.instantiatenormally rejects, so a.catchwas assumed sufficient, but under memory pressure SpiderMonkey throwsno WebAssembly compiler availableSYNCHRONOUSLY and that escaped, taking down the whole module. Sofallbackcovered "this module failed to validate" but not "this engine has no wasm compiler right now" — the broader case, and the one an author cannot code around: intermittent, resource-dependent, ~1 run in 6 on a loaded Firefox. Reported from tosijs-ui's Playwright lane.The dependency-audit gate did not cover
functions/. It ranbun auditwith nocwdoverride and there is noworkspacesfield, so the deployed Cloud Functions tree was outside it — which is how an ecosystem sweep found 3 criticals there while the gate reported green. Both trees are audited now.
Changed
- Dev-tree advisories cleared (#31):
firebasedevDep ^10 → ^12 removes all elevenundiciadvisories; rootfirebase-admin/firebase-functionsaligned with the versionsfunctions/already moved to. Root audit 23 → 7, all dev-only.
Documentation
- The optional-peer re-export trap (#28) is documented in the README.
import typeis erased from emitted JS but not from emitted.d.ts, so a package re-exporting a tjs-lang type makes tjs-lang a hard typecheck dependency for its consumers — and declaring the optional peer does not help, because optionality governs installation, not type resolution.
[0.13.4] — 2026-08-25
Added
asCompared— a type can say what it IS, for comparison. Declared withextend, so you can attach one to a type you do not own without touching its prototype:extend Timestamp { asCompared() { return this.seconds * 1000 + this.nanos / 1e6 } } ts1 == ts2 // true when they project equally Is({ when: ts1 }, { when: ts2 }) // composes into the deep walk if (failedResult) // false, when the type projects to falseConsumed by
Eq,IsANDtoBool— that last one is the point as much as the first. An errored service result is an object, objects are truthy, and until now the type had no way to say otherwise:if (result)took the success branch on a failure.A projection, not an
equals(other)predicate, deliberately. A projection composes — the deep walk normalises each node, so a projected value nested three levels inside an object just works — and it generalises to ordering, neither of which a comparison predicate gives you.Must project to a primitive, or to nothing:
number,string,boolean,null,undefined. An object projection defers the question rather than answering it.bigintis excluded on purpose —1n === 1is false, so two projections disagreeing on number-vs-bigint would compare unequal for values that are equal, and nobody needs nanosecond-exact==. A non-conforming or throwing projection is IGNORED rather than thrown: a hook that breaks==for every value is worse than one that does not apply.It works in standalone emitted code, not only under a shared runtime. Emitted files declare their own comparators and call them bare, so the projection table is emitted per-file — which also makes it file-local by construction. One module's projection cannot reach another's comparators; a single shared mutable type→behaviour table would be prototype pollution by another name, which is what
extendexists to avoid. ⚠️ This claim was FALSE as shipped in 0.13.4 — the table was process-global and projections did leak across modules. Fixed in 0.13.5; the note is left here rather than rewritten, because a changelog that quietly corrects itself is worse than one that says what happened.This is not a new mechanism so much as an opened one:
unwrapBoxedwas already a comparison-projection table with three hardcoded entries (aStringinstance compares as a string,Numberas a number,Booleanas a boolean). Those are the root of the chain;asComparedis the layer above them. Design and rationale indocs/type-system-north-star.md.
Fixed
functions/: 3 critical advisories cleared (#30).firebase-admin13→14,firebase-functions→7.3.2, andtjs-langoff^0.2.8— a range we deprecated ourselves. 3 critical / 5 high / 21 moderate → 0 / 1 / 16. The remainingundicihigh is pinned transitively by@firebase/auth.An abolished mode directive emitted a bare identifier instead of erroring. The guard scanned the file preamble and stopped at the first line that was not itself a directive, skipping only line comments and block-comment openers — so a markdown line inside a doc comment ended the scan at the top of the file.
tjs emitthen exited 0 and wrote a module that threwReferenceError: TjsSafeEval is not definedon load. Our own Cloud Functions shipped that way; the committed bundle worked only because it predated the abolition.
[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
typeof obj[key]compiled to(typeof obj)[key], so every guard of that shape was silently always true (#29).typeoflowered toTypeOf(…)consuming only.name/?.namechains, so a COMPUTED access fell outside the call:typeof obj[k] !== 'function' // -> TypeOf(obj)[k] !== 'function'TypeOf(obj)is the string'object','object'[k]isundefined, andundefined !== 'function'is always true. No parse error, no type error, no warning, and the source reads correctly — the reporter's filter returned every key instead of dropping the function.The report named one form; six were broken.
x[k],x[0],x['lit'],x[k].foo,x.foo[k]andx[k][j]were all silently wrong, andtypeof f()becameTypeOf(f)()— calling a string, which at least failed loudly. The operand now consumes balanced[…]and(…)by depth over a masked view, so nesting is safe and a bracket inside a string is not structure.This also restores something that never worked:
typeof o[k]where the value isnullnow returns'null'rather than'object'.TypeOfexists to fix exactly that footgun and the value was never reaching it through a computed access.Reproduces back to 0.8.1 — not a regression, always wrong.
The Schema Validation playground example shipped a wrong worked example. Its return annotation described a SHAPE (
{ name: '', … }) where TJS reads a worked example and compares by deep equality — the single most common mistake with this syntax, in a teaching example on the public playground. The guard that should have caught it exists, but the example sat on a skip list whose stated reason is "examples that use imports"; it has no imports, nor did three other entries. A test now derives that justification instead of trusting it.
Changed
demo/is no longer published. It shipped 30 files and ~1.9 MB of playground source that no consumer of the package needs, including the Firebase web key in three of them (#31 — a public project identifier by design, not a secret, but noise that trips secret scanners). Unpacked size 15.2 MB → 13.3 MB. The playground is deployed from.demo/and is unaffected.
[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-owhile silently regressingtjs emit bin.tjs > bin.js, which is the first example in--help. Both fixed here, with containment (a symlinked-oROOT stays legal — that is a normal setup) and with stdout treated as the artifact sink it is. A fourth finding — thatport.test.tsis red — was checked and refuted:isOurServerrejects 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
- If you use
tjsx, the playground, ornew Function(result.code)on a file with a#!line, 0.13.1 threwInvalid character: '#'. Fixed.result.codeis now always a clean fragment and the shebang is carried separately asresult.hashbang. tjs emitnow exits non-zero when it fails. If a build script relied on it exiting 0, it was relying on a bug: 0.13.1 reported2 emitted, 0 failedwith output missing.
Fixed
The
#!line was baked intoresult.code, breaking every embedder.codeis a FRAGMENT —tjsxwraps it innew Function, CLAUDE.md documents that idiom, and the playground evaluates it — and a hashbang is legal only at offset 0 of a whole script. All three died withInvalid character: '#', an error naming neither the shebang nor the file. The line now travels beside the code asresult.hashbangand is re-attached only where a FILE is written.tjs convertsilently stripped the#!line. It never had any hashbang handling; the TS→TJS→JS chain loses it at the first step, so the restore added in 0.13.1 never saw it.convertis the command the migration docs point TypeScript users at — the one most likely to meet a real bin script.tjs emitreported success and exited 0 with output missing.emitFileswallowed its own error whenever-owas given, so the directory walk's failure branch was unreachable: a directory with one broken file printed2 emitted, 0 failed, exit 0, with that file's output absent. Nested tallies were also dropped at the recursion boundary.tjs emitis the documented production build path, so a CI step went green having produced a missing module.convertfixed exactly this in #24; its structural twin never got the same fix.A symlinked output DIRECTORY still escaped the named tree. The first guard unlinked a symlinked LEAF, but with
out/suba link elsewhere,lstatonout/sub/b.jsresolvessubthrough the link and sees an ordinary file — so the write went through and destroyed the target while reporting1 emitted, 0 failed, exit 0. Writes are now CONTAINED: the resolved destination must sit under the resolved-oroot. A symlinked root itself stays legal (-o distwheredist -> /build/distis a normal setup).tjs emit/convertto STDOUT dropped the#!line. Moving the hashbang to the file-write seam fixed-oand regressed the pipe — andtjs emit bin.tjs > bin.jsis the first example in--help, appears in two guides, and is whatfunctions/'s own build script runs. A file and a pipe are two consumers of one decision.tjs emit -o out/a.mjsoverwrote the module with its own generated docs. The sibling paths were derived withreplace(/\.js$/, …), which does not match.mjs— the normal ask for a"type": "module"package — so the docs path equalled the artifact path. Now derived from the stem, and a derived path equal to the output is refused as the bug it is.emit --dtsandemit --docswrote THROUGH symlinks too. The first fix for this routedemit's JS output through a safe boundary and left its two sibling writes on rawwriteFileSync, twenty lines below — soemit --dtsstill destroyed a symlink's target and reported success. Every file the CLI writes now goes through onewriteEmitted, andsrc/cli/write-boundary.test.tsenumerates the command files and fails on any rawwriteFileSyncrather than relying on anyone to remember the sweep. A companion check enumerates theresult.codecontract (never starts with#!, always embeddable innew Function), because that fragment has twelve consumer sites and the previous round tested four.emit/convertwrote THROUGH symlinks in the output directory. Without/a.jsa link toprecious/keep.txt,tjs emit src -o out -roverwrote the target and reported1 emitted, 0 failed, exit 0 — data loss reported as success, in the same command whose READ half refuses that exact escape by construction. Writes now go through onewriteEmittedboundary that unlinks a symlink rather than following it.
[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-pushnever fired for it.v0.13.0has 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 checkhad started silently SKIPPING symlinked source files (green because it did not look),emit/convertstill followed symlinks and could write output derived from outside the tree they were given, andtjs emitsilently 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
{ mode: 'a' | 'b', other: 1 }did not compile in 0.13.0. If you pinned it and hit asignature example is inconsistenterror on an options object, this is why — and the workaround (reordering members) is no longer needed.
Fixed
A nested literal union discarded every sibling after it.
collapseUnionshanded the whole bracket body back to itself, found the first depth-0|inside the container, and returned only what was left of it. Whether a file compiled depended on the ORDER of its members:function cfg(o: { mode: 'a' | 'b', other: 1 })threw, while the same members reversed passed. The array form was worse because it was SILENT —['a' | 'b', 'c']collapsed to a one-element array, so the signature test ran against a shorter argument and passed while checking less than it claimed. Every nested case in the test file put the union in a container by itself, which is why the whole multi-member class — the ordinary shape — shipped untested and green.IsNotwithoutIsemitted a module that threw on first call.needsIs = code.includes('Is(')is false forIsNot(, andIsNotis implemented as!Is(a,b), so emitted code raisedReferenceError: Is is not definedin Node and Bun, for both the call form and thea IsNot binfix form.NotEqescaped only by luck:'NotEq('does contain'Eq('.A
#!line was rejected everywhere excepttjs check. Hashbang is standard ECMAScript (ES2023) and acorn already parses it, so rejecting it madetjs(src, { dialect: 'js' })refuse legal JavaScript — aPRINCIPLES.mdTJS ⊇ JS subset violation. It was handled in thecheckcommand alone, socheckgreen-lit bin scripts thatemit,run,typesandtestall died on. Now handled inpreprocess, which every path goes through.The shared directory walk mishandled symlinks — in three ways, across three commands. A link to a real FILE is now collected (skipping it made
tjs check <dir>print a tick and exit 0 without reading a source file that was there); a link to a DIRECTORY is not descended (descending escaped the tree the user named —emit -rwrote output derived from a file outside it while reporting success — and a cycle recursed 33 levels beforeELOOP); a DANGLING link is skipped rather than fatal (it aborted the run and emitted zero files, losing valid siblings).emitandconverthad been left on the oldstatSyncwalk whencheckmoved, so the three disagreed; they now share onereadEntries.tjs emitsilently stripped the#!line.preprocessblanks it for offset stability and nothing put it back, so an emitted bin script opened with 19 spaces and died with a shell syntax error — exit 0, no warning. Worse than the bug it replaced: 0.13.0 rejected such a file loudly, which at least names the problem.tjs convert <dir>mirrorednode_modulesand dot-directories into its output — 913 real.tsfiles in this repo alone. It applied neither exclusion whileemitapplied both. Hoisted asshouldDescend, now shared by all three walks.--max-warningssilently suppressed narration for a single file, while--helpasserted "a single file always narrates". A warning-budget flag doubling as a hidden verbosity switch;--verboseis the narration control.The array diagnostic's
…marker was missing on the four-kinds path, so[1,'a',true,null,{},Symbol()]reported an exhaustive-looking list after scanning 4 of 6 elements — contradicting the invariant the marker was added to establish.chargeHeapWalkcould refuse a heap write without settingctx.error, breaking the contract its callers document: the caller stopped and the run reported success with no explanation.tjs checkon an all-TypeScript directory said "No source files found", which is true and reads like the path is wrong. It now names the extensions it wants and points attjs convert.tjs-playgroundignoredTJS_CACHE_DIR—defaultOutDirwas an independent copy of the cache-path policyresolveCacheDirwas extracted in 0.13.0 to own, and it was the copy that writes tens of megabytes under$HOME.
Performance
rejectAtwas O(violations × filesize) —locAtscans from offset 0, so 3200 violations in a 343KB file took 540ms to build a 44,498-character message it was about to throw. One forward pass now, with the location list capped at 20 plus a total count.
Internal
- One balanced-bracket matcher instead of four:
matchingBracetakes its closer from its opener and handles{/[/(. The fourth private copy had been added in the same window that hoistedsplitTopLevelTrimmedto prevent exactly that — and the untested copy was the one the nested-literal-unions feature ran on. src/cli/walk.tsandtjs check's three 0.13.0 behaviours gained the tests they shipped without;emitted-module-scope.test.tsnow parses, RUNS and loads emitted output in a realnodesubprocess, having previously only parsed — which is how it heldIsNotin its corpus and passed.perf.test.ts's intensive-loop test no longer prints a ratio: measured in-process it varied 54× for identical code depending on JIT history (2.18× alone, 116.75× in a full run), because the baseline folds away. The file's other ratios are unchanged and carry the same caveat, stated in its header;bun run benchis the authority for any number you plan to quote.
Compatibility
- Bun 1.4: full suite green.
Bun.build()'s native memory leak (oven-sh/bun#34053) is fixed upstream — 300 builds now cost ~15MB total rather than growing without bound. Thefetcherror-shape and shared-reference-recursion issues we filed remain open; our workarounds stay.
[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 — amaxHeapBytesbypass and a build failure that would have shipped a stale bundle; the fifth (report) BLOCKed on two — aletarrow with:?that crashed at module load (a regression from the fourth round's own fixes) and the shippedtjsbinary 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"infiles): 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.)
TimestampandisValidTimestampnow mean epoch MILLISECONDS, not an ISO 8601 string. Both signatures WIDENED ((v: string)→(v: unknown)), andRuntimeType<T>.check(value: unknown)never referencesT, sotsc --strictreports zero diagnostics —isValidTimestamp(isoString)simply returnsfalsewhere it returnedtrue. UseisValidISOTimestamp/TimestampISOfor the string form, orDate.parse(s)to convert. As of 0.13.0 the epoch form warns once per process when handed a valid ISO string, naming the replacement.tosijs-schema≥ 1.5.0 enforcesadditionalProperties: false, which the battery atoms' output schemas did not account for. An OpenAI-shaped message carryingrefusal/annotationsfailed withOutput validation failed for 'llmPredictBattery'— a hardAgentError, not a warning. Fixed here. This affects already-released versions: 0.12.0 and 0.13.0-beta.1 both declaretosijs-schema: ^1.4.0, which resolves to 1.5.x today, so a freshnpm ion an older tjs-lang can break with no change on the consumer's side. Upgrading to 0.13.0 is the fix.VM budgets: loop bindings (
map/filter/find/reduce) no longer re-account the loop variable, so per-iteration fuel is size-insensitive again and matches 0.12.x. If you tuned afuelbudget against a 0.13.0 beta, it buys MORE work now, not less.A declared
Typenow CHECKS when used as an annotation.Type Even { example: 0 predicate(v) { return v % 2 === 0 } }followed byfunction double(n: Even)— before,double(3)returned6; now it returns a MonadicError. The annotation always looked like a contract; it now is one. Nothing to migrate if your values were already valid, but a previously-silent violation becomes a visible error at the call site.:?validates the return value at runtime, not only in the signature test. Two consequences worth knowing, because the wrapper REBINDS the function:f.lengthbecomes0(the wrapper takes...args), so anything reflecting on arity sees a different number.- a declared
async functionbecomes a plainFunctionobject —awaitstill works and it still returns a promise, butfn.constructor.nameis no longerAsyncFunction.
A
Typeblock that declares no example, predicate or default is now an ERROR. The interface spelling —Type User { name: '' \n age: 0 }— used to parse, silently discard its members, and produce a type that accepted every value (User.check(42)→true), while the real runtime threw at construction. Source that transpiled now fails, with the fix shown as code. An EMPTY or comments-only block is still accepted: that is whatfromTSemits for a TypeScript type it cannot express, and it discards nothing.tjs check --max-warningsexits 2 on a fumbled argument, where it used to exit 1. A missing or non-numeric value madeNumber()produceNaN, and0 > NaNis false, so a bare--max-warningsfailed a CLEAN file with "0 warnings exceeds --max-warnings NaN". Exit 2 now means "you invoked me wrong" and 1 still means "the check failed" — worth knowing if a script branches on the code.Excess object keys are now accepted everywhere, and dictionary defaults no longer strip them.
place({ x: 5, z: 9 })againstplace(args = { x: 0, y: 0 })returns{ x: 5, y: 0, z: 9 }; in 0.12.0 thezwas silently deleted (WebIDL §5.4 semantics, with a once-per-site recorder notice). A declared type with a non-empty object example likewise no longer rejects an extra key.Three reasons, in
docs/dictionary-defaults.md→ "Where we diverge from WebIDL": WebIDL strips because a dictionary is a wire format, whereas a TJS= {…}parameter is an options bag inside one program; the notice was silent where it mattered (a log, once per site); and TypeScript cannot express the closed type this enforced — its excess-property check is a freshness lint on literals, not a property of the type, and there is noExact<T>.Members are still validated, missing members still fill recursively, and the prototype-pollution keys are still rejected — that one is a security guard, not a normalisation policy. If you relied on a dictionary default to sanitise a payload, destructure explicitly:
const clean = ({ x, y }) => ({ x, y }). Thedict-default-excess-keylint still fires, now worded as the typo check it always really was.Destructured
:members are genuinely required now, and destructured params generate signature tests.function f({ a: 2, b = 3 })called asf({ a: 2 })used to return5; a call omitting a:member now returns a MonadicError. Separately, a destructured parameter used to produce NO signature test at all (the extractor fell through and threw, and the throw was swallowed), so a previously-green build can go red on a signature that was never actually being checked. Both are the same correction: the annotation always looked like a contract and now is one.Emitted
IsandEqno longer run a hostilevalueOf, and no longer throw. A boxed-primitive subclass that overridesvalueOfused to have that method CALLED by emitted comparisons (Is(new Liar(1), 999)wastrue), and a Proxy fakingBoolean.prototypemade==throw a rawTypeError— out of an operator whose whole contract is that errors are RETURNED. Both now read the internal slot and fail soft. If you relied onvalueOfbeing consulted by==, useIswith a[Symbol.for('tjs.equals')]method, which is the supported seam.MonadicError.actualnow describes an array's ELEMENTS, not just'array'. It is a public field on a public error type, and the string changed:f([1, 'bad', 3])againstxs: [0]reportsarray of number | stringwhere it used to reportarray. That names the intruder next to the expectation, which is the pair a reader can act on — but any test assertingerr.actual === 'array', or matching the full message, will need updating. Very long arrays are summarised from the first 64 elements and marked with a trailing…, so the value never claims to have looked at more than it did.
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
predicate => exprandpredicate { return expr }. The terse spellings are real. The type name binds to the value under test, soType Even { example: 2\n predicate => Even % 2 === 0 }reads as "anEvenis a value where…". Both normalise into the function form, so they inherit predicate verification, fuel bounding and the$predicateschema path rather than re-implementing them.predicate =>previously parsed and accepted every value; it was then rejected outright; now it works.A parameterized type derives its predicate from its example.
Type Box<T> { example: { value: T } }now checks its parameter — writingpredicate(x, T) { return T(x.value) }alongside it was restating what the example already said. Previously a predicate-less parameterized type emittedGeneric([...], () => true): it accepted everything while looking like it checked.Type arguments in an annotation:
b: Box<int>. A parameterized type applied to arguments is a call at run time, and a primitive argument becomes a predicate — the only representation available for a type that is not a value (intcompiles to an inline check, soBox(int)would reference nothing). Predicates compose, soBox<Box<int>>works. The applied type is hoisted to a module-levelconstnamed after the annotation, so it is built once rather than per call and errors readExpected Box_int.T[]— the most common annotation in TypeScript.function f(xs: number[])did not parse at all. It now rewrites to the array-example spelling ([0.0]), inheriting item checking,.d.tsemit and JSON-Schema. Nests (string[][]), andint[]narrows wherenumber[]deliberately does not.Literal unions narrow.
function f(x: 'yes' | 'no')rejects'maybe'. This is the one place the examples model bends, and the line is vacuity: read as examples,'a' | 'b'widens tostring | stringand means what''means, so it says nothing and must have been meant as a set — whereas0 | ''widens tointeger | string, which is a real statement, and stays a union of types. Membership is the language's==, which is a decision with consequences:new String('yes')is a member,+0 | +1is identical to0 | 1, and1 | 1.0is a ONE-member union.
Changed
newis not allowed on a class declared in the same file. A TJS class is called —P(1)andnew P(2)produce identical objects — sonewwas decoration with the look of significance, and it was meanwhile a hard error forDate.unsafe new P(1)is the per-site escape. Scoped to locally-declared classes: for a built-in,newis mandatory (new Float32Array(4)throws without it).A rest parameter cannot have a default.
...xs: number[] = [1]compiled and did nothing —f()returned[]. A rest parameter is always bound, to[]when nothing is passed, so there is no absent case for a default to fill. JS rejects...xs = [1]already; only the annotated spelling slipped through.
Fixed
An emitted module using both
==andIswould not load in Node.Eq,Isand__oneOfeach prepended their own copy of the shared boxed-primitive unwrap, so a file using two of them declaredfunction __ubtwice at module top level. Bun runs that; Node refuses (SyntaxError: Identifier '__ub' has already been declared). Emitted modules were therefore dead on arrival for Node consumers while the whole suite stayed green — the same shape as thetypescriptimport snowfox hit in production. Emitted output is now parsed as a MODULE in the guard, where a duplicate top-level declaration is an error rather than a shrug, and the helpers are driven pairwise because the defect only existed in combination.A literal-union type check THREW on a hostile value instead of returning a
MonadicError.__oneOfcarried the fifth hand-inlined copy of the boxed-primitive unwrap and was the only one still missing the fail-soft guard, sopick(new Proxy({}, { getPrototypeOf: () => Number.prototype }))produced a rawTypeError: thisNumberValue called on incompatible object. A throw out of a type check breaks the promise that errors are returned, not thrown, and it was reachable from any untrusted input. It now calls the shared__ub.The shipped
tjsbinary hard-failed without the TypeScript compiler, including--helpand--version, which touch no TypeScript at all.typescriptis a devDependency; the CLI entry imported./commands/convertstatically, which reachesemitters/from-ts.src/index-tsfree.test.tshad guarded the library entry against exactly this since snowfox hit it in production; the CLI had no equivalent.convertis now loaded lazily and still fails clearly, naming the missing package, when actually invoked.One exempt
new X\n .Y()silenced the no-newrule for an entire file. The rewriter and the checker each had their own answer to "does thisnew Xconstruct X?", and the two disagreed: the rewriter tested for.with no whitespace allowed (sonew Shape\n .Circle(2)becameShape()\n .Circle(2), changing what the program means, andnew Reg[key]()becameReg()[key]()), while the reporter re-matched with\s*\.and so matched nothing.tjs checkaccepted files containing genuine violations. Both now call one shared predicate, and every violation is reported rather than only the first.A
letarrow with a:?return annotation crashed at module load — a temporal dead-zone error introduced by the previous review round's own fix.tjs check <dir>walked into.tsfiles it cannot parse, and a shebang line broke offsets. It now takes the directory its own error text recommends.Exported arrow consts reach the
.d.ts(GitHub #4).export const id = (x: 0) => xemittedid: any, so a consumer got no types for exactly the declaration style modern TS code is written in. Guarded bysrc/lang/dts-compiles.test.ts("exported arrows reach the .d.ts (issue #4)"), which compiles the emitted declarations rather than string-matching them.Nested literal unions, and
| nulldropped from.d.tsoutput.A string containing
/* unsafe */turned validation off for the whole function. Per-function safety was decided by a raw substring search over the parameter source, and a parameter DEFAULT is part of that source — sofunction h(n: 0, s = '/* unsafe */')emittedunsafe: trueand checked nothing, while the same function without the literal validatedn. A nested arrow's default disarmed the OUTER function the same way. This is the one member of the literal-blindness class that turns checks OFF rather than garbling output, so its entire effect is the absence of something.Five declaration transforms rewrote TJS syntax inside string literals —
Type,FunctionPredicate,Generic,Union,Enum, plusconst!. A declaration written inside a template came out as the string's CONTENTS transformed; the single-quoted form injected unescaped quotes and failed to parse, rejecting legal JavaScript even underdialect: 'js'. For a language whose own docs and tests are full of illustrative declarations, "a declaration inside a string" is the normal case.A declaration inside a doc template became a phantom exported type in the
.d.ts. Twodts.tsscanners detected on raw source while brace-matching a masked view, so a documentation template could emitexport type Ghost = (x: number) => any— a type with no runtime, degraded as well, which a consumer's editor autocompletes and their build accepts.The
expectharness differed depending on which runner ran your test. Two copies had drifted in opposite directions: one hadtoThrowand notoBeNaN, the other the reverse.tjs test file.tjsfailed withexpect(...).toThrow is not a functionon a test that passed in the playground. Both documented as working. There is now one harness.The converter emitted
.tjsthat does not parse for builtin return types. Any class method returningResponse,URL,AbortSignal(and others) producedmake():! new Response() {—newis abolished in TJS — ormake():! AbortSignal.abort() {. Three sites emitted return annotations and only one filtered anything.The emitted call-stack array grew without bound. The inline runtime's
pushStackappended one entry per call forever — 201,000 calls left 201,000 entries — because the matchingpopStack()is emitted after thereturn. It is now the same bounded 64-entry ring the real runtime uses. Standalone emitted files are the shipping configuration, so this was a live leak in any long-running program built from them.tjs-playgroundbuilt into its own installed package directory. Under pnpm and bun on Linux that is a hardlink into the machine-wide store. It now builds into an OS cache directory keyed by version, announces the path on stderr, and accepts--out-dir. It also no longer regeneratesdemo/docs.jsonwhen installed, which produced a DEGRADED file from a tarball that does not carry every markdown source.--forcecould stop a stranger's dev server. Port reclaim decided ownership from the executable name (bun/node/deno), which is an ecosystem, not an identity — sotjs-playground --port 3000 --forcewouldSIGTERM→SIGKILLa consumer's Vite or Next dev server and report it as reclaiming its own. Identity is now the full command line, and reclaim refuses to signal its own process.The model-audit cache littered the consumer's project and never expired on logic changes. It was written to
process.cwd()/.models.cache.json— a dotfile dropped into whatever repo you ran from — and keyed only on base URL plus a 24h TTL, so whenlooksLikeVisionModelwas corrected this release an upgrader keptvision: falsefor a multimodal model for up to a day. It now lives in the OS cache directory (~/Library/Caches/tjs-lang,$XDG_CACHE_HOME/tjs-lang,%LOCALAPPDATA%), announces its path, and carries a probe version. Override it with the newTJS_CACHE_DIRif you want it somewhere specific; with no home directory at all (scratch containers, some CI images) it falls back to the OS temp dir rather than to., which would have been the original bug wearing a different hat.extractTDocre-scanned the whole file prefix for every function. Doc comments are now located once per source and binary-searched: 128.9ms → 4.5ms over 58 functions on a 176KB file, and ~12% off a full TS→TJS→JS transpile of it.Arrow and function-expression parameters are validated. The same annotation was enforced or ignored depending purely on spelling:
function decl(n: 0)rejected'x'whileconst arrow = (n: 0) => naccepted it. Only top-levelfunctiondeclarations ever got boundary checks. Arrows are most of real TypeScript, which made this the largest silent hole in the language.const f = function (n: 0) {}did not even parse.Rest parameters enforce their element type.
...xs: number[]acceptedf(1, 'x'). (Not a rest-param bug:...xs: [0]was correct all along — the failure wasT[].)EnumandUnionannotations are enforced.function f(c: Color)emitted no check and warned thatColor"could not be resolved to a runtime type" — the type declared three lines above it.Color.check()worked the whole time; nothing asked it.Type N { example: +0 }rejects negatives.+0means non-negative integer, and+0 === 0, so passing the example through as a value destroyed the narrowing before any runtime saw it — the idiomatic way to declare a count accepted-1everywhere, whilen: +0(which reads the source token) was correct. The emitter now writes the check into the emitted code.The inline runtime and the real runtime agree. Emitted standalone
.jsaccepted values the real runtime rejects: an integer example accepting a float, directly and through objects and arrays. Since emitted code callsTypebare, the inline stub always wins — so those were the shipped semantics, not a fallback. All disagreements are closed and the corpus is empty. (The fourth case, excess keys, was first closed by making the stub REJECT them and then resolved the other way — both checkers now ACCEPT them; see "Upgrading" above. Agreement is the invariant; the excess-key POLICY is the open one.)fromTSemits TypeScript parameter properties.class P { constructor(public x: number) {} }converted to an empty constructor body, so every such field wasundefinedat run time. Silent, and large for TS ports: a parameter property is the idiomatic dependency-injected field.The
new Date()remedy is runnable. It advisedTimestamp.now()without mentioning thatTimestampmust be imported, so following it produced a second error —Timestamp is not defined— from the message meant to resolve the first. It now shows the import and both call forms.The editors described a language that is not TJS. The syntax lists were AJS plus a handful of JS keywords: no
Type,Generic,Enum,Union,FunctionPredicate,predicate,extend,wasm, or any type name —int,unsigned,float. Meanwhile->shipped as a valid operator, and the return-type pattern matched) -> Type, so real return types went unhighlighted while an abandoned form was highlighted. Completions had the same shape: nothing for the declaration forms, the deprecatedisErroras the only error check, and the non-canonicaltest('x')snippet.
Performance
extractParamMarkerswas quadratic twice over. A per-characterregions.some(…)literal guard made it O(n × literals), and removing that exposed a second one:out += …built a rope whileout.endsWith(' ')flattened it once per marker. A 622KB file took 152ms and its per-byte cost ROSE with file size — the failure mode nobody notices until their file is big. Now 0.65ms and flat. Pinned by a differential test against the old implementation plus a growth-ratio assertion.Array diagnostics no longer walk the whole array.
describeActualand the emitted__arrKindsstopped after four distinct element types, which bounds the message but not the work: a homogeneousnumber[]never reaches four, so a ten-million-element array was scanned end to end to conclude "array of number" — on the ERROR path, where a failure inside a loop pays it every iteration. Capped at 64 elements, with…marking a sampled answer so the message never overclaims.
Documentation
TJS vs TypeScript vs JavaScript — a generated comparison where every row is executed against
tsc --strictand TJS on each test run. 18 rows. It doubles as the language-design surface: a row can be markedproposed, which asserts it red until someone builds it. All four proposals raised this cycle are now shipped.Type identity — which mechanism answers "does value
vsatisfy typeT", which is authoritative, and where they disagreed. The load-bearing fact: the inline stub is not a fallback, it always wins in emitted code.Equality invariants are pinned (
src/lang/equality-invariants.test.ts):a === bimpliesa == b, verified exhaustively rather than argued;+1,1and1.0are one value; and==is notTypeOf(a) === TypeOf(b) && a == b— under that rulenull == undefinedwould be false, and it is deliberately true.
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
A required destructured member is now genuinely required.
function f({a: 2, b: 3})called asf({a: 2})returned 5 —bhad silently defaulted to its own example, so:(required) was unenforceable in the one parameter shape people destructure most. The colon value is a type and a worked example, not a default; conflating the two made "required" mean nothing. It now returns aMonadicError.=members are unaffected:f({a: 2, b = 3})called asf({a: 17})still gives 20.Destructured parameters generate signature tests.
function f({a: 2, b: 3}): 5silently produced no signature test, while the near-identicalfunction f(o = {a: 2, b: 3}): 5produced one — so the language's headline promise, that the annotation is the test, quietly did not hold for destructuring. The generated call supplies required (:) members and omits defaulted (=) ones, so the defaults are exercised rather than bypassed — which is what would have caught the corrupted'hello,'default below without anyone writing a test.
Fixed
A comma inside a string literal split the parameter list mid-literal.
{what = 'hello,', who: 'alice'}was split inside the string and the pieces rejoined with', ', putting the comma back with a space after it, inside the literal — sogreeting({who: 'fred'})returned"hello, fred!". Silent: the output parsed, ran, and returned a plausible wrong answer, and the corrupted value reached the emitted__tjsmetadata, the.d.tsand the JSON Schema. Templates and regexes were hit too —a = /,/becamea = /, /, a different regex — and a lone brace in a string (a = '{') broke bracket depth and failed the transpile outright.Same literal-blindness family as the fifteen call sites consolidated earlier in this cycle; it survived that sweep because it splits on commas rather than scanning quotes, so it did not look like a literal scanner. Both parameter splitters now scan a
maskLiterals()view and slice from the original.tjs test <file>ignored its argument and always exited 0. It was built around.test.tjswrapper files, so any other path fell into a branch that discarded it and reinterpreted it as a filter pattern — printing "No .test.tjs files found" and exiting 0. A real file with a failing inline test and a path that did not exist produced identical output and the same success code, which made it useless as a CI gate and actively misleading as the first command a reader types. It now runs the file's inline tests and signature tests, reports each with a line number, and exits non-zero on failure, on a missing path, or on a directory scan that matches nothing.
Security
The OOM guard allocated like the thing it was guarding against. Rejecting an array that exceeded a 1,024-byte
membraneMaxBytesby four orders of magnitude cost 549ms and 103MB at 2,000,000 elements, linear in N — the walk calledObject.keysfirst and checked the budget after. It now scans incrementally and abandons on the first overflow: 0ms and 0MB at every size tested. A caller who set a small budget to bound their exposure was wrong in the one case they set it for.An array's
lengthwas never budgeted. A capability could return an array withlength = 1e9holding three values; it passed the membrane on ~40 bytes andstructuredClonethen spent 6.5 seconds materialising a billion-slot array — synchronous host work invisible to fuel, atom timeouts andmembraneMaxBytesalike, behind the guard whose stated job is to reject before the clone allocates.Two
vm.runexit paths leaked the timeout timer and the caller's abort listener. The root-op throw and the input-schema rejection both happened after the timer was created and before thetry, so neither cleared it — while the comment beside thefinallyclaimed it "guarantees on every exit path". A pending timer also keeps the event loop alive, so a host that validates a batch of agents and then exits did not exit.The capability membrane billed array elements for the string form of their own index, cutting effective array capacity ~3.4×.
readOwnDatachargedk.length * 2 + 8for every own key, and an array's key list is its indices — butstructuredClonecopies an element as a slot and never materialises"199999", so the charge was for bytes that do not exist. It compounded with length: 500,000 floats are 3.81MB of data and were charged 13.14MB, so an ordinary RAG return (300 documents × 768 floats, ~1.84MB) came back asCapability boundary rejected the return of 'storeVectorSearch'under the documented 4MB default — and the only remedy on offer was to raisemembraneMaxBytes, i.e. to weaken the OOM guard to buy back capacity that was never being used. A canonical array index now costs nothing: the element's value is already priced when the walk reaches it. Non-index own properties on an array (arr.meta) keep their name charge, since those really are serialised by name. The guard is unchanged in strength — 1M floats are 8MB and still refused.The capability membrane ran host code and defeated the byte budget on two of its three walk branches. The object branch was hardened, then array indices the next morning; two paths were never revisited. Affects 0.12.0 and earlier, and both branches of
0.13.0-beta.1.- An array's non-index own enumerable properties were never visited at all, while
structuredCloneserialises them. A capability returning[1,2,3]with an enumerable gettermetaran that getter and delivered'HOST-CODE-RAN'into guest state; a throwing variant leaked host exception text intoresult.error.message, whichmalicious-actor.test.tsexplicitly forbids for the object branch. It was a budget bypass too —arr.big = 'x'.repeat(5MB)crossed a 4 MB cap cleanly. - Map/Set were read with
for…of, which dispatches to a guest-overridableSymbol.iterator, whilestructuredClonereads the internal slots. Aclass extends Mapwith a lying iterator presented itself as empty to the walk while 20,000 entries crossed a 1024-bytemembraneMaxBytesintact (verified in both JSC and V8). Three guarantees failed at once: the documented OOM guard was simply not enforced for Map/Set,MEMBRANE_MAX_DEPTHwas evadable by nesting, and host code ran.
One
readOwnData()helper now serves the array and object branches, so they cannot diverge again; collections are read throughMap.prototype.entries.call(v)/Set.prototype.values.call(v), and a Map/Set whose prototype is not exactly the intrinsic is refused.- An array's non-index own enumerable properties were never visited at all, while
xmlParsewas taggedeffects: 'pure'while callingctx.capabilities.xml.parse. That tag is what routes a return through the membrane and what the predicate verifier reads to certify a cluster safe to compile to native JS — so aDOMParserresult reached guest state as a live hostDocument, prototype chain and all, withmethodCallstanding right there. Every atom inatoms/browser.tswas untagged as well.atom-effects.test.tscould not have caught it: it iterates the same constant that assigns the tag, so it proves the list agrees with itself. Newatom-effects-scan.test.tsreads each atom's body and asks whether it touches a capability, randomness, the clock, the network or the console.The live-heap ceiling was bypassed completely by two of the four binding atoms.
varSet/constSethad both the prototype-pollution guard and heap accounting;varsLetandvarsImport— the two atoms whose entire job is binding variables — had only the first, as did the loop binds and the catch binding. Verified: the identical doubling program routed throughvarsLetheld a 1 GB string under the 64 MB default cap. Every guest-scope write now goes through onesetStateVar()helper, andstate-writes.test.tsfails on any barectx.state[…] =outside it.The heap-ceiling walker reintroduced the size-proportional fuel bypass — the
==bug class, in the function next door to the commit that closed it.estimateByteswas called with the absolute ceiling rather than remaining headroom, per-key accounting replaced rather than accumulated (so re-binding an unchanged object re-walked it in full, forever), and the walk charged no fuel while being synchronous, so no timeout could preempt it. Measured: 500 rebinds of a 300k-element array cost 28,838 ms of pegged CPU for 50.2 fuel — 574 ms per fuel unit, against 1 ms for a benign program charged identically. Now 53 ms. Also fixes an accounting split wherecreateChildScopecopied the running total by value while sharing the per-key ledger by reference, letting the total drift negative and silently buy back budget.hashandomitwere flat-charged regardless of operand size.hashdigests every byte and returns 64 chars, so result accounting sees nothing — 1 KB and 1 MB both cost 1.20 fuel.omitmust walk the whole source object to know what to keep, so 100,000 keys in and one key out cost the same as 1,000.cost-invariant.test.tsnow enumerates the atom registry: every atom needs either a case demonstrating marginal fuel growth or aSIZE_INSENSITIVEentry saying why not.vm.runleaked an abort listener onto the caller's signal — noonce, no removal. Measured 41.6 MB retained after 20,000 runs against one shared signal, versus 1.59 MB with no signal at all; a host running many short agents under a single cancellation scope is the normal case. Now 1.46 MB.
Fixed
compareVersionsordered prereleases lexically, so'beta.2' > 'beta.10'— the tenth beta looked older than the second. Combined withinstallRuntime's wholesale replacement, an older beta "upgrades" over a newer one and discards the flight recorder and any appliedconfigure(). Now follows semver §11, pinned against the specification's own worked ordering example. (The equality case was fixed in the beta; this is the ordering case left behind.) Affects every consumer of0.13.0-beta.1.unsafestole the identifierunsafefrom JavaScript — aPRINCIPLES.mdTJS ⊇ JS violation shipped by the escape hatch that exists to uphold compatibility, and present underdialect: 'js'too.{unsafe: Date}+o.unsafe instanceof Function,let unsafe = Date; if (unsafe instanceof Function), andunsafe in {a:1}were allSyntaxError. A related span defect let one marker un-ban an entire nested closure:unsafe makeHandler({ onClick: () => { eval(src); var leaked = 1 } })transpiled with zero warnings, quietly reinventing the whole-file mode thatunsafereplaced.The literal-blindness class, consolidated onto one scanner — not ended. A source-processing pass hand-rolls its own literal tracking and silently mis-reads code that mentions the syntax it scans for. Since tjs-lang is code about code, its own source, tests and documentation hit this constantly. Every scanning call site now consumes one
scanLiterals()insrc/strip-comments.ts.This heading previously read "ended", and that was wrong — at least fourteen instances landed in this cycle, the last six found by review after the consolidation: a raw
.replacerewriting the contents of user strings; all five declaration scanners detecting on unmasked source (the single-quoted form injected unescaped quotes and rejected legal JavaScript);/* unsafe */read out of a parameter default, turning validation off for the whole function; and two.d.tsscanners emitting a phantom exported type. A further nine surfaced at once when the dogfood conversion ratchet was finally given a CI lane — six of them labelled "undiagnosed" for weeks, all nine the same defect. See #25, whose pre-registered counter this answers, andASSUMPTIONS.mdE1.isInsideCommenthad no notion of strings, soconst OPEN = '/*'— or the ordinary glob'**/*.ts'— convinced it the rest of the file was one giant comment and everytest { }block after it vanished: no error, no warning, no recorder entry. For a language whose thesis is that tests live in the source, silently reporting zero tests is the worst available failure mode.- The naive escape lookback
source[i-1] !== '\\'in fifteen scanners across five files. It is wrong for exactly the input that matters: in'\\'the character before the closing quote is a backslash, but an escaped one.sep == '\\'failed with "Unexpected token" forty characters away. computeBraceDepthstracked strings but not regexes, so oneconst R = /}/— or the very common/\$\{([^}]+)\}/g— drove the depth negative andgenerateDocsreturned an empty document.tjs emitwrites a sidecar.mdper file, so users' docs came out blank.extractEmbeddedTestCommentsproduced both a false negative and a false positive from one blind spot:const q = /['"]/dropped a real embedded test, andconst q = /'/above a JSDoc promoted a documentation example into a real emitted test.maskWasmBodiesmatchedwasm {inside a string and brace-counted past it, swallowing the real code that followed. No error — the output still parsed.- Fixing
findFunctionBodyEndexposed three latent bugs it had been accidentally compensating for: the class, polymorphic-constructor andextendscanners were all matching declarations written inside/*# … */doc comments — the language's own documentation of those features.
New
src/lang/literal-blindness.test.tspins the class intest:fast(41 cases): each trigger placed in a string, template, regex, comment andwasm{}body in turn.The
Eval/SafeFunctionauto-import emitted JavaScript that does not parse. Usage was detected with\bEval\s*\(over masked source, which never checked whether the name was already bound and matches after a..import { Eval } from 'tjs-lang/eval'— the documented form in README, CLAUDE.md and TJS-FOR-TS.md — produced two imports and aSyntaxError, so the documented TS → TJS → JS chain had no correct authoring path. It also fired underdialect: 'js', making legal JavaScript containingfunction Eval(){}un-transpilable. Now decided from the AST: inject only for a call whose callee is a bare identifier with no binding anywhere in the module.bigintwas inverted in both directions.TS_TYPE_NAMESmapped it to{ kind: 'number' }, sof(10n)returned "Expected number … got bigint" andf(10)— a plain number — passed. In 0.12.0 the annotation degraded toanyand simply worked, so this was working → 100% broken, on a type the beta's changelog advertised as checking at runtime. Two adjacent defects made it unusable end to end even once the check was right:fromTSemittedx: 0nthat the return-position scanner rejected, and a single0nanywhere in a file took down the whole transpile with "JSON.stringify cannot serialize BigInt", naming no file and no line.n?: numberemitted JavaScript that throws on the happy path. The colon shorthand rewrites an optional parameter ton = <annotation>, right for an example (n?: 0→n = 0) and a dangling identifier for a type name — sog()threwnumber is not defined. Long-standing, but this release made it far more likely: bare TS names now produce real checks, so the annotation looks like it works, andint/unsigned/floatare newly encouraged.Predicate canonicalization alpha-renamed object-literal keys. Field names are literals, not variables, and the two node types spell them differently, so the guard's
Propertyarm was dead:{ n: 10 }canonicalized to{ $0: 10 }— the canonical AST read a field the source never named. That AST is forwarded verbatim bystoreQueryWheretostore.queryPredicate, making it the silent filter failure its own comment calls "an authorization bug".tjs checkhid the release's flagship diagnostic. The command CI and coding agents run printed✓ filefor a file whose type had silently degraded, whiletjs runon identical source printed the full remedy;tjs emitwas silent too. That directly undercuts this release's own measured finding — a shown remedy is repaired ~80% of the time, a bare diagnostic 0%. Warnings now go to stderr from check/emit/convert (sotjs emit f.tjs > out.jsstill produces clean output), plus--max-warnings N.tjs convertreported success while dropping files (#24).convertFilecaught its own error and returned normally wheneveroutputPathwas set, so the caller'scatchwas unreachable: one good file and one bad file reported "2 converted, 0 failed" and exited 0, with the bad file silently missing. The failure surfaced two steps later as a bundler resolution error.Unconditional rejections reported no location and stopped at the first occurrence. Fixing a file with three violations took three
tjs checkruns, each printing an identical positionless message — in validator order, not source order.var,new Dateandevalnow throw a located error at the first occurrence and list the rest beneath it.LegacyDefault(...)erased the parameter's type toany— weaker than the plain-JS equivalent it exists to reproduce. The caller asked for atomic default semantics; they did not ask for the type to disappear.The shipped editor artifacts taught a language that isn't TJS.
- The generated TextMate grammars could never match anything:
\\\\bin a template literal produces the string\\b, a literal backslash — so every keyword, forbidden and builtin rule in both grammars was incapable of matching any input, for as long as they have existed. The extension's advertised red-squiggle highlighting was dead. .tjshad no VS Code support at all — the grammar was regenerated on every build and referenced by nothing, in the release whose central idea is that the file extension is the language gate.- The TJS keyword model encoded AJS's restrictions. Measured against the real
compiler, 41 of 42 painted-red tokens are legal TJS:
switch/case/defaultare ordinary control flow,type/module/is/as/keyof/neverordinary identifiers. - The CodeMirror completion inserted
unsafe { … }, a form the language rejects, and offered no completion forunsafe <expr>or anyLegacy*bridge — so this release's entire escape vocabulary was undiscoverable in the editor.
- The generated TextMate grammars could never match anything:
Two AJS playground examples shipped truncated, and the flagship
wasm-functionsexample never compiled.extractCodeBlockwas not fence-length aware, so examples opening with four backticks (precisely because their code contains three) were cut mid-expression;extractWasmBlockscompiled awasm { }written in a doc comment, so the example whose prose necessarily says "inlinewasm { … }blocks" printed "did not compile — running the fallback{} (JS)" on every run.
Changed
Timestampthe runtime type is now a number, matchingTimestampthe module.0.13.0-beta.1flipped the representation to epoch milliseconds and announced it, but the runtime type inType.ts— the one re-exported into__tjs, and therefore the one a.tjsfile annotatingt: Timestampis checked against — still validated an ISO string. SoTimestamp.check(Timestamp.now())wasfalse: the type rejected the only value its own constructor produces, while the compiler'snew Date()diagnostic pointed users at that constructor by name.Before ( 0.13.0-beta.1)Now Timestamp— ISO 8601 stringTimestamp— epoch milliseconds(no equivalent) TimestampISO— the ISO 8601 stringisValidTimestamp(v: string)— ISOisValidTimestamp(v)— epoch ms(no equivalent) isValidISOTimestamp(v: string)— ISOIf you were annotating an ISO string as
Timestamp, useTimestampISO. Both spellings now share one predicate withsrc/types/Timestamp.ts, so they cannot drift apart again.The npm package now ships its own documentation.
llms.txtis the agent-facing navigation index and it ships — but 29 of its 43 links were 404 in the tarball, includingCLAUDE-TJS-SYNTAX.md, the file it names as the thing to read first. The guarding test resolved links against the repo root, certifying an artifact nobody installs. The user-facing docs (DOCS-*,TJS-FOR-*,PRINCIPLES,ASSUMPTIONS,CHANGELOG,guides/,examples/,tjs-src/) are now infiles; repo-process docs (TODO/PLAN/AGENTS/UPSTREAM) are linked absolutely on GitHub instead; anddocs-index.test.tsnow resolves againstnpm pack's own file list.
[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
All nine mode directives are abolished.
TjsEquals,TjsClass,TjsDate,TjsNoeval,TjsNoVar,TjsStandard,TjsDictDefaults,TjsSafeEvalandTjsSafeAssignno longer exist. The file extension is the gate — a.tjsfile gets every rule, unconditionally — the way ESM made"use strict"implicit. Writing an abolished directive is now an error that names the replacement rather than a bare identifier that fails at runtime.TjsCompatandTjsStrictsurvive, because they answer a different question — which language is this? That is dialect, not a rule. Plain JS, TS-originated code and AJS/VM code still get JS semantics by default, so TJS remains a superset of JavaScript.Migration is per-construct, not per-file. The old ladder ("turn the rules off, then re-enable one at a time") is replaced by marking the individual sites that need the old behaviour — which is strictly better, because a modes-off file also silenced the next, accidental use.
Security
The four items immediately below shipped in
0.13.0-beta.1and 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].
The capability membrane executed host code while inspecting it. The pre-walk read every own key with
v[k], which invokes a getter — so the machinery whose entire job is keeping host code out of guest state was itself running host code, beforestructuredClonewas reached and regardless of whether the value was ultimately accepted. The rejection path ran them too, so even a refused value had already executed. A getter can throw, mutate, or stall, making this a side-effect vector on the boundary rather than only a data leak. The walk now readsObject.getOwnPropertyDescriptorand rejects accessor properties outright — not evaluated-then-checked, because there is no way to learn what a getter returns without running it. Breaking for capability authors: see the migration note under Changed. Affects 0.12.0 and earlier.The run's
AbortControllerwas aborted only when the timeout fired. Any other ending — fuel exhaustion, an atom error, or plain success — cleared the timer and left in-flight requests alive with nothing left to cancel them. A time box you can only rely on when it expires is not a time box. It now aborts infinally, on every exit path. Teardown signals; it does not await cleanup, because waiting is exactly how cancellation becomes a path that starts unmetered work. Affects 0.12.0 and earlier.Per-atom call quotas (
quotas: { llmPredict: 3, httpFetch: 10 }). Fuel meters work done inside the VM and is blind to what an atom summons outside it: anllmPredictcosting 50 fuel may cost real money, ahttpFetchcosting 10 may hammer someone else's service. Enforced in the atom exec wrapper before both fuel and execution — an exhausted quota must not have already made the call it exists to prevent, and must not also drain the budget. An unset op is unlimited, so this is purely additive.Scope, honestly: a quota counts calls within ONE run. A capability that starts a new
vm.rungets a fresh counter, so an agent able to trigger re-entrancy can multiply its allowance. Pass the samequotaUsedobject to each nested run to enforce a shared cap. Across a process or network boundary no such enforcement is possible — budget does not travel, only tokens and data do. Documented in DOCS-AJS.md and pinned bysrc/vm/quotas.test.ts.installRuntimereplaced the runtime with itself and discarded the flight recorder. Identical prerelease versions did not compare equal, so a second import of the same version counted as an upgrade and wholesale-replaced the installed runtime — taking the error history and any appliedconfigure()with it. (The ordering half of this bug survived into the beta; see[Unreleased].)Fuel bypass: size-proportional atoms charged a flat cost — the
==bug class, found again by a cost-model audit.defineAtom'scost:is charged once per call regardless of operand width, so any atom whose work scales with input size was effectively unmetered. Measured before the fix:jsonStringifyserialized a 2,000,000-element array for 1.2 fuel and completed under a 10-fuel budget.join,split,jsonParseandtemplatewere the same. (The expression path already charged proportionally inmethodCall— the atom path had diverged, which is how it survived.) Those atoms now charge via a sharedchargeForSize()on both operand and allocated result, using the same per-char/per-element constants as the expression path; fuel now scales linearly with N. Affects 0.12.0 and earlier — anyone relying on fuel as a DoS bound against untrusted input should upgrade.Live-heap ceiling (
maxHeapBytes, default 64 MB) — the space budget to fuel's time budget. Fuel meters cumulative work, which bounds how much a program allocates over its lifetime but says nothing about how much it holds at once:x = x + xcharges honestly, yet at ~10 KB-per-fuel a legitimate 100,000-fuel budget still buys roughly a gigabyte of live string. A run that exhausts host memory has taken the process down regardless of how honestly it paid. Guest scope writes (varSet/constSetand atom-result bindings) are now accounted against the ceiling with a bounded, cycle-safe estimator; accounting is per key, so overwriting a variable frees its budget and ordinary loops don't false-positive. Verified: 26 doublings of 1 KB (~64 GB unchecked) stops at the ceiling with unlimited fuel.New
src/vm/cost-invariant.test.tspins the invariant mechanically: each size-sensitive atom is driven at growing N and must show marginal fuel scaling (a flat-charged atom scores exactly 0 marginal fuel — the bug's signature). Cheap stand-in for a mechanized proof of the cost model; it catches the next flat-charged O(n) atom rather than relying on someone noticing. Adding a size-sensitive atom means adding a case there.
Security / Chore
bun auditgate with time-gated exemptions. A new pre-tag lane (src/dependency-audit.test.ts) fails the suite on any high or critical advisory that isn't covered by a live entry inaudit-exemptions.ts. Exemptions are deliberate and dated: each carries areasonand anuntildate, and lapses on that date (the advisory then fails the gate again, forcing a re-fix or a renewed justification) — not a permanent silence. A dead exemption (advisory no longer reported) warns to be removed. The gate runs in the fullbun test(pre-tag) run, is skipped bytest:fast(SKIP_AUDIT=1— it needs the network), and self-skips offline so a network blip can't red the suite. The current exemptions are all dev/deploy-only transitive advisories (eslint→brace-expansion/flatted, firebase→undici/form-data) with no upstream fix yet.- Dropped
vitestandvalibotfrom devDependencies — removing a whole vulnerable dependency chain (incl. a criticalvitestUI-server advisory) that was dev-only and unused: the repo's framework isbun:test. Six files that imported{ describe, it, expect }fromvitest(and thereby errored out underbun test— a silent coverage hole across the timeout/cost-override/request-context/store tests) were migrated tobun:testand now run.valibotwas used only by a compile-only type-inference file, switched to the shippedtosijs-schema.@happy-dom/global-registratorbumped to a fixed happy-dom (≥20.8.9). (The published package's runtime deps —acorn/acorn-loose/acorn-walk/tosijs-schema— carry no advisories; consumers were never exposed.)
Added
unsafe <expression>— the per-construct escape. Marks one construct as deliberate at the site:unsafe new Date(x),unsafe var x = 1,unsafe eval(s). Zero runtime cost. Recognised only in expression position and only on the same line as its expression, so a variable namedunsaferemains legal JavaScript./* @tjs-unsafe */— the same marker for TypeScript source, which cannot contain TJS-only syntax becausetscrejects it.Legacy equality bridges —
DangerousLegacyEquals,DangerousLegacyNot,LegacyExactly,LegacyNotExactly. A fixed operator has no construct to mark, so the escape is a name. The coercing pair is named "Dangerous" because==invokesvalueOf()/toString()on any object and can therefore throw or run arbitrary code; the strict pair is not, because===cannot.LegacyDefault(value)— per-parameter escape from dictionary defaults, restoring JavaScript's atomic semantics for one parameter rather than disabling a whole function's validation.ASI guidance. Statement boundaries are the one place TJS and JavaScript disagree (
const x = g/(a)callsg(a)in JS, two statements in TJS). That case now warns at the site with a line number instead of changing meaning silently.intandunsigned— the numeric types TypeScript never had. TS has a single numeric type, so "this is a count / index / id" is inexpressible and ends up policed by comments or hand-written asserts.n: intrejects a float,n: unsigned(aliasuint) rejects a negative, andfloatis an explicit spelling ofnumber. These extend TypeScript rather than narrowing it —numberstill means number, so pasted TS is unaffected.- The example forms are shorthand for exactly these, and carry a worked value too:
n: int≡n: 5,n: unsigned≡n: +5,n: number≡n: 5.0. That equivalence is pinned by a test: two spellings of one type that disagree would mean one of them is lying to the reader.
- The example forms are shorthand for exactly these, and carry a worked value too:
Canonical form for verified predicates (
canonicalizePredicate/predicateKey, exported fromtjs-lang/lang). A verified predicate is pure, total, serializable and composable; giving it a canonical form makes it an identity, which is what lets one object serve as cache key, pushdown payload (send the predicate tostore.queryinstead of dragging rows to the code), auth object (a permission is a predicate), and the substrate for safe macro splicing. Predicates differing only in formatting, comments or local variable names now share a key; differences in operator, literal value, field name, or any helper in the cluster do not.- Verification is a precondition, not an option — identity implies "same input ⇒ same
result", which an impure predicate doesn't satisfy however identical its syntax, so
canonicalizing an unverified cluster throws
PredicateNotVerifiedError. - Deliberately not an optimizer: commutative operands are not reordered. That would be a claim about totality and cost, not just purity — and a canonicalizer you can't trust isn't usable as an auth object.
- The convenience
keyis FNV-1a and documented as non-cryptographic: fine for cache bucketing (a collision costs a miss), insufficient where an adversary picks the input (cache poisoning, auth) — hash thecanonicalstring with SHA-256 for those.
- Verification is a precondition, not an option — identity implies "same input ⇒ same
result", which an impure predicate doesn't satisfy however identical its syntax, so
canonicalizing an unverified cluster throws
Predicate pushdown (
storeQueryWhere+store.queryPredicate) — send the predicate to the data instead of dragging rows to the code. The atom takes a canonical verified predicate and forwards it as data; the store evaluates it and can cache on its stablekey, so two spellings of the same rule hit the same cache entry. The VM never parses it — that's what keeps the acorn-dependent canonicalizer out of the leantjs-lang/vmbundle and lets the same payload travel to a remote store.queryPredicateis optional (progressive enhancement, like$predicatein JSON Schema); a store without it makesstoreQueryWherefail loudly rather than degrade to an unfiltered read — silently returning rows the caller meant to exclude is a data-exposure bug, not a fallback.- Known, deliberate limitation: canonicalization is structural, so refactoring a predicate (hoisting a subexpression into a local) mints a new identity. Collapsing those would mean inlining, i.e. optimizing — and a canonicalizer that rewrites more than spelling isn't one you can trust as an auth object.
Changed
BREAKING for capability authors: the boundary takes plain data only — no accessor properties. A getter is host code, so a membrane that ran one while inspecting a payload would be executing the thing it exists to keep out. This bites the obvious shape, which is exactly what a host wrapping a
Responsetends to write:// Rejected: `status` is a getter — code wearing a data costume. return { ok: res.ok, get status() { return res.status }, body } // Fix: read it once, hand over the value. return { ok: res.ok, status: res.status, body }Spreading is not the fix and fails silently: a
Responsekeepsok/status/headerson its prototype, so{ ...res }is{}— it crosses cleanly and delivers nothing. Build the object literally, naming each field. Rejection is aMonadicError(Capability boundary rejected the return of '<op>': … accessor property '<name>'), not a throw.Timestampis a number (epoch milliseconds), not an ISO string.diffisa - b,isBeforeisa < b, sorting is the default comparator — andTimestamp.now()is a genuine drop-in forDate.now(), which it was not before.iso()renders the readable form;isValidISOvalidates it.Diagnostics for constructs AJS deliberately lacks now SHOW the fix.
Unsupported statement type: ForStatementwas accurate and useless: an A/B over diagnostic text (experiments/agent-legibility/error-message-ab.ts) measured the repair rate each message actually produces — worked example 80%, prose remedy 50%, our shipped message 0%, saying nothing at all 0%. On thefor-loop case, prose advice scored 0/5 while the same remedy shown as code scored 5/5.for,for...in,switchanddo...whileerrors now carry a worked correction. Pure message text; no compiler change.- Guarded by
src/lang/diagnostic-remedy.test.ts— deterministic, no model needed: every remedy must contain real code, name a supported alternative, reach the thrown message, and correspond to a construct the transpiler actually rejects. That last check caught a first draft claimingfor...ofwas unsupported (it isn't) — a diagnostic for a restriction that doesn't exist teaches a false limit and is worse than none.
- Guarded by
Fixed
Eqcan no longer be made to run user code. It unwrapped boxed primitives witha.valueOf(), which a subclass can override — so a comparison could throw, mutate, or lie about the value. It now reads the internal slot via the prototype method.Optional chaining broke the
==/!=rewrite.o?.b != nulldid not compile: the operand scanner treated?.as a ternary boundary. Every form was affected.Regex literals were read as comments. A regex containing
*/or//desynced the scanner, and an escaped backslash ('\\') desynced the string scanner — between them these broke conversion of several of our own files.Sound TypeScript type names now produce real runtime checks — restoring a stated design goal that had quietly gone missing: implement the parts of TypeScript that aren't Turing-complete damage, and best-effort only the rest. In native TJS,
function f(s: string)inferredany, so it transpiled cleanly, looked typed, and validated nothing — the worst possible outcome in a language whose pitch is that types survive to runtime, and it hit the annotation newcomers and models reach for first (ASSUMPTIONS.md A7).string,number,boolean,bigint,object,null,undefinedand unions of them now check at runtime, agreeing exactly with the equivalent example type (s: string≡s: '').any/unknown/void/neverremain unconstrained because that is what they mean; an unresolvable user type still degrades to best-effort rather than erroring, which preserves TJS ⊇ JS.- Deliberately still best-effort: conditional types, mapped types, recursive
templates,
infer— the undecidable type-level metaprogramming TJS answers with a predicate function you can read, test and run. - Known gap:
string[]doesn't parse (use['']). It fails loudly, which is the acceptable interim state — a parse error tells you to fix something; the old silentanyremoved your type checking and said nothing.
- Deliberately still best-effort: conditional types, mapped types, recursive
templates,
Best-effort type degradation now teaches instead of happening silently. When an annotation can't be resolved to a runtime type it still degrades to
any(by design — TJS ⊇ JS), but the transpiler now emits a warning naming what was dropped and showing the ladder back to safety: an example (foo: 3), a sound type (foo: number), or aType … { predicate(v) { … } }. The suggestion is shown as code, per the measured finding that a remedy shown repairs 80% where the same advice as prose repairs 50% and a bare diagnostic 0%. No warning whenany/unknownwas asked for explicitly — honouringanyisn't a degradation, and warning there would train people to ignore the channel.Bare-assignment auto-
constno longer captures an all-caps alias (#22). In native tjs,B = BABYLONwas rewritten toconst B = …; whenBwas declared in an enclosing/host scope the source-level transform can't see (e.g. a/*# */example inside a module that alreadylet Bs), the injectedconstshadowed the outer binding — it bit tosijs-3d demos. A bare-identifier RHS is now treated as an alias/reassignment and left alone; the feature still fires for definition RHSs (Foo = Type(…),Foo = { … },Bar = mk()).configure()after a converted module loaded now warns instead of silently doing nothing (#23). A converted module snapshots its config when it captures the runtime (globalThis.__tjs.createRuntime()) at import, soconfigure()called after the module graph evaluated reached nothing — which made tosijs's debug/safe bundles inert. It now emits a loud one-timeconsole.warn(+ a recorderwarning) pointing at the import-order requirement, reliably distinguishing the install (bare module-levelcreateRuntime()) from a module's capture (the instance'screateRuntime()), so configuring before any module loads never warns. (Making config a live post-eval read is a deeper change to the intentional per-instance isolation — deferred to 0.13.0; a silent no-op was the worst outcome and is now gone.)==inside an inlinewasm { }block is no longer rewritten toEq(...)(L807). In native tjs the==→Eq()(andIs/IsNot→call) transforms ran before inline wasm-block extraction, so awasm { if (a == b) … }body becameEq(a, b)— which the wasm compiler can't compile, silently falling back to JS. Wasm bodies are now masked across just those two operator transforms and restored before extraction, so the wasm compiles; a followingfallback { }(real JS) still gets the normal rewrite.wasm functiondeclarations were already unaffected (extracted earlier).
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.
- Capability-boundary membrane on the VM. Every value an
effects: 'io'atom returns (httpFetch,storeGet/storeQuery/vector search,llmPredict,agentRun,runCode/transpileCode, …) is now deep-copied through a structured-clone membrane before it enters guest state, at a single choke point in the atom exec wrapper. This closes a defense-in-depth hole surfaced by an adversarial review: previously a capability could hand the guest a live host reference — an object carrying callable methods (e.g. aResponsewith.json()/.text(), or any object with a function property) — which the guest could then invoke viamethodCallto reach the host realm, or mutate while the host still held it. The membrane rejects functions, symbols, and other non-cloneable host references with aMonadicError(Capability boundary rejected the return of '<op>'), and gives clean data fresh identity so guest mutation can't alias host state. A budgeted, cycle-safe pre-walk caps the estimated payload size (membraneMaxBytesrun option, default 4 MB) and rejects oversized returns before the copy allocates, so a hostile or broken capability can't OOM the VM through the capability boundary. Contract change: custom capabilities must return structured-cloneable data — a capability that returned a liveResponsemust now return the fields the guest reads as a plain object ({ ok, status, body }); the default fetch path already normalizes to parsed body / text / data-URL and is unaffected. methodCallis now allowlisted, not blocklisted. Guest method invocations (str.toUpperCase(),arr.includes(x),d.format(), …) are restricted to an allowlist computed from the standard built-in prototypes, the curated builtin statics, and the VM's own wrapper types (Date/Set) — replacing the previous name-blocklist that admitted any method not literally named__proto__/constructor/prototype. Behind the membrane (guest values are plain data) this permits everything a guest legitimately calls and nothing else; the teeth are thatcall/apply/bindlive only onFunction.prototypeand are therefore rejected, so a leaked function reference can't be re-invoked with a chosenthis.- SSRF guard (
isBlockedUrl) now covers full private/loopback ranges. Previously only127.0.0.1(not the rest of127.0.0.0/8, so127.0.0.2passed) and the single cloud metadata IP were blocked, and IPv6 private ranges weren't checked at all. Now blocks all of loopback127/8,0/8, private10/8·172.16/12·192.168/16, link-local169.254/16(the whole cloud-metadata range), and — for IPv6 —::1/::, unique-localfc00::/7, link-localfe80::/10, and IPv4-mapped addresses (::ffff:7f00:1= 127.0.0.1) that embed a blocked IPv4. WHATWG URL normalization already collapses shorthand/decimal IPv4 (127.1,2130706433) to canonical form before the check. regexMatchReDoS hardening — length caps + a wider heuristic. The regex engine's backtracking is opaque to the fuel counter, soregexMatchnow fails closed on three fronts: a pattern-length cap (1000 chars), an input-length cap (100 000 chars, checked after coercing the value to a string), and an extended suspicious-pattern check that also catches a quantified group repeated by an unbounded outer quantifier ((a+){2,}) in addition to the existing(a+)+/(.*)+forms. Safe patterns (including bounded(abc){3}and grouped captures like(\d{3})-(\d{4})) are unaffected.- VM scope variables can't be named a forbidden property. Binding a variable named
__proto__/constructor/prototype(viavarSet/constSet/varsLet/varsImportor an atom result.as('__proto__')) is now rejected — previously such a name would mutate the scope object's own prototype chain (createChildScopeusesObject.create(state)) instead of creating a binding. No global prototype pollution was possible, but the scope corruption is now closed at the write sites, mirroring the member-access guard.
Added
- Dictionary defaults — the
TjsDictDefaultsmode (docs/dictionary-defaults.md). In native tjs,(args = {x: 0, y: 0})now has WebIDL-dictionary semantics: each member individually defaulted, partial payloads merged per member (recursively —place({pos: {x: 5}})keepspos.yand every other default), members type-checked with precise error paths,undefinedmembers treated as absent, example-nullmembers nullable-any, arrays replaced wholesale (element-checked), excess keys stripped with a once-per-site flight-recorder notice naming them, and prototype-pollution keys (__proto__/constructor/prototype) rejected outright. Complete payloads return by identity — zero allocation.- Faster than hand-rolling it, correct or not: the merge is emitted as
shape-specialized code per signature; measured 91 ns/op on a complete 8-member/3-nested
payload vs 276 for the careful hand-written spread merge and 107 for the incorrect
shallow spread — while validating every member (three-tier methodology in
experiments/dictionary-defaults/perf.bench.test.ts). - Mode-gated per PRINCIPLES.md: ON in native
.tjs(likeTjsEquals), OFF underdialect: 'js',fromTS, VM targets, andTjsCompat— JS-legal source keeps atomic JS default semantics exactly.TjsStrictenables it;TjsDictDefaultsis a standalone directive. Impure object-literal defaults ({x: mkX()}) are a compile error in native mode (compute in the body, or use a colon-form param); non-literal defaults (args = live,x = 0,list = []) are untouched. - Required-ness needs no new syntax:
:params are required (member-validated since Stage 0),=params are defaulted — mixed shapes use separate params, the platform convention. .d.tsoutput is deep-partial for dictionary params:generateDTSemitsargs?: { pos?: { x?: number; y?: number }; label?: string }so TypeScript callers can pass the partials tjs accepts. Mode-gated (the transpile result now carriestjsModes); dialect-js output keeps required members, where partials genuinely aren't valid.- Lint catches excess keys at literal call sites (
dict-default-excess-key). The runtime strips an undeclared key with a once-per-site notice, but at a literal call site (place({x, y, treshold})) it's almost always a typo — the linter now flags it statically, recursing into nested object literals (move({pos: {x, z}})→move.pos). Mode-gated onTjsDictDefaults; skips arguments carrying a spread (the spread may supply the key) and non-literal arguments; covers named functions and arrow/function expressions bound to a const.
- Faster than hand-rolling it, correct or not: the merge is emitted as
shape-specialized code per signature; measured 91 ns/op on a complete 8-member/3-nested
payload vs 276 for the careful hand-written spread merge and 107 for the incorrect
shallow spread — while validating every member (three-tier methodology in
Changed
Behavior change (native
.tjsonly): existing= {object literal}params now merge-on-partial, validate members, and strip excess keys. This is the visible face of theTjsDictDefaultsmode above, called out separately because it changes code that was already legal. Before,function f(o = {x: 0, tag: ''})treated the object as an atomic JS default with no validation; now:f({x: 5})→{x: 5, tag: ''}(was{x: 5}—tagis filled from the default),f({x: 's'})→MonadicError(was{x: 's'}— members are type-checked),f({x: 1, extra: 9})→{x: 1, tag: ''}+ a once-per-site recorder notice (was{x: 1, tag: '', extra: 9}— excess keys are stripped).
It transpiles either way, so a break is only visible at runtime. Migration: to keep the old atomic-default semantics, set
dialect: 'js', add theTjsCompatdirective, mark the functionunsafe(skips all its validation, the merge included), or use a non-object default. There is no per-mode "off" directive to disable onlyTjsDictDefaultsyet (see #7). The new excess-key lint (dict-default-excess-key) flags stray literal-call-site keys statically.Behavior change (VM embedders): capability returns must be structured-cloneable data, and guest
methodCallis allowlisted. Repeated here from Security because it breaks custom-capability consumers: a capability that returned a liveResponse(or any object carrying methods / host references) now hard-fails at the boundary withCapability boundary rejected the return of '<op>'. Migration: normalize returns to plain data (Response→{ ok, status, body }); the defaulthttpFetchalready does. Tune the size cap with themembraneMaxBytesrun option (default 4 MB).Colon-form object params now enforce their member contract (Stage 0 of dictionary defaults,
docs/dictionary-defaults.md).function f(args: {x: 0, y: 0})has always documented "an object with integer x and y," but the emitted check wastypeof args === 'object'only — partial payloads, wrong member types, and garbage members all passed while the full shape sat unused infn.__tjs.params. Members are now required and type-checked (recursively, arrays included) with precise error paths (f.args.pos.y), matchingtypeMatchesand the inlineType.checksemantics. Excess members are still ignored (the excess-key policy belongs to the forthcoming merge mode). Scope: required (colon-form) params only — the JS-legal=form keeps plain-JS semantics, and code that hasn't opted into validation is unaffected. For TS-originated code this makes the runtime contract match what TypeScript itself enforces statically (greet({name})against{name: string; age: number}is a TS compile error — and now a runtimeMonadicErrortoo).
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
tjs-lang/import-resolver(#20) — the playground's bundler-free bare-import machinery (TFS), promoted fromdemo/to a real export so doc systems (tosijs-ui's live-example) can own import resolution instead of hand-rolling it.rewriteImportsrewrites bare specifiers to a configurable same-origin prefix (/tfs/default, e.g./lib/); a service worker resolves them to a CDN — JSDelivr/+esmby default, an esm.sh allowlist for peer-dep dedup (react/react-dom),jsdelivr/·esmsh/·unpkg/·github/hints — and caches via the Cache API.- The worker ships as the raw classic-script asset
tjs-lang/import-resolver/worker(dist/import-resolver-worker.js, esbuild IIFE, 2.9KB): a service worker is origin-scoped, so consumers copy it into their public root and callregisterImportResolver({ prefix, workerUrl, scope }). Config travels to the worker as a query string on its registered script URL — available before the first intercepted fetch and durable across worker restarts — so the client rewrite and the worker's routing derive from oneResolverConfigand cannot disagree. - The routing now has exactly one implementation (
src/import-resolver/resolve.ts, pure, zero-dependency). It previously lived in three diverged copies: the demo client, the demo service worker, and a materially different reimplementation in the dev server (raw JSDelivr + its own package.json-exports resolution — a package could resolve differently through the fallback than through the worker). The dev server and the playground worker now consume the shared core; the playground's/iframe/protocol stays demo-only, composed on top. - The previously-untested routing core is now covered (
resolve.test.ts: parsing, CDN routing, hints, config round-trip, a client↔worker prefix-agreement guard, and an anti-drift smoke that parses the built worker as a classic script and checks the routing is embedded).
- The worker ships as the raw classic-script asset
Documentation
docs/dictionary-defaults.md— design spec for merge-on-partial object arguments (WebIDL-dictionary semantics for options bags), a gated native-TJS mode. Includes the measured finding that member-level object-param validation doesn't exist yet (the emitted check is typeof-only; the full shape metadata goes unused). Spike A (semantics harness + 33-case table suite) lives inexperiments/dictionary-defaults/.
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
- Exponential blowup in deep-equal/format on shared-reference object graphs (#21 —
critical; same defect class as oven-sh/bun#34178,
and since tjs ships its own
expect, Bun's fix did not cover us). A DAG built as{a: n, b: n}per level has O(depth) nodes but a 2^depth unfolded tree:format()re-serialized shared references via rawJSON.stringify— 21MB at depth 20, verified OOM at depth 28 under bun/JSC — whenever an assertion failed. It now marks revisited objects as[shared](which also fixes true cycles, whereJSON.stringifyused to throw and eat the assertion message) and hard-caps output at 16KB.deepEqualwalked all 2^depth paths on every assertion (~61s at depth 30). It now memoizes visited pairs — a revisit is assumed equal (sound: anyfalseshort-circuits to the top) — collapsing the walk to O(nodes).- Fixed in all five copies (the issue named one): the injected
expectFunction(tests.ts), the transpile-time harness's__deepEqualand__format/formatValue(js-tests.ts, which also had no depth bound), the runtimeIs(), and the emitted inlineIs. Guarded bysrc/lang/dag-safety.test.ts, calibrated so a regression fails cleanly (timeouts / message-length assertions) instead of killing the machine. Is()stays allocation-free on the hot path: pair memoization only engages past recursion depth 8 (exponential blowup requires depth; a shallow shared graph pays at most a small constant factor). Measured: flat/nested small-object compares within noise of pre-fix (~29ns/58ns per call);Is(dag(30), dag(30))went from 101s to 3.2ms.
Changed
Is()on two distinct-but-cyclic graphs now terminates and returns their structural equality (bisimulation semantics). Previously it recursed until stack overflow (RangeError). This is a behavior change to a language primitive, strictly in the direction of "gives an answer instead of crashing" — but if anything relied on the throw, note it here. Same applies to the test-harnessdeepEquals.
0.10.0 — 2026-07-16
Minor bump — additive features and fixes, no breaking changes.
Added
- Framework-free editor primitives — a new
tjs-lang/editorsentry point exportingcollectScopeSymbols(AST scope extraction, destructuring included, carriesorigin),introspectValue(live value → members), andscopeCaptureEpilogue(capture a run's top-level bindings in-run, no re-execution). Acorn-only, no CodeMirror/Monaco/Ace dependency. Closes #10 — downstream consumers (tosijs-ui) were hand-rolling a worse regex copy of the scope extractor because it wasn't exported. tjs-lang/editors/codemirrornow ships types. The editor build emits.d.tsand the export declares atypescondition, so consumers stop re-declaringAutocompleteConfigby hand (#12). The five@codemirror/*packages the CodeMirror integration imports are now declared as optionalpeerDependencies— an undeclared import resolved locally by hoisting and hard-failed in a consumer's isolated install (#16).functionMetaToJSONSchemais now exported fromtjs-lang/lang(it was only onsrc/lang/index.ts, which the subpath doesn't resolve to — the documented import failed with "Export not found"). Emitted standalone code also carries.toJSONSchema()/.strip()on its inlineType/Enum/Unionstubs when a file uses them, so a TJS type can describe itself at runtime from inside emitted.js.- Flight recorder (#17). The
__tjserror ring buffer is now a black box for the whole runtime, not just a type-error log. New API on the module, the runtime object, and everycreateRuntime()instance:record(entry),records(filter?),clearRecords(),getRecordCount(),getDroppedCount(). Records carry asource(type/wasm/vm/app/…) and aseverity(error/warning/notice), and can be filtered by either.- Reports today: type errors;
wasm{}blocks that fell back to JS or failed to instantiate (surfacing the previously-silent fallback, #15); typed arrays copied in and out on every call because they weren't allocated withwasmBuffer()— previously silent and can be slower than plain JS (#9); every VM failure — fuel exhaustion, atom timeout, capability denial. - Records once per site, never per call — a recorder that fires inside a hot loop becomes the performance problem it exists to detect.
errors()is unchanged and still returns type errors only, so the documentedclearErrors()→ run → expect-none idiom keeps working. Notices never leak into it.- Emitted modules mirror their records into the installed global runtime, so a page with several TJS modules has one flight history rather than N isolated ones. Standalone emitted code (inline fallback runtime) starts reporting as soon as a runtime is installed, even if it loaded before one existed.
- Recording never throws, never logs unbidden, and never alters control flow.
- Reports today: type errors;
- Type-system north-star design note (
docs/type-system-north-star.md): JSON-Schema +$predicateas the single source of truth for TJS types.
Changed
- LLM tests restructured into three lanes by what they prove, cutting the LLM cost of
the pre-tag gate from ~82s (two files) to ~4s while adding deterministic coverage:
- Plumbing →
test:fast. The real LM Studio HTTP client (getLLMCapability) now has deterministic coverage (src/batteries/llm-transport.test.ts, ~40ms) against an in-process fixture server. It was previously exercised only live — backwards for code we own. (batteries.test.tsdidn't cover it either: it mocks a reimplementation of predict/embed, not the real client.) - Live smoke pared.
models.integration.test.tsaudited five times (once per test); it now audits once inbeforeAlland keeps only predict + embed shape checks. ~28s → ~4s. - AJS grokkability is its own advisory lane (
bun run test:grok, behindRUN_GROK_TESTS, not in the gate). It measures whether a pinned small model (gemma-4-e2b) can write valid AJS — a load-bearing AJS premise — as a success rate over N samples vs a bar, and never fails on the rate (model variance ≠ code regression). Replacestranspiler-llm.test.ts, whosewithRetry(1-of-3)passed on a 33% success rate and couldn't tell a healthy 90% from a degraded 35%.
- Plumbing →
Fixed
- The pre-tag gate no longer fails on LM Studio flakiness. The live playground-example
LLM tests (
demo/examples.test.ts) hit a real LM Studio, which is prone to transient 400s and dropped connections while models swap under memory pressure — a bad server moment, not a code regression, could block a release tag. They now retry the live call and degrade to the existing mock (with a visible warning) on persistent failure, so the gate blocks on code, never on server health. Safe because the LLM client's request/response shape is guarded deterministically byllm-transport.test.ts— a real malformed-request regression fails there, loudly; and a broken example still fails via its transpile/VM error. The fallback logic is itself covered by deterministic tests. - The friendly "start LM Studio" error was dead under Bun.
getLLMCapabilitydetected a refused connection viae.cause?.code === 'ECONNREFUSED'(Node's shape), but Bun — our primary runtime — surfaces it ase.code === 'ConnectionRefused', so users got a raw "Unable to connect" instead of the actionable message. Now detects both. (Found by the new deterministic transport tests.) - Every file in
examples/works again, and a guardrail keeps it that way (src/examples.test.tsruns each throughtjs checkandtjs run). Five of the seven were broken; nothing caught it because nothing ran them. Beyond thetjs runand WASM bugs below, this surfaced:tjs runcould not run any file with animportor anexport. It evaluated emitted code withnew AsyncFunction(code), andimport/exportare module-only syntax — aSyntaxErrorinside a function body. It even reported the failure as a syntax error in the source, pointing at a line the user never wrote. The emitted module is now written beside the source and imported, so relative and bare imports resolve exactly as they would for the original file.tjs runexecuted your program twice. The transpile-time test harness evaluates the module to run signature tests, and thenrunevaluated it again — so every top-level side effect fired twice (console.log('hi')printedhitwice). Running a program no longer tests it; that is whattjs test/tjs checkare for (the same position the Bun plugin already took).- Generics were dead on arrival in emitted code. A generic's predicate receives its
type parameters as check functions —
Generic Box<T> { predicate(obj, T) { … T(obj.value) } }— but the inline runtime spread the raw type arguments in, soTwas the string''and calling it threwcheckT is not a function. - A runtime type's
check()accepted anything of the righttypeof. For an object example that means any object passed:User.check({ name: 'Alice' })returnedtruefor a type requiringname+age+email. It now matches the example structurally. A validator that answers "yes" to everything is worse than no validator. .toJSONSchema()/.strip()did not exist in emitted code, so a TJS type could not describe itself from inside TJS — the "types are examples that survive to runtime" claim, unmet. Both are now emitted (only for files that use them).tjs-lang/langdid not exportfunctionMetaToJSONSchema.src/lang/index.tsdid, but the subpath resolves tosrc/lang/transpiler.ts, and the two had drifted — so the documented import failed with "Export not found".
- WASM now instantiates synchronously, so an exported
wasm functioncan be called the moment its module is imported. The bootstrap was a fire-and-forgetasyncIIFE, so nothing was bound toglobalThisuntil a microtask later. An inlinewasm{} fallback{}block survives that window (it runs the JS fallback), but awasm functiondeclaration has no fallback — it calls the global directly. Soimport { dot } from 'tjs-lang/linalg'; dot(a, b, 3)threw__tjs_wasm_dot is not a function: a shipped entry point that could not be imported and used.new WebAssembly.Moduleis synchronous everywhere except a browser main thread with a >4KB module, which is now the only case that takes the async path —__tjs_wasm_ready()still resolves in both and remains what to await in a browser. - Inline
wasm{}block ids are no longer a per-file counter. Every module's first block claimedglobalThis.__tjs_wasm_0, so two modules with inline wasm blocks overwrote each other's binding — and since the emitted call site guards the wasm path on that global merely existing, module A could find module B's compiled function and call it with A's captured variables. Ids are now salted with a content hash of the module (__tjs_wasm_<hash>_<n>), which is deterministic, so the metadata cache is unaffected. Namedwasm functiondeclarations keep their exact__tjs_wasm_<name>id — that name is the cross-file composition contract. - A
wasm{}block that failed to compile could still be called. It was left in the module as a stub (correct — function indices must stay stable for other blocks'call <i>) but was also exported and bound toglobalThis, which made the call site's guard see a function and take the wasm path into a body that never compiled, invoking it with captures that don't exist in that scope. Failed blocks are no longer bound. (Reachable before this release too: the async instantiation window merely hid it from any caller that ran synchronously.) tjs runwas preprocessing every file twice — it calledpreprocess()and then handed the already-preprocessed source totranspileToJS, which preprocesses internally. The first pass consumes thewasmblocks, so the emitter never saw them, emitted no wasm bootstrap, and ran the file withwasmBufferundefined while everywasm{}block silently fell back to JS. It produced correct answers, which is why it went unnoticed.tjs runinjected a runtime prelude that collided with the emitted code. It declaredconst { Type, Generic, ... }, while emitted code inlines its ownfunction Typefallback —const Typeplusfunction Typein one scope is aSyntaxError, reported against a line number the source file did not have. Emitted code is standalone by design; the prelude is gone.- WASM module instantiation failures were swallowed by a bare
.catch(() => {})in the emitted bootstrap — the module vanished without a trace while everywasm{}block in the file silently ran its JS fallback. Now recorded as a warning. - The inline runtime core (
MonadicError+typeError+isMonadicError) was emitted from three copy-pasted source strings. A file needingcheckFnShapeand bang access withouttypeErrorwould have declaredclass MonadicErrortwice in one scope (aSyntaxErrorin the emitted output). Not reachable in practice — but held shut by coincidence, not design. Now one definition, emitted once.
Performance
- The Bun plugin (
preloaded bybunfig.toml) no longer loads the whole transpiler at startup just to register a.tjsonLoadhook that most invocations never fire. The import moved inside the callback, cuttingbunstartup in this repo from ~34ms to ~18ms (bun's cold floor is ~11ms, so the preload had made it start slower than node). This is a saving per invocation — everybun test, every CLI run. It defers the transpiler rather than adding work: a run that does import a.tjspays the same total.
Documentation
MEMORY-PROFILE.md: what transpilation actually costs under bun vs node.fromTScalls only the TypeScript parser and emitter (createSourceFile+transpileModule), nevercreateProgramor a type checker, so its memory is bounded by the largest file seen rather than by project size — a whole 36.7k-line project costs about half of whattsccosts to check it once. Also records a measured, unfixed inefficiency:transpileModuleis called once per top-level statement and per class member (89 times for one 1,930-line file), which is ~70–80% offromTSwall time and roughly 3× the cost of a single whole-file call.- CLAUDE.md now defers cross-project defaults to
../tosijs-coding-practices, recording only tjs-lang-specific divergences. - Explained why the full build is named
make, notbuild(bun buildis a Bun builtin — abuildscript would be silently shadowed). src/docs-index.test.tsenforces thatllms.txtindexes every top-level/docs/markdown file and everypackage.jsonentry point, and that its links resolve.- Added a
pre-commithook (.githooks/, enabled by thepreparescript) that checks staged files only with Prettier and ESLint, plus a repo-widebun run format:check.
0.9.1 — 2026-07-11
No breaking changes.
Added
- Inline-WASM developer feedback (from tosijs-ui adoption):
- Silent
wasm{}fallback now surfaces inresult.warnings(UI-#1). await __tjs_wasm_ready()awaitable ready signal (UI-#2).__tjs_wasm_enabledenable/disable toggle (UI-#3).f32x4min/max, comparisons, andselectfor data-dependent SIMD (UI-#6).
- Silent
- Auto-lint for
i32 / i32integer division, a WASM footgun (UI-#4), plus supported-control-flow-subset docs (UI-#5).
Changed
TjsStrictnow escalates an unverifiable predicate to a transpile error (default remains warn-only, preserving the subset invariant).
0.9.0 — 2026-07-06
Added
- Predicate verification wired into
TypeandGenericguards — verified predicates compile to fuel-bounded, DoS-safe native JS, with graceful fallback. - Per-predicate verification status on the
tjs()result (result.predicates, mirrored intoresult.warnings); exportedPredicateVerificationfromtjs-lang/lang. - ReDoS lint: the verifier rejects ReDoS-prone regexes.
- New package subpaths:
tjs-lang/css(verified-predicate CSS validators — colors, dimensions, order-flexible shorthands, recursive style structure,$predicateschema builders, property-aware validation),tjs-lang/schema(tosijs-schema pre-wired with$predicatesupport),tjs-lang/runtime, andtjs-lang/bun-plugin. $predicateJSON-Schema keyword +createPredicateEvaluator(the tosijs-schema bridge).generateDTSreachable fromtjs-lang/lang;editors/*rebuilt from source.
Fixed
.d.tsemitter: bare params are required positions, not optional (valid TS).- TS→TJS (
fromTS) no longer leaks raw TS intoType/Genericblocks.
Changed (mildly breaking)
fromTSis no longer re-exported from the main entry — import it fromtjs-lang/lang/from-ts(keeps the TypeScript compiler out of the main bundle).
0.8.7 — 2026-07-01
Fixed
- Bare-assignment auto-
constmust not touch plain JS or redeclare bindings. - A doc comment must start a line (mid-line
/*#and/**are ignored).
0.8.6 — 2026-06-30
Fixed
- TS→TJS (
fromTS) never leaks raw TS intoType/Genericblocks.
0.8.5 — 2026-06-30
Added
- Self-contained browser bundles for in-browser transpilation (
tjs-lang/browser).
Fixed
- AJS
==is footgun-free (not structural), consistent with TJS. (Recorded that the old structural==was also a fuel-bypass DoS; a futureIsatom must be fuel-metered.)
0.8.4 — 2026-06-26
Added
- First-class predicate-safety verifier (
src/lang/predicate.ts) + fuel-bounded, global-shadowed native predicate compiler. - Atom
effects: 'pure' | 'io'tag — the predicate-safety keystone. - The
$predicateJSON-Schema keyword + reference evaluator. suggest()— autocomplete completions mined from predicate clusters.- Introspection-driven, destructuring-aware playground autocomplete (scope-aware symbol model + runtime-truth member completion via an introspection bridge).
Changed
==is footgun-free (not structural) — stale docs corrected and pinned with tests.
(No 0.8.3 was tagged — the version was skipped.)
0.8.2 — 2026-06-24
Added
- Explicit source dialect (
js | tjs) + extension-based resolution; restores the TJS ⊇ AJS subset invariant. - AJS local helper functions.
- Playground surfaces inconclusive signature tests as a distinct state.
Fixed
- Run-level default timeout =
max(atom timeout) × 2(was a fixed 60s). - Generous timeouts on embedding IO atoms (
storeVectorize/storeVectorAdd). .prettierignorenarrowed soformatisn't ~50× slower.
0.8.1 — 2026-06-10
Fixed
- Broken npm main entry (index bundle) + structured-output
predictfix. predict()omits an emptytoolsarray so structured output works.- Robust SIMD speedup timing in the demo (no more "Infinityx" in Firefox).
Changed
- Migrated to ESLint 10 + typescript-eslint 8 flat config.
0.8.0 — 2026-05-14
Added
- Cross-file WASM libraries: composable
wasm functiondeclarations, transpile-time module composition (wasm-to-wasmcallresolution), and thetjs-lang/linalgSIMD stdlib subpath. Seewasm-library-plan.md.
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.8were tagged before 0.8.0, so the long0.2.0 → 0.7.6span is summarized as one entry rather than split across untagged versions.
0.7.8 — 2026-04-30
Added / Fixed
- AJS agent-loop fixes (PR #2): computed member access
arr[i]in expressions,whileerror propagation (no more infinite fuel burn on a failing body), battery user type widened for multi-turn messages. - Runtime: validate function-typed params on every call, pass-time function-shape
checks, deep array validation (
arr: [0]checks element types, not just array-ness), array-error propagation through nested params. - Inference: rich function shapes (params + return types);
functionkind for arrow/function-expression defaults. - Docs: classes and
test { }blocks render as documentation; function-extraction fixes. - Playground: SW-served iframe (all iframe fetches route through TFS), per-package /
per-import CDN routing (JSDelivr
/+esmdefault, esm.sh for React peer-dep dedup).
0.7.7 — 2026-04-27
Fixed
- Protect string literals from code transformations.
0.7.6 — 2026-04-26
The long feature-accretion phase (202 commits since 0.2.0, no intermediate tags — coarse summary):
Added
- Inline WASM: compiled at transpile time (with WAT comments), SIMD (v128/f32x4)
intrinsics,
wasmBuffer()zero-copy memory + vector search (~5× speedup), iframe instantiation. - FunctionPredicate: first-class function types, generic
FunctionPredicate<T>, structural validation,.d.tsemission. - JSON Schema generation from TJS types + function signatures;
Type.strip(); ecosystem compat tests (Zod, Effect, Radash, Superstruct, ts-pattern, Kysely). - Complete
.d.tsemission: constants, type aliases, rest params, auto-populated declaration blocks for round-tripping; DOM type handling infromTS(130+ types). - Error-history ring buffer (flight recorder) for catching silent monadic errors.
- Honest equality:
==split intoEq(honest equality) vsIs(structural),tjsEqualssymbol protocol, VM structural equality;typeof null === 'null';NaN == NaNis true;IsBounded(). TjsNoVar+const!(compile-time immutability, zero runtime cost); standalone JS output (emitted code runs without runtime setup);@tjsannotations and/* @tjs ... */mode directives in TS source.- Playground TFS service worker: dynamic module resolution, specifiers rewritten
directly to
/tfs/URLs (import maps dropped); Firebase infra (Auth, Cloud Functions withEval).
Changed
- ASI protection fixes (was breaking WASM multiline expressions); predicate reason strings in diagnostic type errors.
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
- BREAKING — VM return flattened to value-based (
202e72a):returnnow takes a value directly ({ op: 'return', value: {...} }) instead of schema-based state extraction. Removed the__result__wrapper and the nestedseqblocks around returns, sovm.run()'s result is exactly the value you return — no envelope, no intermediate wrapping. This is the VM-return-flattening change; it landed in 0.2.0 (before 0.7.8) and was only recorded in the git log until this backfill.