Changelog
This file starts at v1.0.0. Before it there was no compatibility promise to describe — neither for
the language nor for the .lyrbc format — and a changelog written under those conditions records
churn rather than change. The pre-1.0 releases carry their notes in their annotated tags.
Versions follow vMAJOR.MINOR.PATCH, as described in README. Each entry
lists what changed for someone using the toolchain: the language, the standard library, the
bytecode format, the command line and the embedding API. Compiler internals are in git log.
v4.5.0 — 2026-09-22
The project system. A compile is one of two named profiles and the default is debug; a
build script names what it builds; a project builds, runs, packs, checks and tests without
anybody naming a file; and lyric.json says what the project is called, what it depends on and
which toolchain it needs.
Added
Profiles. A compile is one of two named shapes.
debugruns no IR optimizations and keeps the source map and the debug info;releaseruns them and drops the debug info, keeping the source map.lyrc,lyrbuildandlyrtesttake--profile <name>,--releaseand--debug, andlyric run,lyric buildandlyric packhand them through. Every field of a profile stays overridable, and a field flag wins:--optimize/--no-optimize,--source-map/--no-source-map,--debug-info/--no-debug-info.LYRIC_PROFILEnames the default for a whole process, theLYRIC_JITshape; a flag beats it.HostOptions.Profilein the embedding API. A host that ships setsProfile.ReleasebesideCompile = true; left unset, a VM compiles the process default.Four diagnostic switches on
lyrc, part of no profile:--no-inline,--no-scalar-replacement,--no-devirtualizeand--no-fusion. Each takes one pass or the fused instruction forms out of an optimized build, for bisecting a finding down to the pass.lyrvm run --jit, the engineHostOptions.Compileopts into, from the command line.lyric run app.lyr --jithands it through to the runtime.lyric.jsonlearns three keys.dependenciesmaps a module path segment to the directory of another project; that project's own source root is where the segment's modules come from, itsnativeRootsanddependenciescome along, and the table is flat: one segment, one directory, for the whole program — two dependencies disagreeing are refused with both named, and your own entry pins the segment for everybody under you.nameis the project's name, a module name.toolchainis the oldest toolchain that may build the project; an older one refuses with both numbers (LYR-CLI0018) instead of compiling against a language the project was not written for. All three are optional; a file without them means what it meant.HostOptions.DependencyRoots, the same table for a host compiling a project's scripts.std.buildnames what it builds.executable(name, entry)declares a program that lands asout/<profile>/<name>.lyrbc;library(name, root)checks every module under a root and writes nothing;packed(app)declares the packed form of an executable, landing asout/<profile>/<name>with the platform's suffix. An artifact is an ordinary object: its fields are the profile's four (optimize,sourceMap,debugInfo,denyWarnings), set one by one or all at once withuse(Profile.release()), andoutputnames where it lands outright.Profile.selected(),Profile.debug()andProfile.release()are the toolchain's table, asked rather than copied.A build script takes options.
option(name, help)answers-D name=value(nullwhen not given),flag(name, help)answers-D name;lyric build --helpin a project lists them. A-Dthe script never asked about is refused with the ones it did (LYR-CLI0003).--only <name>builds one artifact; a name nobody declared is refused with the ones that were (LYR-CLI0019). Apub fn after()in the script runs once every artifact was written, and not otherwise.The verbs take no file in a project.
lyric runbuilds the default artifact — the first executable the script declares — and runs it;lyric run mktexruns that one;lyric packbuilds it in the release profile and packs it. An argument that is on disk or carries.lyr/.lyrbcis a file and goes to the compiler as before, and anything else is an artifact's name.lyric checkchecks a whole project. Without a file, or with a directory, the source root is read as one compilation and the test root as a second one that imports it — a test calling a function that was renamed is a question no per-file check can answer.lyrc checktakes the same directory.lyric test --filter <text>runs the tests whosemodule.functioncontains the text. A filter that matches nothing is an error, because a green run of nothing is worse.A project builds without a script.
lyric buildin a directory without abuild.lyrcompilesmain.lyrunder the source root intoout/<profile>/<name>.lyrbc, named after the project (lyric.json'sname, or the directory); a source root without amain.lyris a library and is checked as a whole. Neither isLYR-CLI0011, naming both ways.
Changed
lyric newwrites atests/directory with one test in it, and the app template gains a module besidemain.lyrfor that test to import: a scaffold whose shape is the shape a project keeps.lyric testruns it straight away.Build artifacts land under
out/<profile>/.lyric new's app builds toout/debug/<name>.lyrbc, andlyric build --releasetoout/release/, so the two shapes never overwrite each other. A script that wants the old layout setsoutputon the artifact.addExecutable(entry, output)is deprecated (LYR-SEM0076, kept until 5.0). It still builds, named after the output's stem; the new spelling isexecutable(name, entry)withoutputset when the derivation is not wanted. The old arguments in the new call are refused at the call with a message saying which way round they go. Its one setter,app.sourceMap(false), is the field now —app.sourceMap = false— and a script calling the method gets a compile error at the call: a field and a method cannot share the name, and the field is the one the other three options have.lyric build,lyric run,lyric check,lyric test, the REPL and the embedding API compile the debug profile by default. Before, every compile ran the optimizations and kept the names, the shape--release --debug-infoproduces now.lyric packcompiles the release profile by default. The measurement behind the change, on this interpreter (tools/Bench, Release configuration, both shapes in one session):shape release debug struct built and added per iteration ( construct_call)55 ns 141 ns for-inover a range73 ns 117 ns for-inover an array154 ns 140 ns Set.iter()loop409 ns 351 ns one call per iteration 64 ns 65 ns the standard library's test suite 3.96 s 3.45 s A backtrace from a default build names every frame, small callees included; the release profile still splices them away.
Every tool refuses an option it does not know (
LYR-CLI0003, exit code 2).lyrcandlyrvmused to overlook one in silence, solyric run app.lyr --grant nonesent--grantto the compiler, which ignored it, and the program ran with every capability.
Fixed
The standard library's own tests are checked for warnings again. The repository holds every Lyric file it tracks to complete diagnostic silence, and the sixteen files under
stdlib-tests/were exempt by accident for as long as the rule has existed — the check that routes a standard library module through an importing probe matched them on their path's prefix. Three findings fell out of it at once, all cleaned: two unused imports and an unused loop variable.lyric testcould not findlyrtestin a source build: the test runner was the one tool the driver's output directory did not get a copy of. The release archive always carried it, so the failure was a developer's, which is why nothing caught it.
v4.4.1 — 2026-09-04
Round 1 of the sweep after M36. Two findings, both older than the feature that surfaced them.
Fixed
A pattern may no longer bind the same name twice (
LYR-SEM0097).P { n, n },P { n = x, m = x }andE.B(x, x)each name one binding twice, and the second was dropped in silence:P { n = 7, m = 9 }matched againstP { n = x, m = x }answered7. The mirror image had always been refused —LYR-SEM0070for a duplicate field in an initializer — so one brace list answered the same question two ways depending on whether you were constructing or destructuring.Shadowing a name from an enclosing scope is untouched, and so is an or-pattern, whose alternatives are required to repeat the same names.
An or-pattern that binds is refused by name.
E.A(x) | E.B(x)never lowered: each alternative is a branch of its own and would have to bind on its own path, while the binding step runs once for the whole pattern. It said nothing about that, so the first use of a bound name failed asreference to 'x'— a message about a name that only looks undeclared. An or-pattern that binds nothing, which is what the form is usually for, is unaffected.
v4.4.0 — 2026-09-04
Pattern matching becomes complete. Two forms the grammar always admitted, the checker always accepted, and the lowering never built. Both are additive: every program that compiled before compiles unchanged, and the bytecode format is untouched.
Added
A
matchtakes a struct or a class apart.struct Point { x: int, y: int } fn sum(p: Point): int { return match (p) { Point { x, y } => x + y, }; }It tests nothing — the value is already of that type — so one arm covers the whole
match.Point { x = across }binds under a name of your own,_in a field's place skips the field, and a field left out is not read at all. A bound field is a copy, exactly aslet x = p.x;is.A field pattern only binds: writing a test into one (
Point { x = 3 }) is refused, and the refusal names the field it sits on.A
matchover an optional enum takes both states.fn area(s: ?Shape): int { return match (s) { null => -1, Shape.Dot => 0, Shape.Rect { w, h } => w * h, }; }Presence is settled before any variant is examined, because the tag lives inside the optional — and that changes nothing about which arm answers: the arms are tried in the order they were written, wherever the
nullarm stands among them.Before this, both forms failed at compile time with
LYR-IR0001. Nothing that worked has changed.
v4.3.6 — 2026-09-04
Round 8 of the sweep. One finding, and it is a sentence I wrote in 4.3.5 that was not true.
Fixed
Disposing a VM now frees a guest that is only sleeping. A task can be waiting in three ways. A descriptor wait ends because
Disposecloses the descriptor under it, and an interrupt wait because a disposed VM answers it — but a deadline was a plain sleep, which nothing interrupts. So "disposing gives the thread back", which guide 14 has promised since 4.3.5, held for two waits out of three: measured, a task that asked for 20 000 ms held the host's thread for 19 845 ms after its VM was gone. It waits on the VM's own signal now — 6 ms in the same measurement — and guide 14 names all three waits rather than the two that happened to work.A guest sleeping is exactly the hung script a host has no other way to stop, so this was the case the contract exists for, missing for the second round running.
v4.3.5 — 2026-09-04
Round 7 of the sweep. Three findings: one is round 6's own, and two are the ownership rule of 4.3.0 meeting tools nobody had checked against it.
Fixed
A task that re-parks on
Wait.Interruptends when the VM is disposed, instead of spinning. 4.3.4 taught a disposed VM to answer that wait the way an interrupt does, so shutdown code gets to run. A "wait for quit" loop asks again — and was handed that same answer for ever: measured at 508 896 turns in five seconds on a saturated core. That is a HOT hang where the bug before it was a quiet one. The wake is delivered once now; the next ask panics with the sentence this native already uses for a wait nothing can end, which here is true in the strongest form.A handle one test file leaks no longer reaches the next.
lyrtestpromises a fresh instance per test, and it delivers one — but a file, socket or child belongs to the VM, and the runner used ONE VM for the whole run. Measured: a test that opened a file and did not close it made a test in another file fail to write that file. One VM per test file now, which costs nothing measurable because the module is compiled once either way.Two tests in one file still share a VM, and guide 20 says so rather than leaving it to be discovered. Closing that half needs either a VM per test — about twelve times the compilation — or a way to release a VM's handles without ending it.
lyrbuilddisposes the VM its build script ran in, so a script that leaves a file open no longer holds it until the process ends.
v4.3.4 — 2026-09-04
Round 6 of the sweep, aimed at what round 5 built. Two findings in it, both mine, and one older one found while checking that round 5 had broken nothing.
Fixed
Disposing a VM now frees a guest parked on
Wait.Interrupt. It did not, and the reason it looked like it did is the point: a guest waiting on a SOCKET is woken becauseDisposecloses the socket under its select. A guest parked on the interrupt holds no descriptor, so nothing woke it — the host's thread was gone for good whileDisposereturned in a millisecond and reported success. "Disposing frees a parked guest" was true by accident, for the parks that happened to hold a handle.A disposed VM now answers that wait the way an interrupt does: every parked task wakes, the run drains, and the call returns.
Wait.Interruptis the language's "you are being asked to stop", which makes it the honest answer for a VM that is ending — and it is the documented shutdown shape, so it was the case the contract exists for that did not work.A disposed VM no longer leaves Ctrl+C swallowed for the whole process. The interrupt listening count moves on transitions, and the poll that moves it read and updated it outside the lock
Disposeuses to read the same flag. A raise landing between the two adds a listener that nothing ever subtracts, and while that count is above zero the Ctrl+C handler cancels the signal — on behalf of a VM that no longer exists. The transition is one step now, and a disposed VM does not arm at all.The wake that frees a parked guest is no longer discarded. The poll resets the interrupt event just before waiting on it, and re-reads the pending flag afterwards for exactly that reason. The disposed flag was checked only once, at the top — so a
Disposelanding between that check and the reset had its wake wiped, and the guest waited on an event nobody would set again. It is re-read after the reset now, like the pending flag beside it.v4.3.3was tagged and published nothing: the release gate ran the suite on Windows and the parked-guest test hung there. This release is that content plus the fix. No download carries4.3.3.A variant pattern over an OPTIONAL enum stops calling it a non-enum.
match (e) { null => …, E.A => … }on a?Eis refused by the lowering, which is unchanged — but it said "a match over a non-enum", sending the reader to look for a mistake in the type rather than at the missing lowering. It now names the optional and says what to do:narrow it first, then match the value. Matching an optional with a binding arm is unaffected.
v4.3.2 — 2026-09-03
Round 5 of the sweep, aimed at 4.3's ownership rebuild and at the specification's own sentences. Four findings in the toolchain and two in the documents; the heaviest is again in what the release before it built.
Fixed
Disposing a VM while its guest is still running no longer throws, and strands nothing.
A host may do this, and for one case it is the only thing it can do: a guest parked in a blocking wait is out of reach of an
ExecutionBudget, which runs on the guest's own thread. 4.3.0 moved the descriptor tables to the VM and left them plain dictionaries, so that host got two wrong answers instead. Measured over 60 rounds:DisposethrewInvalidOperationExceptionout of its own enumeration 53 times, and 55 rounds left a handle stranded past a secondDispose— two of them with no exception at all, which is a handle published into a tableDisposehad already emptied.The tables take a lock, and acquisition publishes under it: if the VM went while a file or socket was being opened, the call closes what it opened and answers its own "could not", the same refusal it gives when the VM was already disposed.
A disposed VM no longer hands the host a raw
SocketException. The descriptors underpoll's select are closed byDispose, and the select throws when they are. Nothing caught it, so a host that disposed a parked VM got aSystem.Net.Socketsfailure in the platform's own language, across an API whose every other failure is aScriptException. The wait now ends with the clock alone: the next turn finds those descriptors gone and the reads give the waiter the ordinary failure they always gave.Destructuring a struct in a
matchsays so instead of blaming a name. The form is sema-complete — field types bound, irrefutability computed — and the lowering half was never built. It did not say that: the bindings were simply not emitted, so the first USE of one failed asreference to 'x' (only parameters, locals and constants), a message about a name that only looks undeclared. It is nowLYR-IR0001: destructuring a struct or class in a match. Enum destructuring is untouched — it is the form the specification gives meaning to.The debug adapter refuses a second
launchinstead of overwriting its session and stranding the first debuggee's sockets, children and files. No client should send one; that is why the adapter must not be the one to leak when it happens.
Documentation
The specification stopped contradicting itself.
spec/02-grammar.md§3.5 said an interface's parent list "holds at most one entry" — false since 2.16, and said the other way round in §5.2 of the same document, in the conformance suite, and in the compiler's own comment. Corrected upstream (lyriclang/lyric-spec#30) and mirrored here. No rule changed and no case moved, so the suite pin stays at 4.0.The README stopped promising the REPL forgets. It said "declarations persist across entries; statements run once". What persists is the declaration, and the session re-runs everything it has accumulated on every entry — so a declaration whose initializer has an effect repeats that effect once per entry. Measured: four entries, four executions.
v4.3.1 — 2026-08-27
Round 4 of the sweep, aimed at what 4.3.0 rebuilt. Two findings, and both are 4.3.0's own.
Fixed
A disposed VM no longer opens handles it will never release.
Disposereturns early the second time, so anything a script acquired AFTER the first call was skipped — the fix for a leak had a leak in it, reachable by calling into a VM after disposing it.A disposed VM now keeps INTERPRETING but opens nothing: a file, a socket or a child process answers the ordinary "could not" of that operation, which a script already handles like any other I/O failure. Pure code still runs, because it holds nothing and stopping it would be a second and stranger failure mode.
This also replaces the sentence 4.3.0 shipped. It said calling into a disposed VM was "not defined"; undefined behaviour is not an answer a sandbox may give, and now it is defined.
The toolchain's own registry owners dispose. 4.3.0 made the registry disposable and wired only
LangVm. Four places create one directly and did not:lyric run's host, its bind-only path, the REPL and the debug adapter.The REPL was the one that mattered. It creates a registry per ENTRY and re-runs its accumulated declarations every time, so an entry that opens a file opened it again — and each evaluation stranded the previous one's handles for the whole session. The debug adapter is the same shape one level up: an editor keeps one adapter across many runs, and a debugged program's sockets and files accumulated in it. Attached rather than launched, nothing is released — the registry is the host's, exactly as the controller only detaches.
v4.3.0 — 2026-08-27
A VM owns what its scripts open. The heaviest finding of the 4.2 sweep, fixed.
Added
LangVmisIDisposable, and disposing it closes every socket, child process and file its scripts left open.using var vm = new LangVm(new HostOptions { Capabilities = Capability.FileAccess }); // leaving the block closes whatever the script still heldA script has no obligation to close what it opens: it may be stopped by an
ExecutionBudget, it may panic, or it may simply return with a handle in hand. Until now those handles were held per THREAD, so neither dropping the VM nor collecting it released them — a host that stopped an untrusted script was left holding its resources, and on Windows a file the script opened stayed locked. That was the half of the sandbox a host could not work around.Two VMs never share a descriptor, on one thread or on several. The tables belong to the VM, so which thread opened a handle no longer decides who owns it, and disposing one VM cannot touch another's. The two-VMs-on-two-threads case is a deterministic test now rather than a timing argument.
Changed
- A child process is DISOWNED, not killed, when a VM is disposed — what
std.process.closealready does by hand. Releasing a handle automatically must not mean more than releasing it by hand; kill it from the script, or hold the pid in the host.
Deliberately unchanged
- There is no finalizer. A VM that is merely dropped keeps its handles until the process
ends. A finalizer here would run against
Socket,FileStreamandProcessobjects that have their own, in an order nobody controls — a half answer that hides the case instead of closing it. Guide 14 says so plainly instead. - The interrupt SIGNAL stays process-wide — the handler, the pending signal, the listening
count and the self-pipe. Only the programmatic
interrupt()and the listening flag became per-VM, which is what they had always meant; they were per-thread standing in for it.
v4.2.3 — 2026-08-27
Round 3 of the sweep. No behaviour changed: two gaps the earlier rounds named are now pinned by tests, one sentence of the 4.2.1 notes is corrected, and the heaviest finding is recorded rather than fixed.
Documentation
- A correction to the v4.2.1 notes. They said the zero-
maxpins live in the CLI suite "because a panic ends the program the lyrtest runner is running". That is not true:lyrtestruns each test in a fresh instance, reports a panic as a FAIL with its backtrace — including one raised deep under the scheduler — and carries on. The real reason is thatstd.testhas no expect-a-panic assertion, so a panic can only ever be a red test there.
Testing
- Two questions the sweep had answered with prose are answered with pins. A host granted only
fileAccesscannot run astd.io.streamscript — the refusal comes by the time it runs, not at compile time — and anExecutionBudgetdoes reach inside a task that is waiting on a file, stopping the drain withLYR-CAP0002rather than letting it wait its way out of the budget.
Known, not fixed
- A guest's OS handles outlive the guest. A file opened through
std.io.streamand not closed stays open for the lifetime of the thread — measured surviving a budget stop, a guest panic, an ordinary return, and the VM being garbage collected.std.io.net's sockets andstd.process's children have the same shape since 4.0; a locked file merely makes it visible. A host that stops an untrusted script is therefore left holding handles the guest opened. The fix is per-instance resource ownership rather than per-thread, which is a design decision; it is recorded inSTATUS.mdunder §Still open.
v4.2.2 — 2026-08-27
Round 2 of the sweep. One diagnostic fixed, one limit documented, and two language questions recorded rather than answered — deciding them is not a sweep's job.
Fixed
A diagnostic no longer tells you to wait for a newer compiler when your program is simply wrong.
x == null,x ?? yandx ??= yon a value that is not optional reportedLYR-IR0001with the note "this compiler version cannot lower it yet". No version will lower it — there is nothing to lower — so the note sent a reader looking for a release that will never help. It now reads "a value of this type is never null".The check itself stays in the lowering, and that is correct: a generic body may write
x == nullorx ?? yover aTthat IS instantiated with an optional, and only monomorphization knows. The code staysLYR-IR0001— codes are stable identifiers, andLYR-IR0002..0010stay free by decision. Only the aside changed, and only at the six sites where the default category was false.
Documentation
- An f-string interpolation holds a scalar, and the guide now says so.
f"{p}"on a struct is refused even when the type conforms toDisplay, whileprintln(p)works through the constraint. v4.1.0 made that gap felt rather than created it, by givingInstantandDurationaDisplay; guide 2 and guide 13 now state the limit and show.show()as the way through.
Probed and clean
Round 1's own fix re-checked: the guard's panic is NOT catchable as an IoError, and a computed
max walking down to exactly one still reads. Beyond that: a LineReader walked inside a
coroutine that is itself pulled from a task — a chain in a chain, with the file read at the bottom
yielding past the inner chain to the scheduler; the same walk through map and filter; one
handle used across two separate run() calls; fifty queued writes all landing; and the time types
carried through a coroutine by value.
v4.2.1 — 2026-08-27
The sweep after 4.1.0 and 4.2.0. Ten probe groups against what the two releases added; one real finding, fixed failing-test-first, and it turned out to be older and wider than the release that surfaced it.
Fixed
A
readSomeof zero or fewer bytes no longer returns one byte. The natives clampedmaxinto[1, 1MB], so asking for nothing handed back a byte nobody asked for. A caller computing a remaining-byte count — the length-prefixed frame readerstd.bytesexists for — silently over-consumed its stream at the moment the count reached zero.Four entry points, across three modules:
std.io.net.readSomeandstd.io.net.receiveFrom,std.process.readSomeOut/readSomeErr, andstd.io.stream.readSome. Only the last is new in 4.2 — the other three have behaved this way since 4.0.The datagram case was the worst.
receiveFromcuts a payload tomaxand drops the remainder, soreceiveFrom(s, 0)kept one byte and threw the packet away.A non-positive
maxnow panics, which is the conventionstring.substringandstd.bytes.slicealready follow for an argument that has no sensible answer. There is no value to return instead: an empty array already means EOF, andnullalready means a failure with a reason behind it.maxof one is unchanged and still reads one byte.
Probed and clean
Worth not re-running: two tasks sharing one handle (300 of 300 bytes, both finish); a 200 000-byte
line and a CRLF pair split across the 64 KB refill boundary (exact); 2 000 open/close rounds with
no descriptor leak; a directory opened as a file, and a write to a read-only handle, both refused
with the twin naming why; close while a read is outstanding — the 4.0 sweep's crash class — which
answers null rather than taking the process down; reads after EOF staying empty; create
truncating; the time conformances at both ends of int, including sortList, Set and Map;
the whole stdlib suite under LYRIC_JIT=1; and a packed executable.
v4.2.0 — 2026-08-27
Open files, read and written in pieces, inside tasks — and a scheduler bug the feature's own test exposed. Additive: nothing was deprecated or removed, and no existing program changes meaning.
Added
std.io.stream— a file HANDLE, and it waits without blocking the thread.std.io.filereads a file in one call and holds the thread while it does. That is right for a config file and wrong for two things: a file too large to hold, and a program with tasks, where one blocking read stalls every other task.let f = stream.open(path)!; let reader = stream.lineReader(f); while (true) { let line = reader.next(); if (line == null) { break; } // … } stream.close(f);open,create,openAppend,readSome,write,close, each with itsOrThrowtwin per §9.0.readSomespeaks the 2.14 three truths — bytes are bytes, an EMPTY array is the end of the file,nullis a failure. The waiting happens INSIDE the module, so no signature says "waits", exactly as instd.io.netandstd.process.writeanswers the OUTCOME, not the handover, which is the difference tostd.process: a queued write cannot report a full disk at the call, and for a file that is the answer that matters.LineReader, anIterator<string>over an open file. Terminators removed, CRLF and LF alike, a last line without a terminator still a line. The adapters chain onto it, and every step yields through the read beneath it — the first place the standard library itself uses what 4.0's dynamic yield bought.The iterator protocol spends
nullon "the end", so a read failure and the end of the file look the same fromnext().failed()tells them apart after the walk.These forms need a running
std.task.run(). They yield, and a yield outside a running coroutine panics.openandclosework anywhere; outside a scheduler,std.io.file's whole-file forms are unchanged and remain the answer.It is its own module because of the capability, and that is the whole reason. Waiting means importing
std.task, which carriesosAccess, and a module requires the union of what it imports. Putting the handle besidetextwould have made every program that reads a config file demandosAccessas well — measured at 0x1 before and 0x5 after, on a program that only callstext. It is thestd.io.pathsplit of 4.0 in the other direction: a path costs no capability and moved out, a handle costs an extra one and stays out.
Fixed
A task yielding
Wait.Nowin a loop no longer starves every other kind of wait.std.task.runreached its poll only with an EMPTY run queue, and the poll is the one place a descriptor or a deadline is ever looked at. So one spinning task meant aWait.Sleepnever woke and aWait.Readablenever fired — for as long as the spinner ran.This is a bug in 4.0's scheduler, not in the new module; it affects
std.io.netandstd.processexactly as much. Nothing caught it because no test mixed a spinner with a waiter for longer than a single round. The fix polls with a zero timeout when a task is runnable and blocks only when none is, so a runnable task still runs first and a waiter is still looked at every round. Pinned intask_testswith a 5ms sleeper beside a bounded spinner — the bound is the assertion, because without the fix the loop never ends.
v4.1.0 — 2026-08-27
Instant and Duration join the library's conformances. Additive: no existing program
changes meaning, and nothing was deprecated or removed.
Added
InstantandDurationconform toEquatable,Ordered,HashableandDisplay. Every other value type in the library has carried these; the two time types never did, so comparing two instants meant unwrapping both toepochMillis(), sorting aList<Instant>was not possible at all, and neither type could be printed or used as aMapkey.if (started < finished) { // Ordered println(finished.since(started)); // Display -> "3500ms" } sortList(stamps); // the reason Ordered is on the type seen.set(finished, "done"); // HashableInstant.show()is the textiso()writes — the one textual form the type has.Duration.show()is the millisecond count with its unit (3500ms,-500ms), NOT a scaled3.5s: the type counts whole milliseconds, and a rendering that divides them promises a precision the value does not carry.compareis three comparisons rather than a subtraction of the two fields. Two instants far enough apart overflow that subtraction and it answers with the opposite sign.+and-did not arrive with them.plus,minusandsinceare methods, and an operator beside them would be a second way to write one calculation;Instant - Instantis aDurationbesides, which the homogeneous operator interfaces do not express.
v4.0.1 — 2026-08-27
The sweep after 4.0.0. Eight probes against what the major added; four real findings, all fixed failing-test-first. Nothing in the language or the format changed.
Fixed
- A read of a CLOSED socket no longer crashes the process.
netReadReadyanswered "no bytes" for a descriptor the table does not know without recording a reason, so the previous call's would-block stood andreadSomewent back to the scheduler to wait on a descriptor nothing could ever ready.pollthen selected on a list that resolved to nothing, andSocket.Selectthrew — an unhandled host exception, printed as a .NET stack trace, which is precisely what the panic doctrine exists to prevent. Now: an unresolvable descriptor is a recorded failure (so the silent form answersnulland the twin names it), andpollnames a dead descriptor READY at once — the convention an errored socket already followed, since the wake exists so the next read can tell the waiter what happened. - An import used through its SECOND overload is no longer reported as unused
(
LYR-SEM0072). Importing a name brings the whole set, but the accounting looked only at its first member — soimport std.io.net { bind, localPort, close };warned about names the file demonstrably calls, and under--deny-warningsthat breaks a build for correct code. - One VM's
interrupt()no longer reaches another VM's tasks. The pending flag was process-wide, while the socket table beside it is per-thread for exactly the opposite reason. The SIGNAL stays process-wide — SIGINT means the process — but the programmatic raise is now per VM. - A second VM no longer clears the first one's interrupt listening. The flag was assigned on every poll, so a VM without a parked task turned Ctrl+C back into a process kill while another VM was waiting for it. Listening is counted now, and the count moves on transitions.
- A sleep beyond ~292 million years no longer waits forever. The millisecond-to-microsecond
conversion overflowed into a negative number, which
Selectreads as "no deadline"; the clamp now happens before the multiplication.
v4.0.0 — 2026-08-27
The major (the basket is lyric#121, decided 2026-08-25 and delivered whole). One sentence
carries it: yield works from any call depth, and everything the standard library waits
for is built on that. Coroutines became stackful, std.task turned them into cooperative
tasks, and TCP, UDP, child processes and Ctrl+C are all "a task yields a Wait" — no async
syntax, no threads, no second mechanism. Every language rule landed spec-first
(lyric-spec#26–#28); format 3.6 → 4.0; a 4.0 runtime runs every 3.x module unchanged, a
3.x runtime rejects 4.0 modules — which is the correct answer.
Added
Stackful coroutines. A coroutine is a chain of captured frames, and
yieldworks from ANY call depth beneath a runningresume— a helper can wait so its callers do not have to, with no marker on any signature.yieldis legal in every function; its failures are PANICS, the dynamic half of the rule: no running resume, a value whose type is not the running chain's element type, a native or JIT-compiled frame between yield and resume (Lua's C-boundary rule), andresumeof a coroutine that is already running. Inside a coroutine body the static check stays. Format 3.6 → 4.0: three opcodes (mkcoro,resume,yield); a 4.0 runtime runs every 3.x module unchanged, a 3.x runtime rejects 4.0 modules.std.collections.Deque<T>— the double-ended queue:pushFront/pushBack,popFront/popBack,peekFront/peekBack,length,isEmpty,clear, all O(1) ends over one ring. A pop or peek answers?Tandnullmeans "empty" — the whole truth, so no throwing twin. Deliberately without iteration: a queue is drained, not walked. Written in Lyric, like the rest of the collections.std.task— cooperative tasks over coroutine chains, the scheduler written in Lyric. A task is aCoroutine<Wait>;Waitsays what it waits for (Now,Sleep(ms),Readable(fd)/Writable(fd)forstd.io.net's descriptors,Interruptfor Ctrl+C or the programmaticinterrupt()— while a task is parked there, the signal wakes the scheduler instead of killing the process, one interrupt wakes every parked task, and a run left with only Interrupt tasks waits for it quietly).spawnenqueues,run()drives everything to its end — round-robin when ready, blocking in the module's ONE native (poll, time and readiness in one answer) when not. Sleepers wake in deadline order even when one poll covers several overdue deadlines; ties wake in spawn order. The waiting composes through helpers with no marker on any signature — what stackful coroutines were for.spawntakes the non-throwing type: a task settles its own errors. CarriesosAccess.std.io.net— TCP for tasks, and thenetworkAccessbit reserved since 1.x finally gates something.listen/accept,connect,readSome/write,close, with opaqueListener/Sockethandles; every waiting form yields to the scheduler inside the module, so a server is straight-line code in a task. Silent forms answer whether;OrThrowtwins say why (IoErrorKindgainsConnectionRefusedandAddressInUse).readSome: bytes are bytes, an empty array is EOF,nullis a failure.std.task.poll's descriptor half is real now — select over the module's sockets, an errored socket reporting as readable. And UDP:bindaUdpSocket,sendToone datagram (whole or not at all, no delivery promise — that is UDP),receiveFromwaits and answers aPacket— payload plus sender, the address a reply'ssendTotakes. UDP has no EOF, so an empty payload is a real packet;localPortandcloseare the same names TCP uses, chosen by the handle.std.process— child processes for tasks, behindprocessAccess, the FIFTH capability bit and the first new one since 1.0: starting programs is a new power, not anosAccessrefinement, so a host that granted environment questions has not thereby agreed to it.start/startOrThrow(opaqueChild),readSomeOut/readSomeErrspeakingreadSome's three truths, queuedwriteto stdin withcloseStdinas the filter's EOF,waitfor the exit code,kill,close. Every waiting form yields to the scheduler through the child's notify descriptor — the host pumps the streams and posts a datagram per event, so a child is just another thingpollwatches.std.random.secureRandom—countbytes from the operating system's cryptographic source, the ONE non-deterministic draw in an otherwise reproducible module, and marked as such. Capability-free: entropy reaches no file, clock or network, so an embedded guest that can draw good dice costs a host nothing.Randomstays seeded-by-the-caller.std.bytes— slicing and searching overuint8[], the tooling wire protocols stand on:slicewithstring.substring's exact edge behavior,indexOf/indexOfFromanswering-1like their string namesakes. Deliberately absent:concat(a + balready joins arrays — a function would be a second name for the operator) and subsequence search (a line ends in one byte; the protocol that needs more can ask). Written in Lyric, capability-free.Instant.fromIso— the round-trip halfiso()never had:fromIso(x.iso())isx, pre-epoch instants included. Strict on purpose — exactlyYYYY-MM-DDTHH:MM:SS.mmmZ, UTC only, and a day that does not exist (February 31st) is refused rather than carried into March.nullanswers whether;fromIsoOrThrownames the field that broke (TimeError, §9.0's twin doctrine). Written in Lyric, the exact inverse ofiso()'s civil-from-days.
Moved
- The path helpers left
std.io.fileforstd.io.path—joinPath,fileName,parentDir,extension,stem,withExtension,isAbsolute, unchanged in behavior. The move is the point: a path is a string and touches no disk, so it now costs NO capability — a sandboxed guest can assemble the path it hands its host without being grantedfileAccess. The old names inio.fileare gone, not doubled; the 4.0 major pays the rename.
Removed — the clocks came due
listDiris gone, as itsuntil = "4.0"promised since 3.7: it answered an empty array to both "empty" and "unreadable".entriessaysnull,entriesOrThrowsays why. Modules compiled before 4.0 still bind the native.- The inward opaque cast outside the declaring module is an ERROR (
LYR-SEM0093), as 3.8 announced: a handle is issued, not assembled — the declaring module offers a constructor function for values rebuilt from stored numbers.
Fixed
- A module-qualified call sees the whole overload set. Since 3.0,
net.localPort(x)through a module alias bound only the FIRST function of the name and refused the call with a type mismatch, while the selective import chose correctly — overload resolution depended on the import style, against §4.3a. Surfaced by UDP'slocalPort/closesharing their TCP names; the qualified route now collects the same candidate set.
v3.9.1 — 2026-08-25
The sweep after 3.9.0 (spec: lyric-spec#25). Twelve probes; two real findings and one
recovery wart, all three fixed failing-test-first.
Fixed
- An attribute field no row can hold is refused instead of crashing the compiler
(
LYR-SEM0096).struct Odd :: [OnFunction] { n: ?int }with@Odd { n = 3 }passed the sema — the literal adapts to?int— andlyrc builddied on the writer's "not encodable" throw. Reachable since 3.2 with any non-row-shaped field (an optional, an array, a struct); found by probing the new forms. The refusal sits at the USE and names the field: the struct alone is an ordinary struct. - The
WithArg<T>promise travels the parent chain (LYR-SEM0095). An attribute reaching the conformance through an interface parent (interface Carries :: [WithArg<int>]) admitted@Retry(3)while the first-field check ran only on direct list entries — the mismatch then surfaced at every use as a plain assignment error. The check runs at the entry that reaches the conformance now, so it lands with whoever ships the attribute. @Retry(3, 4)reports the parenthesis alone. Recovery now consumes through the parenthesis; before, the leftovers reached the declaration parser, which blamed the attribute for missing its declaration (LYR-PAR0042) on top of the real message.
v3.9.0 — 2026-08-25
Attributes: one value in parentheses, and the group spelling. The twin of lyric-spec#24.
No format change — the row of a positional use is byte-identical to its braces twin, so
nothing about the bytecode, its readers or a host changes.
Added
@On(Event.Damage)— an attribute takes its one value positionally (spec §4.7). The parenthesized form carries exactly one value under the existing value rules and fills the attribute's FIRST field; every other field ends with its default. The form is a conformance, not a courtesy: the attribute struct declaresstd.core.WithArg<T>— new, beside the three placement markers — and only then is the form admitted (LYR-SEM0094);Tmust be the first field's type, checked at the attribute's declaration (LYR-SEM0095), so a mismatch lands with whoever ships the attribute rather than at every use site.@[Component, System { order = 10 }, On(Event.Damage)]— several attributes as one group, behind the new token@[(spec §1.1, §2). The group is the same list the stacked spelling declares: the parser flattens it, the rows land in written order, and the one-per-attribute rule counts across both spellings.
Changed
lyric fmtcanonicalizes attribute lists: two or more attributes on one declaration take the group shape, a single-entry group unfolds to@Name, and a group that outgrows the line breaks one entry per line. Module-header attributes keep their stacked lines.
Documentation
- Guide 15 documents both forms;
docs/Grammar.mdmirrors the amended spec chapter.
v3.8.2 — 2026-08-25
The release 3.8.1 could not be. The v3.8.1 tag points at a tree whose README still said
3.8.0, and the release gate did its job: the version test failed on both platforms and no
archives were published. This release is that tree with the README telling the truth — nothing
else changed. There is no v3.8.1 download, and the tag stays as the record of why.
v3.8.1 — 2026-08-25
Tagged, never released — the gate refused it (see v3.8.2, which carries this content).
The sweep after 3.8.0. One finding, one cause, two faces: a global initializer was checked as if it stood in no module at all.
Fixed
- The inward privilege reaches global initializers (
LYR-SEM0093).let g = 42 as Entity;at module scope compiled without the 3.8.0 warning — a global's initializer is checked before the walk that knows the current module, so the privilege check saw the "bare snippet" case and stayed quiet. The same cause let a global initializer call extension methods of modules never imported:let n = "ab".length();with no import compiled, while the same call in any function body was refused. Both positions now follow the rules the rest of the language follows — the cast warns toward its 4.0 refusal, the extension needs its import.
v3.8.0 — 2026-08-25
Round 3: the opaque clock. opaque type has promised since 1.15 that "a script cannot
forge one" — and any module that could see the alias could: 42 as Entity compiled anywhere.
This release starts the clock that closes the hole, on the warn-then-error path LYR-SEM0074
took into 2.0. No format change; no correct program changes meaning today.
Added
Making an opaque value is the declaring module's privilege (
LYR-SEM0093, spec §3.5,lyric-spec#23). The INWARD cast —value as Entity— warns outside the module that declaresEntity, and 4.0 refuses it: a handle is issued, not assembled. The OUTWARD cast (e as int) stays free everywhere — reading the number breaks no promise the alias makes, and inside the declaring module nothing changes at all.The legitimate cross-module pattern — rebuilding a handle from a stored number, the save-load shape — is what the warning's message names: the declaring module offers a constructor function (
pub fn entityFromRaw(raw: int): Entity), and issuing stays an issuer's act. The warning now, rather than the error, is what gives running code a line of releases to move.
Also in this release
- The M35 sweep ran and found nothing; its two probes with pin value land as stdlib tests
(no stale error classification across two failures;
removeOrThrowkeepsremove's success-when-never-existed semantics). Per the pipeline, the loop exits without a patch.
Documentation
- Guide 12 stops claiming a script "can neither forge one" — it could, until now — and documents the privilege, the clock, and the constructor pattern.
v3.7.0 — 2026-08-25
M35 — the reason. The gap std.io.file has carried since 2.14 — "a missing file and a
permission denied look the same" — closes, and it closes as a LIBRARY DOCTRINE rather than a
one-module patch (spec §9.0, lyric-spec#22): a value answers whether, a throw answers why.
Everything is additive — no silent form changes, no format change (bytecode stays 3.6) — and
every twin derives from the same implementation as its silent sibling, so the two can never
drift.
Added
std.io.error—IoErrorandIoErrorKind, the reason an I/O operation failed: which kind (NotFound,PermissionDenied,InvalidPath,Other— a matchable enum behind a carrier class, so the list can grow without breaking anyone'smatch), on which path, and what the platform said. Capability-free: knowing the type costs nothing, producing one takes the capability of the module whose operation failed.std.io.file— anOrThrowtwin for every silent form.textOrThrow,bytesOrThrow,linesOrThrow,sizeOrThrow,entriesOrThrowanswer the value or throw the reason; the ten operation twins (writeTextOrThrow…removeDirOrThrow) arevoidwhere the silent form answersbool, throwing where it answersfalse. The runtime classifies the last failed operation; the twins read the classification immediately — the VM is single-threaded, so nothing runs in between. A module that binds the new natives needs a 3.7 runtime (theparseFloatforward path); one that does not loads everywhere it always did.entries(path): ?string[]completes the 2.14 linelistDirmissed:nullwhen the directory cannot be read, an empty array when it is empty.listDir— which answers an empty array to both, with "askisDirectoryfirst" as its documented race — carries@Deprecated { until = "4.0" }.std.json.parseOrThrow— aJsonErrornaming the line, the column (1-based, code points), the offset and what the parser expected there. One parser answers both forms: the leaf that refuses records the FIRST place,parseignores the note,parseOrThrowreads it.std.encoding.hexDecodeOrThrow/base64DecodeOrThrow— anEncodingErrornaming the character offset and what a canonical string would have had there.std.string.utf8DecodeOrThrow— aUtf8Errornaming the byte that broke the sequence.
Deliberately unchanged
parseInt and parseFloat stay ?T alone — the reason of a five-character input is the input.
std.os stays silent throughout: an unset variable, an unanswered hostName — null IS the
whole truth there. Map.get, List and the option helpers keep absence as a value, and panics
keep programming errors.
Fixed
- An import mentioned only in a PATTERN warned as unused (
LYR-SEM0072).match (e.kind)over an imported enum — the exact shape the new convention recommends — left the import "unused", which under--deny-warningsfails the build. The 3.3.0 type-path fix, one node kind further: a pattern's path is strings, and its qualifier is recorded now.
Documentation
- Guide 10 gains "Whether, or why" — both shapes side by side,
??for the fallback andmatch (e.kind)for the reason. Guide 13 listsstd.io.error, the twin convention, and thelistDirdeprecation.
v3.6.1 — 2026-08-25
The sweep after 3.6.0, per the pipeline: adversarial probes against what the four changes make newly reachable. Two findings, both fixed with a failing test first; a second probe wave over the fixes found nothing.
Fixed
lyric fmtno longer strips the type fromcatch (_: Boom). The clause printer read a null binding name as "the whole binding is_" and dropped the annotation — a selective catch became the catch-all, and a clause behind it silently unreachable: the formatter changed what the program means. The form has PARSED since forever but never stood in any corpus, because it crashed the compiler until 3.6.0 fixed #115 — the sweep probed exactly what that fix makes reachable.let f = m.ident;is the same sentence aslet f = ident;(LYR-SEM0052). The 3.6.0 refusal covered the bare name; the qualified path went through the module member lookup and handed out the unsubstituted type anyway, ending in anLYR-IR0001about a lowering limit that is really a language rule. Both doors now say the same thing, andm.ident(5)— callee position — stays untouched.
Noted, not changed
Three pre-existing edges the sweep recorded in STATUS.md rather than fixing, because each
needs a decision before code: a transparent type alias is refused in a :: list although §3.5
says aliases serve everywhere; a fully substituted static method as a VALUE
(let f = List<int>.empty;) is an LYR-IR0001 whose message names <?>; and a duplicate
CONSTRAINT entry (<T :: [Ord, Ord]>) is silently deduplicated — the 3.6.0 list rule
deliberately covers conformance and parent lists only.
v3.6.0 — 2026-08-25
The toolchain twin of the specification round lyric-spec#21: the inference rules are written
down in spec §8.3, and the two places where the compiler silently chose or silently accepted now
say so out loud. No format change — the bytecode stays 3.6 — and no correct, order-independent
program changes meaning.
Fixed
The order of a
::list no longer decides a call (LYR-SEM0092). Type-argument inference through a conformance took the FIRST conformance in declaration order, so withTag :: [Sink<int>, Sink<string>]the callpick(t, 42)compiled — and with the entries swapped it failed, complaining about astringnobody wrote. An argument that conforms to the wanted interface several times now refuses the inference, names every conformance it found, and points at the way out — the written type argument (pick<int>(t, 42)), which already wins over inference and needs no new machinery. One conformance, a conformance repeated across declarations, and a call with written type arguments behave as before.A
::list that repeats itself is refused (LYR-SEM0078widened, the 2.15 precedent).struct S :: [Equatable<S>, Equatable<S>]compiled in silence — the list claimed two conformances where one exists. The rule reads the ENTRIES of one list: a parent written out beside its child ([Hashable<S>, Equatable<S>]) stays documenting style, and anextendrestating a conformance the type also declares stays one conformance — the two declarations may live in different modules, and an upstream adoption must not break a downstream build. The parent list of an interface follows the same rule.catch (_: Boom)compiles instead of crashing the compiler (#115). A typed catch whose binding is_— the exact form the unused-binding note recommends — died in the lowering with an internal error: the parser turns_into "no name", the checker bound nothing, and the typed lowering path needs the binding for its TYPE. The clause is bound whenever a type is written; the name is scoped only when there is one.catch (_: SomeInterface)now reaches the documented refusal (LYR-IR0001) instead of the crash.A generic function used as a value is one sentence, not a cascade (
LYR-SEM0052).let f = ident;boundfasfn(T) -> Tand exploded at the use with "cannot assign 'T' to 'int'" — aTnobody wrote, reported where nobody wrote it. Function values are monomorphic (spec §8.1); the refusal now stands at the expression, with the way out: call it, or write a lambda that calls it.
Documentation
- Guide 7 stops claiming that
v.mul(2.0)is ambiguous (it resolves by the overload rules since 3.0, exactly wherev * 2.0does) and that the arithmetic interfaces are "the one place" a type may conform twice (multi-conformance is general since 3.0). Both sentences had outlived the release that wrote them. - Guide 8 gains inference through a conformance, the several-conformances refusal and its explicit-argument way out, and the rule that a generic function is not a function value.
v3.5.0 — 2026-08-24
The standard library learns structured data: JSON in and out, and the binary-to-text encodings
beside it. Both modules are written in Lyric; no language change, no format change — with one
exception underneath, where std.string.parseFloat had to become native to make the numbers
honest.
Added
std.json— reading and writing JSON (RFC 8259). One value type carries a whole document:import std.json { JsonValue, parse, serialize };JsonValueis an enum over the six JSON shapes, with arrays asList<JsonValue>and objects asMap<string, JsonValue>— the existing collections, not a second container API. Accessors (asString,asInt,asFloat,asBool,asArray,asObject,at,field,isNull) answernulloff-shape,serializewrites compact JSON,serializePretty(value, indent)the readable form.What the contracts pin down, each with a test:
parseanswers?JsonValue— the library's absence convention, asparseIntandutf8Decodeanswer. Strict RFC 8259: no comments, no trailing commas, noNaN, no lone surrogate escapes, no raw control characters in strings. Trailing content refuses the document.- Numbers keep the distinction the text had. No fraction, no exponent →
Int, exact over the wholeintrange, so 64-bit ids survive a round trip; everything else →Float. An integer pastintfalls back toFloat— magnitude over exactness — rather than wrapping into a silently wrong value. - An integral
Floatserializes as3.0, so it comes back as the float it was; NaN and the infinities have no JSON spelling and serialize asnull, the answerJSON.stringifysettled on long ago. - Hostile nesting answers
null. Containers deeper than 128 levels refuse rather than running into the runtime's call-depth panic — input is data, and data that is not JSON comes back asnull, not as a crash. - Duplicate object keys take the last value, as in JavaScript. Object member order follows
the map: unspecified, as
Mapdocuments.
std.encoding— hex and base64 over raw bytes (RFC 4648), on the sameuint8[]thatutf8Encodeandfile.bytesspeak.hexEncode/base64Encodeare total;hexDecode/base64Decodeanswer?uint8[]and are strict — no whitespace, no missing padding, no characters outside the alphabet, and base64 refuses padding whose unused bits are not zero, so accepted input is exactly what the encoder produces.
Changed
std.string.parseFloatis native now — correctly rounded, exponent-capable. The Lyric predecessor summed fractional digits one by one, which drifts by an ulp on long fractions, and it knew no exponent notation at all (its own doc said so):parseFloat("1e-6")wasnull, andparseFloat(fromFloat(x))did not reliably answerx. Both halves matter the moment JSON numbers flow through, and correct rounding is runtime-class work — the worst cases need arbitrary-precision arithmetic, the same reasonfromFloathas always been native. The contract is unchanged where it existed ("3.5",".5","42", junk →null; no whitespace, noNaN/Infinitywords) and wider where it was missing: exponents parse, and a magnitude beyondfloatrounds to the infinities as IEEE 754 prescribes.Like every new compiler-bound native, a module compiled against this library binds
std.string.parseFloatby name: an older runtime refuses such a module at binding with that name — the designed forward path, as withco.next()in 2.2.0 — and a module that never callsparseFloatloads everywhere it always did.
Documentation
- Guide 13 gains the two module sections and their rows in the module table, and its
capabilities line stops naming
std.io.net— a module that does not exist.
v3.4.1 — 2026-08-24
A ground-up bug sweep through the pipeline. Every fix carries a failing test written first, and none changes the behaviour of a correct, documented program — what they change is that malformed input, whether hand-written source or crafted bytecode, is now refused with a message instead of crashing, corrupting, or slipping through.
Fixed
A Unicode escape past the last scalar no longer crashes the compiler.
"\u{80000000}"— eight hex digits with the high bit set — wrapped to a negative number and took the compiler down with an unhandled exception instead of reportingLYR-LEX0007. A surrogate escape (\u{D800}) passed the lexer too and was then dropped from the string. Both are refused now.An oversized array is a panic, not a process crash.
[0] * nand array concatenation with a result too large to fit an array reached the allocator as an overflowed length and aborted the whole runtime — the escape aCapability.Nonesandbox exists to prevent. They panic (LYR-VM0006) now, on the interpreter and the JIT alike.A module that under-declares its capabilities is refused. Binding trusted the capabilities a module declared: one that called
std.io.filewhile declaring nothing was handed the native and ran it, even underCapability.None. A gated native can no longer be bound without its capability declared (LYR-CAP0001), so a host loading foreign bytes is protected as the capability model promises.The bytecode loader closes four gaps in what it rejects. A function whose code ends without a terminator, a
retin a valued function or aretvalin a void one, athrownaming a type outside the table, an entry point whose signature is neither empty nor a singlestring[], a reference index out of range inside a function-type signature, and an attribute value naming an enum's payload variant or an out-of-rangechar— each passed validation before and could crash or misexecute; each is refused now.lyrvm verifycatches them without running a byte.An array type carries no length.
int[3]parsed into a type nothing could produce; the parser reportsLYR-PAR0043with the advice to build the array with[x] * n.A pattern literal must be a literal.
-yand3..yparsed with an identifier where the grammar has a literal — and the lowering evaluated it, a pattern quietly matching a runtime value. Both are refused (LYR-PAR0033).A selective import names at least one item.
import a { };was accepted as an import of nothing; it reports now.An f-string format spec runs to its matching brace.
{x:a{b}c}stopped at the first}, leaking the tail into the surrounding text; nested braces, parentheses and brackets are tracked as the grammar has always said.A supplementary char crosses the host boundary without truncation. A Lyric
charis a full Unicode scalar; a .NETcharis UTF-16. The embedding boundary silently masked an astral code point to sixteen bits. It now crosses as its code-point integer — a host asking forchargets a checked error, one asking forintgets the whole code point.
Docs
- The guide called
intan alias forint64; both are distinct types of the same width, as the specification says. A literal adapts to either, a variable crosses withas.
Added
A chain can be started from an array, a range, or a table with holes in it.
std.itergains four entrances:over(xs).map(f) // an array range(1, 4) // what '1..4' walks in a loop rangeInclusive(1, 4) // what '1..=4' walks compact(slots).map(f) // the non-null values of a '(?T)[]'The adapters have been methods since 2.17, but the three built-in forms could not start a pipeline: an array and a string have no declaration to hang an
Iterableconformance on, and a range is not a value at all —a..bis a loop head, so(a..b).iter()is not something that could exist. Naming the machinery by hand was the alternative, and a class literal with two fields reads worse than the loop it replaces.rangeInclusiveis its own function rather thanrange(low, high + 1), for the reason the two adapters behind it are separate: at the type's maximum that+ 1wraps and the chain yields nothing.compacttakes an ARRAY, not an iterator, and that is the whole design. AnIterator<?T>cannot exist —next()answers?Tand spendsnullon "the end", so an optional element would need??T, and?does not nest. Reading the slots directly is what makes the distinction available. It is lazy like every other adapter.
v3.3.0 — 2026-08-24
Fixed
A module that only CALLS through an interface no longer fails to load. A library — an interface, a class calling through it, and the implementing left to whoever imports it — produced a
.lyrbcits own loader refused:function 'kit.props.Field.take' at 70: stack depth 5 exceeds the declared maximum of 4The bytecode was correct. The loader reads a
callvirt's argument count off an Impls row, because every implementation of a slot shares its signature and the Types section carries slot names alone; with nothing implementing the interface there is no row, and the missing count was answered with "no arguments, no result". A two-argument call then looked as though it left its arguments on the stack, which is why the message named a function far from the call and moved when unrelated statements were deleted.Such a call cannot execute —
mkifaceis already refused without a row for exactly that pair, so no value of the interface can exist — and the loader now says so by stopping rather than guessing: everything up to the call is checked, and the rest of that block is not claimed to be. Where a row exists nothing changes.Iterating an array of optionals is a message, not a crash (
LYR-SEM0091).for (h in houses)over a(?House)[]— an ordinary table shape — crashed the compiler in debug (optnone of ?i64 — optionals do not nest, with a stack trace and no position) and, in release, madecheckanswer "ok" for a programbuildcould not finish.The cause is the protocol, not the container:
next()answers?Tand uses null to mean "the end", so an optional element would need??Tto be told apart from it, and?does not nest. The message says that and points at the loop that does work — over the indices. It fires the same way for an iterator of optionals handed in directly, because the reason is the same.An operator whose operand already reported stays silent.
<error>[]appeared in sentences about operators:IsErrorwas consulted where the error sat INSIDE an array or a tuple, so the type was not itself the error and the check went on to complain about it.A range outside a loop head is a message, not a crash (
LYR-SEM0090).let r = 1..5;and[1..3]threw an internal exception out of the lowering, with a stack trace and no source position. A range is a loop head and not a value — the grammar has no range expression among its primaries and there is no range type — but nothing said so where the type was INFERRED; where it was written down, the assignment had refused it all along. The grammar now names the form and its one legal position.The compiler reads back every module it writes. The emit step was taken for mechanical, so nothing checked it: a
.lyrbcits own loader refuses was found by a golden-image test that opened a window, two layers from the change. The bytes now go through the loader before a build writes them, in release as in debug — the one time it mattered, compiler and runtime were the same released build. A module that fails is a compiler bug and is reported as one, with the loader's own words.An aliased import used only as a TYPE is no longer reported as unused.
import kit.eye as look;mentioned once, aseye: look.Eye, warnedLYR-SEM0072— a build error under--deny-warnings. The qualifier of a type path has no node of its own, so neither reference table carried it; the resolver records the step-through now. A plain import used as a type and an alias used in an expression were already counted, which is what made the gap read as arbitrary.LYR-IR0001says one sentence instead of two grown together. The category was appended as a clause, and where a message ended in a subordinate one the result was "initializer omits field 'wood', which has no default is not supported by this compiler version yet". The message names the construct; the category is a note beneath it.A keyword written where a name belongs says so.
keep.resume();reportedexpected member name after '.', got Resume— the way a parser talks about a typo, which costs a reader a look at the spelling before they suspect the word. The identifier expectations now carry a note:note: 'resume' is a keyword and cannot be used as a nameOnly those:
got Returnwhere a;was expected is not a naming problem.
Added
lyric check <file> --emitanswers the question plaincheckcannot: not "does this program compile" but "does the module it produces LOAD". It emits, reads back, and writes nothing. For a project that compiles every one of its files as an entry — to read each file's attributes — that was the missing invariant.
v3.2.0 — 2026-08-23
Added
A host can read and write a module's globals:
LoadedProgram.GlobalCount,ReadGlobal(int)andWriteGlobal(int, LyrValue).For a TOOL. A program reaches its own globals with an instruction and needs none of this; a debugger's Globals scope, or an editor showing a running game what it is holding, had no way in at all. The names and types were already there —
GlobalNamesfrom the debug section,Globalsfor the types,FieldNamesfor what is inside an object — so this is the one piece that was missing.Nothing checks the type on write. A slot is a bit pattern and the program reads it as whatever its instructions expect, so writing a float where an integer stands produces a number nobody can explain rather than an error.
Module.Globalscarries the types; a tool is expected to have looked.
v3.1.0 — 2026-08-23
Added
Packed programs run on macOS (#54).
lyric packproduced a working executable on Windows and Linux and a dead one on macOS: a Mach-O declares its own extent in its load commands, and bytes beyond it make the file fail strict validation — the loader killed it before it started.The payload is now folded into the file instead of following it. The stub's signature is dropped and its load command removed, the payload takes that space,
__LINKEDIT— the segment that ends every Mach-O — is grown so its extent reaches the new end, and/usr/bin/codesign --force --sign -signs the result ad-hoc. The signature says the file has not changed since packing, which is what the loader asks; distributing to other people's machines remains a question of notarisation with your own identity.Two consequences worth knowing:
- the footer is no longer last on macOS — the signature follows it. The reader tries the end of the file first, as before, and otherwise scans backwards for the magic within a bounded window, checking each candidate whole;
- packing a macOS program needs macOS. The signature is the half only that platform can make, so packing one elsewhere is refused with that reason rather than writing a file the loader would kill. Packing ON macOS FOR another platform works as it always did.
The pack-and-run gate in both workflows now runs the packed program on macOS too, and verifies its signature — the part no test elsewhere can answer.
v3.0.2 — 2026-08-23
Fixed
The build sometimes produced a
lyric packthat could not pack (#101). Intermittent on Linux, on an unchanged tree, and the message pointed at the packer — where nothing was wrong.The driver copies the tools into its own output directory so that "beside itself" means the same during development as after an install. It learned their directories by BUILDING them a second time (
<MSBuild Targets="Build">with its own set of global properties), then listing what came out. Two things were wrong with that. A second build under different properties is a second project INSTANCE, writing the same output directory as the first. And the listing was not ordered against the stub:lyrpackpublishes its single-file stub from anAfterTargets, which in the failing run landed 169 ms after the driver had finished building — into a directory the driver had already read.The directories are now derived by convention instead of asked for, which removes the second instance; the stub publish is invoked explicitly before the listing, which orders it; and the target now fails the build when the stub did not arrive, rather than leaving it to be discovered by a packing test half a minute later.
That last part earned itself immediately: the first derived path forgot that a publish FOR a platform writes to
bin/<config>/<tfm>/<rid>/, so the release build copied nothing — and said so, in the build, instead of shipping an archive whose driver could not pack.
v3.0.1 — 2026-08-23
The first sweep after the major, and the reason the pipeline now has one: four defects, none of which a test had asked about, all in what the new features do to EACH OTHER.
Fixed
Static methods did not overload.
Id.of(7)besideId.of("seven")took the first declaration and then failed to type the argument. A static call names the type rather than standing on a value of it, and the candidate lookup read the receiver as a value type — finding nothing, so no choice was ever made.Two conformances satisfied by two overloads compiled to the wrong call. A type may conform to
Equatable<Tag>andEquatable<int>and satisfy both with twoequals; the vtable rows are built per instance, and both resolved the method by NAME, so both rows pointed at the first one. The conformance check had already decided which implementation belongs to which conformance, and the lowering now reads that answer instead of asking the name. Caught by the IR verifier as a type mismatch, which is not what it looks like — without it, a silent wrong call through an interface value.A broken argument in an overloaded call hid its own error. Choosing an overload means typing the arguments before a candidate is known, and that typing is muted so that mismatches against the wrong candidate are not reported. An argument that does not type at ALL was muted with them, and the reader got "no overload takes (
<error>)" — a consequence, with the cause swallowed. Such an argument is now re-checked out loud and nothing else is reported: the poison rule the rest of the checker follows.Two overloaded extension methods were an ambiguity.
LYR-SEM0044fired whenever two visible extensions shared a member name, which was right before overloading and wrong after it. It now fires when two of them share a name AND their parameters, which is the case nothing can tell apart.
Documentation
- Guide 13 no longer says the language has no overloading. The
minInt/clampIntfamily keeps its names — they were chosen when that was true — and the chapter says so rather than pretending the reason still holds. - Guide 19 listed
LYR-SEM0074among the warnings and promised it would become an error "in a future major version". It became one in 2.0. - Guide 3 gains where overloading works (statics and extensions included), that an import brings
the whole set, and that
@Deprecatedsits on one overload rather than on the name.
v3.0.0 — 2026-08-23
The major. Everything the 2.x line had deferred, in one release: heterogeneous arithmetic,
throwability that survives being stored, overloading, and a compiler that turns hot functions into
machine code. The .lyrbc format is unchanged at 3.6 — a 3.0.0 module loads in a 2.12 runtime
and the other way round, as far as the language allows.
Migrating from 2.x, in the order the compiler will tell you:
- every
Add<T>/Sub<T>/Mul<T>/Div<T>gains a second type argument, the RESULT:Add<Vec2, Vec2>. The compiler names the count (LYR-SEM0026); - a coroutine whose body may throw is a different TYPE now —
Coroutine<int> throws Exception— and a field or optional holding one says so; - the eleven forms whose
@Deprecatedpromised removal at 3.0 are gone:readText,readBytes,readLinesand the eight free iterator adapters. Their replacements have been there since 2.14 and 2.17.
Nothing else in a 2.x program changes meaning.
Changed — breaking
The arithmetic interfaces take two type arguments.
Add<T>is nowAdd<T, R>, likewiseSub,MulandDiv: what stands on the right of the operator, and what the operation gives back. Every existing conformance and constraint gains a second argument, and the compiler names the count (LYR-SEM0026):struct Vec2 :: [Add<Vec2, Vec2>] { … } // was: [Add<Vec2>] fn total<T :: [Add<T, T>]>(a: T, b: T): T // was: <T :: [Add<T>]>The result could not be read off the operand, or
Vec2 * 2.0would have to give afloat.Every deprecation whose promise named 3.0 is gone. The
untilfield is a commitment, and this is the release it comes due in. Eleven forms:gone use std.io.file.readTexttextstd.io.file.readBytesbytesstd.io.file.readLineslinesstd.iter.map/filter/take/skip/takeWhile/zip/chain/flatMap(free)the methods of Iterator<T>The free iterator adapters have been the methods' delegates since 2.17;
enumerateandchunksstay free, and the comment instd/iter.lyrsays why (a non-generic method changing the element type does not monomorphize).The toolchain calls itself 3.0.0. The tree carries the version the language it speaks belongs to, so the specification suite runs the right cases against it.
Added
Overloading: several functions may share a name, told apart by their parameters.
fn describe(n: int): string { … } fn describe(s: string): string { … } fn describe(a: int, b: int): string { … }The second answer this language has to "one name, several types" — generics with constraints being the first — and admitted deliberately, which is why its rules are written out in the specification (§4.3a) rather than left to be discovered.
The arguments choose, and only the arguments. Not the result type: a call has to mean one thing before anyone looks at where it goes. Where several fit, the one that needs least wins, in this order: an exact type beats a literal that adapts; a concrete parameter beats a type parameter; no defaults beats defaults; non-variadic beats variadic; and a type's own method beats an extension that fits equally well — the rule that predates overloading, last so it decides nothing a parameter could decide. Nothing fitting is
LYR-SEM0087, a tie isLYR-SEM0086, and both name every candidate they weighed.Functions, methods and extension methods overload. Interface members do not (
LYR-SEM0088): a method table holds one function per slot and finds it by name. Two functions with the SAME parameters are a redeclaration however their results differ (LYR-SEM0085). A lambda argument takes no part in choosing, having no type until a parameter gives it one.Used as a VALUE rather than called, an overloaded name is picked by the type it is wanted as —
twice(describe, 7)takes thefn(int) -> stringone — and refused where nothing says which (LYR-SEM0089).In the compiled module the overloads carry their parameters in the name (
main.show(int)besidemain.show(string)), because function names are unique there. A name declared once is unchanged, so a program without overloads compiles to the bytes it always did. A host calling by name matches on the argument count and says so when that is not enough.A type may conform to one arithmetic interface several times, and the operator picks the conformance by the type of its right operand. This is the one exception to "one conformance per interface" in the language:
struct Vec2 :: [Mul<Vec2, Vec2>] { x: float, y: float, fn mul(other: Vec2): Vec2 { … } } extend Vec2 :: [Mul<float, Vec2>] { fn mul(other: float): Vec2 { … } // scaling, its own conformance } let scaled = v * 2.0; // Mul<float, Vec2> let square = v * v; // Mul<Vec2, Vec2>Both implementations are called
mul, so the NAME never decides — which is also the limit of it: a writtenv.mul(2.0)stays ambiguous, because a call has only the name to go on. Free overloading is a separate feature and comes in v3.1.0.What this touches, since a name no longer identifies a method: conformance checking asks whether ANY visible method matches the signature the conformance demands; vtable rows are built per interface INSTANCE rather than per interface, so
Mul<float, Vec2>andMul<Vec2, Vec2>get their own row and an interface value dispatches to the one it names; and a call under a constraint goes through that row, so the constraint decides which conformance a generic body means.An untyped integer literal adapts to the conformance's operand type: with
Mul<float, Vec2>in reach,v * 2multiplies by2.0, under the same literal rule as everywhere else. An exact conformance wins over an adapted one.LYR-SEM0083— two conformances taking the same operand and disagreeing on what to call. Reported where the operator is used, not at the declaration: a conformance another module'sextendblock adds first meets the others where both are visible.
Fixed
A generic interface member called on an instance of a generic class.
ArrayIterator<int>.zip<string>(…)did not compile: the receiver took the instance path, which binds the OWNER's type parameters and not the member's own, soIterator<(T, B)>reported "this type argument is not supported" — at the interface's declaration, which is not where the program was wrong. Such a call is lifted into the interface now, as the same call on anIterator<T>value always was. It surfaced when the free adapters went:zipas a method had never been reachable in a test.Scripts can run compiled.
HostOptions.Compile = trueand each function is compiled to IL the first time it is called; a loop overfloats stops being limited by the VM and starts being limited by the CPU.var vm = new LangVm(new HostOptions { Capabilities = Capability.None, Compile = true });Off by default, and opt-in is the whole design. Compiled code has no instruction boundaries: a debugger cannot stop inside it and a budget cannot count it. So the shape is develop on the interpreter — breakpoints, stepping and hot reload all work there — and ship with this on.
It is not a decision per call. A call carrying an
ExecutionBudget, and any call under a debugger, stays interpreted even with the option set, so a host may turn it on for a whole VM and still meter the foreign code inside it. Compilation is per function and refusal is normal: what the compiler does not understand the interpreter keeps, which costs speed and never correctness.ScriptInstance.CompiledFunctionsandScriptInstance.Refusalssay what happened, the second as short phrases meant to be tallied rather than read.Compiled today: arithmetic, comparisons, branches, locals, globals, arrays, fields, optionals, interface values, object construction, string constants and comparison, and calls — to a native, or to another function that compiles. Declined: closures, exceptions, enums, recursion, and the narrow integer widths.
Ahead-of-time publishing and this are alternatives, not a pair: emitting IL at run time needs a runtime that can, and a NativeAOT build cannot. There every function is declined with
no runtime code generationand every script is interpreted — nothing else changes.A coroutine's throwability is part of its TYPE (#73).
fn gen(): Coroutine<int> throws Exceptionproduces aCoroutine<int> throws Exception, and that type keeps its demand through a field, an optional, a parameter and a return:class Runner { current: ?Coroutine<int> throws Exception = null, }The clause was checked at the call until 3.0 — the one event that runs no body and therefore cannot throw. It looked right while the coroutine stayed a local beside its
try, and the demand vanished the moment it reached a field or an optional; the exception then left the entry point and ended the program withLYR-VM0010. A coroutine held in a field is the idiom coroutines were built for.What changed for a program that compiled before:
- the call of a coroutine function demands nothing;
- every pull —
resumeandnext()alike — of a throwing coroutine is a throw site (LYR-SEM0034).next()is lenient about exhaustion, never about throwing; - a throwing coroutine no longer fits a plain
Coroutine<T>slot. The other direction is fine: a coroutine that cannot throw keeps the promise athrowsslot makes; - the type is written with a suffix wherever a type is written —
?Coroutine<int> throws Exception— andthrowsalone means anyThrowable. On anything but a coroutine it is refused (LYR-SEM0084): every other value runs at its call, where the callee's own clause already says what it throws.
Purely static — the IR, the bytecode and the runtime are untouched.
v2.17.0 — 2026-08-22
M33 — iterator chaining. The last thing this project had written down as a documented No.
xs.iter().map<int>((n: int) => n * 2).filter((n: int) => n > 4).take(2)
Added
An interface member may have type parameters of its own, and it is not dispatched: a method table holds one function per slot, and such a member is one function per instantiation. It gets no slot and is monomorphized, like a generic function. Three rules follow, and they are one fact seen from three sides (
LYR-SEM0082):- it must have a body — an abstract one would promise a dispatch nothing can perform;
- it may not be overridden — without a slot the target is chosen by the receiver's static type, so an override would be reached through the concrete type and the default through the interface: one name, two functions;
- it is reachable through a constraint AND through an interface value — which is what a chain needs, and is sound because of the first two.
The same trade Rust makes for a provided method with type parameters of its own.
A default method of a GENERIC interface is lowered at all.
interface Source<T> { fn twice(): T { … } }did not compile: three places lifted a receiver into the interface DEFINITION, which has no entry, and reported thatSourceneeds a type argument — at the interface's own declaration, which is not where the program was wrong. All three take the instance now.std.iteradapters are methods:map,filter,take,skip,takeWhile,zip,chain,flatMap. The free forms delegate to them — one implementation — and carryuntil = "3.0".
The two that stay free, and why
sum,sumFloat,minValue,maxValueask something of the ELEMENT type, and an interface cannot require that of its own parameter.enumerateandchunkschange the element type without being generic. A method like that has a slot every instance must fill, soIterator<T>would demandIterator<(int, T)>, and that one the next — the monomorphization does not terminate. It was tried; the compiler stopped answering.mapandflatMapchange the element type safely because they are generic: built per use rather than per instance.
Fixed
A non-terminating monomorphization is a diagnostic instead of a hang. The lowering now reports it with the advice that fixes it — write the method as a free function — where it previously ran until the machine gave up, or produced a module the verifier rejected by naming a type nobody wrote.
The vtable rows are built to a fixed point. A row can request a method whose body interns further types; one pass left those types without rows of their own.
v2.16.0 — 2026-08-22
An interface may declare several parents. The rule that said otherwise rested on a claim about the runtime, and the claim was wrong.
Added
Several parent interfaces:
interface Item :: [Counted, Scaled]. Conforming to the child implies conforming to every ancestor, as before.The old rule read: a parent's default method needs its own slot indexes to remain valid behind a child-typed receiver, so several parents would need thunks. They do not. A vtable is keyed by the pair (concrete type, interface), and an implementing type gets a row for every interface in the transitive closure — every ancestor keeps its own numbering and nothing is remapped. The rule went when the reasoning was measured instead of repeated: lifted experimentally, probed, then built.
What a second parent actually costs, and it is the second half of a rule that already existed (
LYR-SEM0079): two parents may not contribute the same member name from DIFFERENT declarations. One slot holds one method, and no rule picks correctly between two. Before the check they were silently merged, which is worse than either answer.A name reached twice through a diamond is neither ambiguous nor refused: both paths lead to one declaration, so there is nothing to pick, and an implementation supplies it once.
Changed
LYR-SEM0078no longer includes "more than one parent";LYR-SEM0079gains the clash. Three conformance cases replace the one that pinned the old rule — including a default on the SECOND parent running behind a child receiver, which is the case the old reasoning said would break.
v2.15.0 — 2026-08-22
Two small things off the list before v3, both of the same kind: something written in the source that the compiler looked past.
Fixed
- A
::list takes interfaces only.struct S :: [Vec2]was SKIPPED — the entry resolved to something that is not an interface, and the check moved on. The declaration claimed a conformance nothing verified and nothing reported, which is the quietest way for a mistake to survive a compiler. It isLYR-SEM0078now, on struct, class, enum andextendalike; the code already meant exactly this for an interface's parent list, so the catalogue entry is widened rather than a second number spent on the same sentence.
Added
An interface member may carry
@Deprecated. It was refused because a conformance question had no answer: do implementations inherit the clock?They do not. A use that resolves to the interface's member warns; an implementation does not — an implementation is not a use, and a conforming type MUST implement what the interface requires, so a warning there could not be acted on without breaking conformance. A call on a concrete receiver resolves to the concrete method, which is its own declaration and carries its own deprecation or none.
The member restriction is unchanged: only
@Deprecated, because the module format has no member rows. An attribute on the interface DECLARATION is still refused.Two tests that pinned the old refusal now pin the new rule. They were right to exist: that is what a pin is for — it made the change a decision instead of a drift.
v2.14.0 — 2026-08-22
The second link of the patch train. std.io.file had three conventions for "it did not work",
and one of them could not be told from success.
Added
A read answers
?T.text,bytesandlinesreplacereadText,readBytesandreadLines:before now the file is empty [][]the file is not there []nullreadBytesandreadLinesanswered an empty array to both, so no caller could tell them apart — and the documentation said to askexistsfirst, which is a race dressed up as advice.readTextwas already?string; it is renamed only so the module has one word for one idea.The three old names still work, warn, and carry
until = "3.0"— the compiler will stop the build on the day they are due, which is the mechanism 2.13 shipped for exactly this.A native may return
?T[](RegisterOptionalArrayReturning). Nothing about the VALUE needed building — an optional over a reference IS the reference — but the binder now checks three levels of type, because only the element below the optional and the array separates?string[]from?uint8[]. Without it a host could hand back bytes where the module expects lines, and the mistake would surface somewhere else entirely.
The rule, written down
Two shapes, and each says what it is for:
- a read answers
?T—nullis "could not", an empty result is an empty file; - an operation answers
bool— whether it happened. It carries no value, so nothing is lost.
A predicate (exists, isFile, isDirectory) answers bool as well, and there false is an
answer rather than a failure.
What none of them carries is a REASON: a missing file and a permission denied look the same.
That gap is real and left open deliberately — carrying reasons means an error type and a decision
about throws, which is the larger question this release deliberately did not answer.
v2.13.0 — 2026-08-22
The first link of the train that runs ahead of v3.0.0: everything that does not need a major ships first, on its own, and deprecates what it replaces. This is the mechanism those deprecations will be written with.
Added
A deprecation may name the version that removes it.
@Deprecatedcarries a second field:@Deprecated { message = "use renew", until = "3.5" } pub fn old(): int { return 1; }Building with a toolchain that has reached that version is an error (
LYR-SEM0081), as is a version the compiler cannot read. A deprecation makes two promises — use something else, and this will disappear — and only the first was ever written where anyone could see it; the second lived in a release note, which is another way of saying it lived in somebody's memory.Three decisions in it, each of which could have gone the other way:
- The named version is the one that removes it.
until = "3.5"fails AT 3.5, not one release later, so the failure lands on whoever is preparing that release. - The check sits at the declaration, not at a use. A form kept past its date is wrong whether or not anything still calls it, and dead code would never trip a use-site warning.
- It stops the build rather than warning. A warning about a removal that should already have happened is a warning nobody acts on — the same reasoning as the documentation ratchet and the corpus-silence invariant.
Not a second attribute: an
@Sunsetbeside@Deprecatedwould be a second mechanism for "this is going away", and the two would eventually disagree about a declaration carrying one and not the other. An emptyuntilis the ordinary policy — warn now, remove at the next major.- The named version is the one that removes it.
v2.12.0 — 2026-08-22
Bytecode format 3.6. The interpreter runs the same programs in a third of the instructions, and the release is about why that is the number that mattered.
The measurement it started from
An instruction on this VM costs ~6 ns — and costs it regardless of what it does. A br, an
add f64 and an and i64 lie within twenty percent of each other. That is the normal price of a
switch-dispatch stack machine, not a slow interpreter: the dispatch is the whole bill. So nothing
that makes an instruction cheaper moves a program's time, and the only lever is executing fewer.
The loop test of every while in the language was four instructions, of which three existed to
move a value onto the operand stack and off it again.
Added
Fused instructions (format 3.6). Four opcodes that read local slots and write local slots, touching the operand stack not at all:
brcmp,brcmpkcompare two slots, or a slot and a constant, and branch binll,binlkdest = a op banddest = a op k, for any binary operationA comparison in the arithmetic form writes a
bool, exactly as the unfused pair leaves one on the stack; the destination may be one of the sources, which is what makesi = i + 1a single instruction. Instruction selection is in the emitter, not in the IR — a fused instruction is a property of the encoding, and the IR is the machine-independent form every pass reads.brcmpk lt i64 l0, 10000000 -> bb2, bb3 binlk add f64 l1 = l1, 1.5 binlk add i64 l0 = l0, 1 br bb1std.randomdraws in a native round. Three shifts and three exclusive ors written in Lyric were 53 instructions, and on this VM a crossing into the host costs about what one instruction costs. The state stays in the script: one integer crosses, the next comes back. The sequence is unchanged, including the replaced zero seed, and it is pinned by a test now — the one beside it compared two generators with each other and would have held for any algorithm at all.
Measured
Same harness, same machine, same session; the baseline is a build with selection switched off and
the old nextInt. Two iteration counts differenced, so nothing that happens once is in the
figure, and the instruction counts are read from an ExecutionBudget rather than counted by hand.
| case | instr | ns/iter | instr | ns/iter | |
|---|---|---|---|---|---|
| 2.11 | 2.11 | 2.12 | 2.12 | ||
| a counting loop | 9 | 44.3 | 3 | 18.2 | 2.4× |
| an integer accumulator | 13 | 59.2 | 4 | 27.6 | 2.1× |
| a float accumulator | 13 | 68.7 | 4 | 24.8 | 2.8× |
| a masked accumulator | 15 | 88.7 | 9 | 61.2 | 1.4× |
| a native call | 14 | 82.4 | 8 | 56.9 | 1.4× |
Random.nextFloat() |
70 | 395.4 | 42 | 199.2 | 2.0× |
| an array read | 19 | 91.6 | 13 | 65.8 | 1.4× |
The time fell WITH the count rather than beside it, which is the claim the release rests on. What rose is the average price per instruction (≈4.9 → ≈6.3 ns), and that is the healthy direction: the instructions the fusion removed were the cheapest ones, the moves.
The rows that gain least say where the remaining work is: a nested expression keeps its intermediate on the stack, and neither fused form can reach a value that is not in a slot.
Format
3.6 against 3.5: four new opcodes. A producer may emit the unfused sequences instead, and one that never emits a fused form writes a module any 3.5 runtime accepts; a module that uses one needs a 3.6 runtime, named at load time.
The §Versioning rule was corrected in the same round. It read "a minor version may only add skippable sections", which 3.4 was not — the note beside that version said as much, so the document had contradicted itself since the day it shipped. It now states the compatibility the format has actually delivered: per MODULE, not per version.
v2.11.0 — 2026-08-22
Bytecode format 3.5. One new section, and the whole release is about a name that used to get lost.
Added
A module records the
opaque typea field was declared with (section 14, format 3.5). An opaque alias is a distinct type in the language and its underlying type everywhere below the checker — that is what makesx as Entityfree and lets a handle cross a native boundary unchanged. The cost showed up on the other side: a host reading the shape of an attributed class saw@Saved class Holder { hero: world.Entity, stage: int } → i64, i64and a save writer that WANTS to refuse a handle — the slot it names belongs to something else after a restart — had nothing to refuse it by. Now:
foreach (var field in module.Attributes.FieldsOf(saved.Target)!) if (field.OpaqueName is { } opaque) throw new InvalidOperationException($"{field.Name} is a '{opaque}' handle");FieldsOfreturnsAttributeFieldrecords instead of tuples;NameandTypeare unchanged,OpaqueNameis the addition and isnullfor every ordinary field. The name is the leaf through arrays and optionals — a field of typeEntity[]answersEntity, and its type still says it is an array. A transparent alias resolves through to what it names, because that one is not a type of its own.Nothing about the program changes. The field is the same
i64it was, no instruction moved, and a runtime that ignores the section is a runtime that knows what it knew before.lyric disasmshows the names where they exist:names Holder(hero: Entity, stage).
Format
- 3.5 against 3.4: one new section, OpaqueFields (id 14), skippable in the plainest way — no other section refers to it and none of it affects execution. A 3.4 reader loads a 3.5 module unchanged, and the other way round; the only difference is whether a host can tell a handle from the number it is made of. Unlike 3.4, this one asks nothing of anybody.
v2.10.1 — 2026-08-22
One fix, reported the day 2.10.0 landed: the new enum argument was unusable in the shape an SDK actually writes.
Fixed
An attribute's field default holds in every module that uses the attribute. A default is written where the attribute is DECLARED —
layout: Layout = Layout.Sharedin the SDK — and read wherever someone writes@Saved. What a name in it MEANS is settled when its own declaration is checked, and declarations were checked in discovery order, so a use could be checked before the declaration it depends on. The result:in another module @Saved { layout = save.Layout.Separate }compiled @SavedLYR-SEM0069@Saved { version = 2 }LYR-SEM0069The same code in a single file compiled, which is the tell. Declarations are checked in dependency order now — every module after the ones it imports — the same order globals have taken since 2.8, and for the same reason.
It applies to both forms a default can name: an enum variant (2.10) and a
letbound to a value (2.4).
v2.10.0 — 2026-08-22
An attribute argument may name an enum variant. The bytecode format goes 3.3 → 3.4, and this is the first change to it that an older reader cannot ignore — see below.
Added
A unit enum variant is an attribute argument.
pub enum Layout { Packed, Separate } pub struct Saved :: [OnFunction] { layout: Layout = Layout.Packed } @Saved { layout = Layout.Separate } pub fn store(): void { }A vocabulary written as an enum is checked by the TYPE SYSTEM at the use site:
Layout.Seperateis a compile error, where the same typo in a string is a row nobody ever matches — the fault class attributes closed for entry points, one field deeper. It works as a written argument, as a field default, and through aletbound to a variant, because one resolution walk answers all three.A variant with a payload is refused, and the message says why rather than repeating "must be a literal": a row holds one value per field, and a payload is values of its own.
A host reads both halves —
Textis the qualified variant (Layout.Separate),AsIntits tag. The disassembler prints the name too.
Changed
Bytecode format 3.4:
ConstValuegains one form, an attribute value of enum type written as the variant's tag. The value names no enum — the field's type does, and the enum's entry names its variants, so the name is resolved rather than stored twice.The compatibility note that matters: the Attributes section is skippable as a whole, but a reader that does read it meets a tag it has no case for. A module whose attribute rows use an enum value therefore does not load on a runtime older than this one; a 3.4 module without such a value loads unchanged. That is the same forward path
co.next()took in 2.2.0 — the module that uses the new thing is the module that needs the new runtime.LYR-SEM0066says what it now allows: a number, a string, a char, a bool, a unit enum variant, or aletbound to one.
v2.9.0 — 2026-08-22
An editor can attach to a program a host is already running. The bytecode format stays 3.3 and the language gains nothing.
Added
DapServerserves a controller you already hold. A second constructor takes aDebugControllerand the directory the scripts were compiled from; the client sendsattachwhere it would otherwise sendlaunch, and everything after that is the protocol as it was. Nothing is compiled, nothing is started — the program is running, which is the point. A game has nomainto launch, and the bug worth stopping at is rarely the one at startup.One server per controller, which answers what a host with several scripts would otherwise have to invent: which program a
setBreakpointsis about is decided by the connection it arrived on. The transport stays the host's business — the constructor takes two streams, so a socket, a pipe or anything else works without the adapter knowing about it.lyrdbgis unchanged: it launches, and an attaching adapter lives in the host's own process.DebugController.Detach()— gives the program back: breakpoints go, a parked thread is released, and the event stream ends. It is what a session needs when it ends without the program ending, and the attaching server calls it ondisconnect. Without it a game whose editor crashed would stand at its breakpoint for good, and the breakpoints nobody reads any more would park it again on the next frame. A detached controller is spent; attaching again means a new one.A launched session is unaffected: there the process is the session, and ending it is the whole answer.
v2.8.0 — 2026-08-22
Module constants initialize in dependency order, so a file compiles the same way whoever compiles it. The bytecode format stays 3.3; more programs compile than before and none fewer.
Fixed
A module-level
letmay read one from a module it imports. The order of initialization followed the order in which the entry file happened to discover the modules, so a THIRD module decided whether a SECOND one compiled:the entry imports pub let doubled = a.width * 2;inba, thenbcompiled b, thenaLYR-SEM0057only b, which importsaitselfLYR-SEM0057bon its ownLYR-SEM0057The import is the dependency statement, and the compiler had already followed it to load the file. Globals now initialize module by module in dependency order — every module after the ones it imports — and in declaration order within a module, which is the rule §4.3 of the specification now states.
Which file you compile no longer changes the answer. That is the half that made this a defect rather than a wart: a host compiling every file as its own entry — to read its attribute rows before deciding what to load — saw errors
lyric checknever showed, and a project could look green for months and still refuse to start.Unchanged: the rule inside one module, where reading a constant declared further down is still
LYR-SEM0057, and import cycles, which the resolver refuses asLYR-RES0005before the question arises. The diagnostic's message says what the order is now.
v2.7.1 — 2026-08-22
One fix, to a request the debug adapter left unanswered.
Fixed
lyrdbganswerssetExceptionBreakpoints. An editor configures exception breakpoints as part of its startup sequence — after theinitializedevent, beforeconfigurationDone— whether or not the adapter offers any filters to set. This one offers none and answered the request with an error, and a client that treats a failed configuration request as fatal never gets toconfigurationDone: the program stands there launched and never started, with no output and nothing to see.There is still nothing to set and nothing to report, so the answer carries no body. What changed is that it is an answer. Nothing about breakpoints, stepping or a running session behaves differently, and a client that never sends the request sees no change at all.
v2.7.0 — 2026-08-21
One overload, and with it the debugger reaches the shape an embedded script actually has. The bytecode format stays 3.3 and the language gains nothing.
Added
LoadedProgram.Invoke(index, debugController, args). Since 2.3 the toolchain has had breakpoints, stepping, the call stack, locals and evaluate — offered at exactly one place,RunEntry, which starts a program at itsmain. A game has nomain: its entry points are the functions a host calls once per frame throughInvoke, so none of that machinery was reachable from a host. The budget got this shape in 2.4; the debugger gets it now.The call runs on the caller's thread and a breakpoint parks it, so the commands come from another one — the same arrangement
DebugController.Startmakes with the roles swapped. A controller survives across calls: its breakpoints hold for every invocation it is passed to, and a call into a function nothing breaks on returns without stopping. NoExitedevent arrives, because nothing ended — the host simply stopped calling — so the event stream stays open while the game runs.There is deliberately no overload taking a debugger and a budget together: a session parked at a breakpoint would spend a budget on standing still.
What it does not do is keep a window drawing: the parked thread is the game's own. Drawing through a breakpoint needs an interpreter that can return to its host mid-instruction and resume later, and this one keeps its frame stack on the CLR stack. Guide 21 says so rather than leaving it to be discovered.
v2.6.0 — 2026-08-21
A compilation error tells an embedding host WHERE it happened, and a panic hands over its backtrace. Both were already known inside the toolchain and stopped at the boundary. The bytecode format stays 3.3 and the language gains nothing.
Added
EmbeddingException.Diagnosticscarries file, line and column. A compiler diagnostic holds a span, which is an index into the compilation's source manager plus offsets — and that manager belongs to the compilation, so a host caught a code and a message and nothing else. In a project of thirteen.lyrfiles that is the question "in which one?" every time, where the command line had been printingsrc/held.lyr:129:15: error[LYR-SEM0002]: …all along.The place is resolved at the throw, where the manager is still in hand.
File,LineandColumnstand on the diagnostic and on every note under it;ToString()gives the one-line form the command line prints. An error one import away carries THAT file's path — naming the entry file would be confidently wrong, which is worse than naming nothing.ScriptPanicException.Backtrace— the Lyric call stack, innermost first, each frame naming its line while the module carries a source map. The frames existed; reaching them meant namingLyricPanic, a type of the runtime assembly this API exists so a host need not reference.
Changed
- The element type of
EmbeddingException.Diagnosticsis nowScriptDiagnosticrather than the compiler'sDiagnostic.Code,SeverityandMessagekeep their names, so host code reading those compiles unchanged; what goes isSpan, which no host could resolve — the very reason for this release.
v2.5.1 — 2026-08-21
One fix, to a promise the formatter's own chapter makes.
Fixed
lyric fmtbreaks operator chains at the 100-column limit. A condition of three comparisons ran to 140 characters and stayed there, idempotently:BinaryDocheld no line opportunity at all, so a chain could not break however long it grew. The limit is documented as the tool's contract, and it now holds for expressions too.A chain that does not fit breaks before every operator of its precedence level, indented one step — the operator leads its line, as PEP 8, rustfmt and the .NET style default all settled on. The level breaks as a unit:
a && b && cis one decision, so no staircase of nested pairs. A tighter level inside stays flat while the looser one breaks.One shape is deliberately left alone: a chain with an operand that breaks by itself — a
matchorifexpression, a lambda with a block — stays as it is, because a group around it could never be flat and the chain would break for a reason that has nothing to do with the width.Formatted files can change shape: a
lyric fmt --checkin CI may report files that were clean before. Nothing in this repository's own corpus changed, which is how narrow the reformatting turned out to be.
v2.5.0 — 2026-08-21
One requirement from the embedder, and the documentation gap beside it. The bytecode format stays 3.3; nothing on the wire changes, which is the whole finding.
Fixed
A native signature may name a value struct of ANOTHER SDK module. An SDK of several files declares its
Vec2once; before this only the declaring module could name it, so a second module (engine.camera.toWorld(x, y): world.Vec2) wasLYR-IR0001— "non-primitive type in a declared signature" — whether the type was imported selectively or written module-qualified. Both forms work now, as parameter and as return.It is the same shape as the imported-alias fix of 2.2.1, one function away in the same file: the lookup was module-local and never asked the resolver. The 2.2.1 note called the restriction "documented and deliberate" — that was too generous. Its reason (an alias has nothing to flatten) explains why the alias fix did not carry structs along; it does not explain why a foreign struct may not be flattened, and there is no such reason. What crosses the wire is a LAYOUT, a layout belongs to the program rather than to the file that wrote it down, and the host is held to it by the same load-time check as before.
Unchanged: a struct in a native signature flattens to scalars and strings, so a field that is an array or an object is still refused — crossing a module line changes nothing about what the host would have to know.
Documentation
- The
…Intfamily ofstd.mathis named in the guide.clamptakes floats andclampInttakes ints, asmin/minIntandabs/absIntdo, because the language has no overloading and the library distinguishes by the type in the name. Reaching forclampwith threeints and gettingcannot assign 'int' to 'float'is the convention working as designed — but chapter 13 never said so where someone would look for it.
v2.4.0 — 2026-08-21
M31: two additive answers to what an embedder found in production. A host can bound how long
foreign code runs, and an attribute argument may name its value instead of repeating it. The
bytecode format stays 3.3, and a module compiled by 2.3 loads unchanged. Two conformance
cases activate with this release (//! since: 2.4.0); the suite stands at 90.
Added
An instruction budget for embedded code.
new ExecutionBudget(2_000_000), handed toInstantiateor to a call, stops a script that will not stop by itself:try { instance.CallVoid("onUpdate", budget, 0.016); } catch (ScriptBudgetException) { mods.Disable(instance); }A capability decides what a script may REACH;
while (true) { }needs none, and until now nothing bounded it. The budget counts instructions, not milliseconds, so the same script under the same limit stops at the same instruction on every machine — a wall clock cannot promise that, and a replay needs it.Consumedafter a call that fits is how a host arrives at a number worth setting;Reset()refills; one budget passed to several calls bounds a whole frame across several scripts, and a host function calling back in draws from the same one.Instantiatetakes one because the constant initializer runs there — a module-levellet x = spin();used to hang the load itself, before the host had called anything.The stop is
LYR-CAP0002and arrives as a panic: the script cannot catch it and nodeferruns behind it, which is what makes it worth having against code you do not trust.ScriptBudgetExceptionderives fromScriptPanicException, so a host written before this keeps catching what it caught — the separate type is there to tell "this script is broken" from "this script was still working". What it does NOT bound is host time: a native of yours that blocks for a second is charged one instruction.An attribute argument may name its value. Beside a literal, an argument may be a
letwhose initializer is one — through a chain of them, across modules, imported selectively or written module-qualified, and astatic leton a type the same way:pub let CLEARED = "tetris.cleared"; @On { event = CLEARED } pub fn onCleared(): void { }This is what lets a program publish a vocabulary — event names, kinds, versions — and have its consumers checked. Repeating the raw string was the last place where a typo produced a handler nobody ever calls, which is the fault class attributes closed for entry points.
It is not constant folding:
let LIMIT = 1 + 2;stays rejected, because the value has to stand in the source and this language folds nothing anywhere. One edge worth knowing: the named form is slightly stricter than the written one, since@A { n = 5 }adapts the literal to a narrow field whilelet N = 5;is already anint— a narrow field wantslet N: int32 = 5;.
v2.3.1 — 2026-08-21
The post-2.3.0 audit's patch wave: four measured bugs, none of them reachable from a program
that was doing anything unusual. The bytecode format stays 3.3, and the language gains
nothing — three of the four were valid programs the toolchain mishandled. Three conformance
cases activate with this release (//! since: 2.3.1); the suite stands at 88.
Fixed
- A parent interface's default method works through a child-typed value.
c.describe(), wheredescribeis a default declared on the parent ofc's interface, was an internal compiler error in Debug and a wrong receiver slot in Release — the optimizer had resolved the slot to the parent's function and called it directly with a child-typed value, which the language does not convert either. All three routes to such a default are fixed and pinned: through an interface-typed value, through a constraint naming the child, and through an element of an interface-typed array. The constraint route had a second gap of its own and reported "'C' has no 'describe'" from the lowering. - An array literal gives its elements the element type.
let xs: (?int)[] = [null, 5];was accepted by the checker and refused by the lowering ("'null' in a position without an expected type"), andlet xs: Shape[] = [Sq { }];put class references where interface values were declared — an internal error in Debug, a wrong dispatch in Release. An element is a context position like every other now. - A redirected stream carries UTF-8. On Windows the console's code page encoded redirected
output too, and
lyrreplset that shared code page unconditionally — so a tool running beside a REPL could have its output best-fit-mapped, turning an em dash into a hyphen. Every tool now writes UTF-8 into a redirected stream and reads UTF-8 from a redirected stdin; the code page is set only for a console that really is one. This is what made individual process-spawning tests fail sporadically under load since M16. - A float renders with a lowercase exponent.
println(f"{1.0e21}")wrote1E+21, where C, Go, Python, JavaScript and Rust all write1e+21.std.string.fromFloatbacks the f-string lowering, so its output is program behaviour: the shape is now specified (spec §11) and pinned — shortest round-trip, plain decimal while the decimal exponent lies in-4 .. 16and scientific outside it,Infinity/-Infinity/NaN,-0for negative zero. The disassembler, the IR printer and the debugger's variables panel render through the same one place.
v2.3.0 — 2026-08-20
M30: the toolchain learns to debug. Breakpoints, stepping, the call stack and the variables panel, in any editor that speaks the Debug Adapter Protocol — the VS Code extension wires it to F5. The bytecode format goes 3.2 → 3.3; the change is one strippable section, so a 3.2 runtime loads a 3.3 module unchanged, and every 2.x module keeps loading here.
Added
lyrdbg, the debug adapter — the eleventh binary. An editor launches it over stdio. It compiles the program itself, in the debug shape — source map, debug info, optimizations OFF, because an inlined callee has no frame to show — so what you debug is always the file in the editor; a prebuilt.lyrbclaunches as it is. Breakpoints stop before the line runs, slide from a blank line to the next mapped one, and hit on every loop pass; stepping is line-granular with the standard over/in/out rules; a pause lands before the next instruction. The panels show the real frame stack with source lines, the locals of any frame, a Globals scope, and structured values expanded — struct and class fields by name, enum variants with their payload, arrays by index, interface values as their concrete type. Hover and the debug console evaluate dotted name paths (player.pos.x); expressions are out by design — half an expression compiler answers wrongly. The program's own stdout/stderr arrive as labeled output events; a panic ends the session with its message, backtrace and exit code 101.- Bytecode format 3.3: the DebugInfo section (id 13). The names of local and global slots,
strippable like the source map, written by default; a compiler-created slot carries the empty
string and is never shown. The Names section (id 12) becomes a floor instead of a ceiling:
with debug info on it carries field names for ANY named type, so a debugger can expand an
object.
lyrc build --no-debug-infostrips both back to the 3.2 shape;lyrvm inforeportssource mapanddebug infopresence. CompilerOptions.DebugInfoandCompilerOptions.Optimizein the frontend library: the debug shape is available to any host that compiles.- Guide chapter 21 — Debugging, including the three stated limits: the global initializer runs before the debugger attaches, standard-library lines are not steppable, and optimized bytecode shows the optimizer's world.
Fixed
tools/Benchcompiles again. The 2.0 deprecation removal tookstd.collections.emptySetwith it and the harness'sset_itercase was never migrated; every run died before the first number. The case usesSet<int>.empty()now, and a case that fails to compile names itself and its diagnostics.
v2.2.1 — 2026-08-20
One fix, found by the embedder the same day: the specification's §3.5 promises that an opaque alias resolves to its underlying in a native signature — without restricting where the alias was declared. The toolchain under-delivered.
Fixed
- An imported opaque type resolves in a native signature. An SDK of several modules
declares its handle type once (
engine.worldownsTextureId) and names it in a sibling's natives (engine.assets:pub fn load(name: string): TextureId;) — that wasLYR-IR0001("non-primitive type in a declared signature") while the same alias resolved fine in the declaring module and in every ordinary signature. Both import forms work now, selective and module-qualified, in scalar and array positions; transparent aliases resolve the same way. Value structs stay module-local in native signatures — that restriction is documented and deliberate, an alias has nothing to flatten.
v2.2.0 — 2026-08-20
The A8 wave: both coroutine edges Erato's register filed after building its cutscene driver,
plus one the fix uncovered. The bytecode format stays 3.2; a module that never calls
next() loads on every 2.x runtime. Four conformance cases activate with this release
(//! since: 2.2.0); the suite stands at 85.
Added
co.next()— the safe pull. It advances a coroutine exactly likeresumeand answers?T: the value, ornullonce the body has run to its end — andnullstays the answer on every further call, whereresumekeeps its promised panic. ACoroutine<void>answersbool(did it advance?), sowhile (p.next()) { }drives it out; aCoroutine<?T>refuses the form (LYR-SEM0080) becausenullwould mean two things there — drive it withresumeand a protocol of your own. The name and shape areIterator<T>.next()'s on purpose.A query that does NOT pull (
isDone) was probed and rejected: a pull-based coroutine cannot know whether another value comes without running the body, which is why no generator API in Python, JavaScript or C# has one. The pull with an end signal is the honest form.On the wire, exhaustion is read back through the compiler-bound native
std.core.coroutineIsDone; an older runtime rejects a module that usesnext()at load time with that name in the message — the format's designed forward path.
Fixed
Coroutine<T>works as a field type. A class or struct field, and a type argument (List<Coroutine<T>>), used to be refused withLYR-IR0001("type 'Coroutine' (not a class)") while the same type worked as a parameter and a local. A driver now holds its coroutines directly instead of hiding each behind a captured closure.- A bare
return;in the middle of a coroutine body works. It compiled to a valueless return from a value-yielding body — an internal compiler error in Debug, malformed bytecode in Release, and the coroutine was never marked exhausted. It is now exactly the run-through exit: the nextresumepanics, the nextnext()answersnull. (return;in tail position, the common form, was always fine.)
v2.1.1 — 2026-08-20
One fix, found the day of 2.1.0 by the first embedder to re-pin: a compiler crash on a form the language always meant to allow.
Fixed
- A static call through a module-qualified type works.
import std.random;followed byrandom.Random.seeded(1234)crashed the compiler with an internal error out of the lowering ("type not lowerable") — the qualified type name got an unreported error type instead of a diagnostic. The form now compiles and dispatches exactly likeRandom.seeded(1234)after a selective import; a qualified type name standing alone in value position reportsLYR-SEM0052, and a qualified generic without type arguments reportsLYR-SEM0063, both as the bare name always has.
v2.1.0 — 2026-08-20
The ergonomics wave: two additive changes the audit measured the edges of. Four conformance
cases activate with this release (//! since: 2.1.0); one 2.0 case pinning the old
non-context rule retires with its sentence.
Added
@Deprecatedreaches members. A method, field orstatic letof a struct, class or enum — and an extend method — may carry@Deprecated; every use warns at the use site, exactly like a deprecated free function. Only this one attribute is admitted there: the module format has no member rows, and@Deprecatedis the attribute that needs none. Interface members stay attribute-free — deprecating an abstract member would raise conformance questions nobody has answered.- The adaptation context propagates structurally.
let xs: int64[] = [1, 2, 3];,let i: int64 = if (c) 4 else 5;and the match twin compile now: in a §3.1 context the array elements and the arms check against the context type, an unsuffixed literal adapts, and a misfit errors at the element or arm. Without a context, elements and arms unify among themselves as before. Parameter defaults thread their context too.
v2.0.1 — 2026-08-20
The first harvest of the deep audit: seven bugs measured against the now-normative
specification, fixed as one wave. Seven new conformance cases activate with this release
(//! since: 2.0.1); the suite stands at 78.
Fixed
a..=hireaches the type's maximum. The inclusive range desugared to..hi+1, which wraps at the bound —for (_ in max-2..=max)ran ZERO times. Inclusive ranges ride their own adapter with a done flag now; no arithmetic touches the bound.- Ranges over every width and signedness. A range over
uint8/int16/… produced malformed IR that only the Release verifier-skip let run; auintrange crossing 2⁶³ compared SIGNED and ran dry. Bounds now widen into a signed or unsigned carrier, and the loop variable converts back at its own width. deferbelongs to the block. A defer in a loop body registered once into the function scope: it fired once, with the last iteration's values, after the code following the loop. Loop bodies are scopes now — the defer runs at every iteration's end,continueandbreakincluded, draining exactly the scopes being left.let x = null;andlet xs = [];report instead of crashing. Both drove an unhandled internal exception through the lowering. They areLYR-SEM0010now: the initializer fixes no type, the binding needs an annotation.- An oversized literal no longer reinterprets.
let x = 9223372036854775808;compiled and held −9223372036854775808 — the magnitude's raw bits. A literal that stays at the defaultintmust fitint; the same magnitude still adapts to annotateduintpositions. - An integer literal meets a float exactly.
let g: float = 9007199254740993;(2⁵³+1) adapted with a silent rounding; "fits" is exact now, forfloatandfloat32alike. - A
throwsclause onmainis refused (LYR-SEM0021). It compiled, and a thrown exception left the program as theLYR-VM0010panic the specification calls unreachable from source.
Improved
- "cannot assign 'T' to 'T'" names its cause: when the two displays collide, the message says the types differ by identity — declared in different scopes, or a generic call instantiating itself at a larger type, which monomorphization refuses.
- A contextless generic construction reports
LYR-SEM0026once, without a follow-up "cannot assign" per field. - A new test pins the §11 contract: every native the shipped stdlib declares is bound by the
default registry (
std.buildexcepted — the build runner is its host, now said in §11).
v2.0.0 — 2026-08-20
The specification is normative from this release on: lyriclang/lyric-spec defines the
language, the toolchain implements it, and a divergence is a toolchain bug. The major cuts the
SOURCE surface — everything the 1.x deprecation clocks announced — while the bytecode format
stays 3.2 and bytecode compiled against 1.x keeps loading.
Removed — the 2.0 cut
Everything @Deprecated announced through 1.x is gone from the source surface. The registry
still binds the old NATIVE names, so bytecode compiled against 1.x keeps loading; what the cut
takes is the ability to write the old forms in new code.
- The free string forms (
length(s),trim(s),toUpper(s), … — 27 declarations): the method API (s.length(), v1.15) is the one form.concatandrepeatstay free — they back+and*— and thefromXxx/parseXxxfamilies keep their names. - The free constructors
emptyList,emptyMap,emptySet,newStringBuilder: constructors live on the types (List<T>.empty(),StringBuilder.new(), v1.12). std.math.RandomandnewRandom: randomness lives instd.random(v1.14).write/writeln:print/printlntake anyDisplayvalue (v1.14).StringBuilder.length(): it counted appended PIECES, an implementation detail; measure the built string. (It never carried@Deprecated— an attribute cannot sit on a member — its doc announced the 2.0 removal instead.)
Changed
Hashable<T>declaresEquatable<T>as its parent. A hash table cannot exist without equality; demanding the hash without the comparison was a lie by omission. A key constraint isK :: [Hashable<K>]alone —Map,Setand their helpers dropped the second constraint. A type conforming toHashablenow implementshashANDequals.LYR-SEM0074is an error. Calling a static extension method through an instance was a warning through 1.x with the message announcing this change; the clock has run out. The one 1.x→2.0 severity change, recorded as such in the specification (§12.1).docs/Grammar.mdanddocs/Bytecode.mdare checked mirrors. The canonical home of both islyriclang/lyric-spec(chapters 02 and 13); CI diffs the copies here against the specification, so drift fails the build.- A library prunes from its
pubsurface. A compile without an entry point takes thepubfunctions of the compiled modules as reachability roots — a library's surface decides its contents, and the standard library no longer ships whole inside every library or embedded script. Observable at the host boundary: a private function nothing public reaches is not in the module (Definessays so), which is why this waited for the major.
v1.16.0 — 2026-08-19
The language has a specification: lyriclang/lyric-spec holds twelve chapters and a
conformance suite, non-normative until 2.0 — and from this release on the semantics are
FROZEN. Two toolchain fixes came out of writing it. The bytecode format stays 3.2.
Fixed
{{and}}in f-strings produce a literal brace — promised by the grammar since 1.0, honored by the lexer since this release:f"json: {{\"k\": {n}}}"renders braces instead of a parse error. A lone}in the text stays ordinary text. Found by the spec audit.catch (e: Throwable)catches now. It compiled — the exception analysis treats an interface catch as handling — and then never caught: the handler carried the interface's type id and the runtime compared it against the thrown CLASS, so the exception flew past a clause the compiler had accepted. Found by the conformance suite on its first run. The explicitThrowablecatch is the catch-all now, identical tocatch (e); a catch naming any OTHER interface is refused with a diagnostic until the handler table can express a conformance test — the alternative was a clause that silently caught nothing.
v1.15.0 — 2026-08-19
The freeze prep: the design leftovers settled before the spec freezes semantics. opaque type
arrives, the string API becomes methods, iterator chaining gets its documented No — and a
latent function-id collision in the lowering falls. The bytecode format stays 3.2.
Fixed
- A function-id collision in the lowering, latent since struct-returning natives: the global initializer's slot was reserved only for DECLARED globals, but a struct-return buffer CREATES one during body lowering — and once a body also requested an extension method (the new string methods do), initializer and extension landed on the same id, and calls mis-spliced into the wrong function. The initializer draws from the shared id counter now, and the function list refuses id holes with a named internal error instead of a silent mis-splice.
Added
Strings have methods:
s.trim(),s.split(","),s.contains(x),s.length()— 26 methods viaextend stringinstd.string. The free forms warn as deprecated and go with 2.0;concatandrepeatstay free (they back+and*), and the type-directed families (fromXxx,parseXxx) keep their names. The methods come with any import of the module; a file needing no free name writesimport std.string as strings;— and an import whose extensions are used no longer counts as unused.s.length()stays a call because it costs O(n), and every method returns a NEW string.opaque type: an alias with a new IDENTITY over the same layout —pub opaque type Entity = int;. Nothing converts implicitly in either direction; the explicitasto exactly the underlying and back is the one crossing;==/!=compare within one alias; arithmetic, ordering, constraint satisfaction and f-string rendering are refused. At runtime the value IS its underlying (the cast costs nothing), and a native signature resolves the alias to the underlying — an SDK's handle crosses the host boundary as a plain number while scripts can neither forge one nor leak it. Neitheropaquenortypeis a keyword; both stay usable as identifiers.
v1.14.0 — 2026-08-19
The std polish, born from a line-by-line audit. The string module stops being quadratic, the
print family collapses to one generic concept, the collections learn the operations daily use
kept reaching for, arrays cross the native boundary as parameters for the first time — and two
new modules arrive: std.random and std.time. The bytecode format stays 3.2.
Added
print,println,eprintandeprintlntake anyDisplayvalue:println(42),println(true). The string forms keep working unchanged — a string displays as itself — andwrite/writelnwarn as deprecated; they were the same thing under a second name. Bytecode compiled before this release keeps running.Collections round out:
Listgainsinsert,removeAt,first,last,reverseandswap;MapgainsgetOr,clearandentries— key and value in ONE walk, without the second probe per key;Setgainsclearand isIterable, sofor (v in set)walks it directly.clearon all three keeps the backing for reuse; the values are released all the same.std.itergainsflatMap,chunks,reduceandfirst.Arrays cross the native boundary as parameters — the bytecode format always allowed it (§3 type grammar; format stays 3.2), the registry just never used it. On top of it:
std.io.file.writeBytesandappendBytes(the write side readBytes was waiting for),std.string.utf8Encodeandutf8Decode— the strict bridge: invalid bytes answernullinstead of the U+FFFD replacementreadTextdocuments — andfromCharsbecame one native call instead of one string per character.std.random: the generator moved out ofstd.math— randomness is not arithmetic — and gained what it was missing there:shuffle(Fisher–Yates over aList),choiceandnextGaussian. Deterministic, seeded by the caller, no capability. Thestd.math.Randomtwin stays one release as a deprecated migration path.std.time:InstantandDurationas value structs over epoch milliseconds —b.since(a),a.plus(d), andiso()rendering UTC ISO 8601 with floor semantics, so an instant before 1970 lands in the right day. Gated byosAccess, the same bit asstd.os: reading the clock is a question to the environment, and a new bit would be a contract change. The subtraction is a named method, not an operator —Instant - Instantyields a Duration, and the operator interfaces are homogeneous by the v1.13 decision.
Fixed
std.stringstops being quadratic.StringBuilder.buildandjoinfolded left and copied the whole result once per piece — both are one native join now;replacemoves untouched stretches as whole substrings; the searches, parsers and trims index a character array instead of calling O(n)charAtper position. Same results, different cost curve.Audit rests:
std.fmtloses its German locals,std.io.filea torn doc fragment, andstd.io.consolesorts a native above the "written in Lyric" divider it contradicted.
v1.13.0 — 2026-08-19
The language gaps close. Interface inheritance arrives — one parent, implied through the whole
chain — compound assignment reaches through the operator interfaces, block lambdas infer their
return type, and std.core becomes the import-free root of the library. One question got its
documented No: heterogeneous operator arithmetic. The bytecode format stays 3.2.
Added
Interface inheritance: an interface may declare one parent —
interface Labeled :: [Named]. Conforming to the child implies conforming to the whole chain: implementing types provide the chain's abstract members (a missing one names the implying interface), inherit its default methods, satisfy parent constraints, and carry into parent-typed interface values. A value of the child's interface type answers the parent's members too. The rules: at most one parent (several requirements side by side are constraints:<T :: [A, B]>), only interfaces, no cycles (LYR-SEM0078), and no redeclaring a chain member (LYR-SEM0079) — an inherited member keeps its declaring interface, so the same call cannot dispatch two ways. What a chain does NOT add: a child interface value does not convert to the parent's type — conformance is implied for the implementing type; take the concrete value through the parent directly.std.coredoes not adoptHashable :: [Equatable]yet: changing what every conforming type must implement is a breaking cut reserved for 2.0.Compound assignment reaches through the operator interfaces:
v += won a type conforming toAdd<T>now compiles for variable targets (locals, captured variables) instead of reportingLYR-SEM0003. Field and element targets stay written out — the desugaring would evaluate the object or the index twice, and that stays visible in source.Block lambdas infer their return type:
(x: int) => { return x * 2; }needs neither an annotation nor a context anymore — the type comes from the body'sreturnstatements, unified like match arms (return null;widens to the optional). This also closes the open-generic case:apply(5, (n) => { … })bindsUfrom the block. A non-void inferred lambda still needs return coverage, and disagreeing returns are one error at the lambda.
Changed
LYR-PAR0039retired:interface B :: [A]parses since this release; everything the parent list may not be is a semantic message now, not a parse error.newStringBuilderwarns as deprecated — the piece v1.12 had to leave out.std.coreimports nothing anymore: its extensions use private duplicates of six string natives (fromIntthroughcharAt; the registry binds both names to the same host function), which makesstd.corethe library's root — andimport std.core { Deprecated }insidestd.stringlegal. Public API is unchanged; existing bytecode keeps running.
Fixed
@Deprecatedkeeps its promise: it emits no metadata row and roots nothing. Previously a non-generic deprecated function survived dead-code pruning in every importing program — dead code carried along exactly because it was marked for removal.
v1.12.0 — 2026-08-19
The standard library grows up. Every public item is documented, constructors live on the types, the first real deprecations start their clock, and the library tests itself — in Lyric. Two compiler fixes came out of the work. The bytecode format stays 3.2.
Added
Constructors on the types:
List<T>.empty(),Map<K, V>.empty(),Set<T>.empty(),StringBuilder.new()andRandom.seeded(seed). The free functionsemptyList,emptyMap,emptySetandnewRandomstill work and warn as deprecated — the first real uses of@Deprecated, and their removal lands with the next major. (newStringBuilderpoints at its successor in the documentation; its attribute waits onstd.corevisibility insidestd.string, where the import would be a cycle.)std.io.file.readBytes: the whole content as raw bytes, undecoded — the answerreadTextcannot give, because its UTF-8 decoding turns invalid bytes into U+FFFD. Writing bytes is not there yet: an array has never crossed the native boundary as a parameter, and that machinery is a change of its own.The standard library tests itself.
stdlib-tests/holds behavioral tests written in Lyric and run bylyric test; the build runs them, and both repository invariants — formatted, and compiling in silence — cover the directory.Every public item of the standard library is documented — hover and the reference site answer everywhere — and a test pins completeness, not a count: new API without documentation is a red build.
A bare import that shadows a builtin type warns (
LYR-SEM0077):import std.string;binds the namestring, and the annotation then names the module. The warning says the way out; using the shadowed name as a type is now a proper error naming the trap — previously it CRASHED the compiler on the local-annotation path.
Changed
@Deprecatedmay sit on generic declarations — the one exception to the no-attributes-on-generics rule, because its consumer is the compiler and no metadata row is involved; none is emitted there.A static call on a generic instance substitutes the caller's type parameter:
List<T>.empty()inside your own generic function works now; previously the lowering met the bareTand failed with an internal error.Two parameters of
std.string.replace/replaceFirstare namedreplacement(signature help used to show a German name); every remaining German local and section header in the library is English now.
v1.11.0 — 2026-08-19
Attributes stop being decoration. One is now read by the compiler — @Deprecated — and one by
a new tool: lyric test runs every function marked @Test. The bytecode format stays 3.2,
and no existing program changes meaning.
Added
@Deprecated, the first attribute the compiler reads. Fromstd.core, on a function, a type or a module: every use warns (LYR-SEM0076) at the use site, the note points at the attribute, andmessagesays what to use instead. Resolved by IDENTITY — a struct someone else namesDeprecateddeprecates nothing. Uses inside anything itself deprecated are exempt, so a deprecated function may keep calling its deprecated siblings; a deprecated module warns at the imports that pull it in. It changes diagnostics and nothing else — the same module compiles either way. Editors strike deprecated uses through.With this the compiler-read attribute set becomes part of the language contract:
@Deprecatedis in it, everything else stays inert.lyric test— tests, the Go shape. Tests live undertests/(or thetestRootyourlyric.jsonnames) and only the test runner ever compiles them; production builds never see a test file. A test is a top-level function marked@Testfrom the newstd.test, fails by panicking, and runs in a fresh instance — module state cannot leak between tests.lyric teststd.testships the marker and the assertions:assertTrue, andassertEqover[Equatable<T>, Display], naming both values when they differ. The report is plain text, one line per test; the exit code is0when everything passed and1otherwise. Notests/directory means no tests and exits 0; a testRoot named explicitly and missing is an error. Guide chapter 20 covers it.The runner is
lyrtest, the tenth binary, and it drives the compiled module through the embedding API — the attribute rows for discovery, a call handle per test: the same machinery a host uses, now with a consumer that is not a test of it.HostOptions.SourceRootin the embedding API: a host may compile a file whose imports resolve against a directory other than the file's own — the test runner compilingtests/againstsrc/is the case that added it.
Not in this release
- Test filters, parallel execution, expectPanic, fixtures and setup/teardown, JSON output, editor test integration — deliberately; each is an idea issue, none blocks running tests.
- Suppressing a deprecation warning in code: the mechanism would be another compiler-read
attribute, and the set grows by decision.
--deny-warningsstill means what it says.
v1.10.0 — 2026-08-19
The compiler learns to speak below "error". Four severities, warnings that matter, notes that point at places, and a CI gate. The language, the bytecode format (3.2) and the embedding API are unchanged; every program that compiled still compiles — some now hear about themselves.
Added
Warnings. A local binding, loop variable, catch binding or pattern binding that is never referenced (
LYR-SEM0071— naming it_is the opt-out; parameters and the shorthand field patternRect { w, h }are deliberately exempt). An imported name nobody in the file uses (LYR-SEM0072). A statement control flow can never reach (LYR-SEM0073). And a static extension method called through an instance (LYR-SEM0074): that form is deprecated and becomes an error in the next major — the warning is the clock. Warnings stay silent over a program with errors, and never fail a build by themselves.--deny-warningsoncheckandbuild, for CI: the warnings keep their severity in the output, one closing error (LYR-CLI0016) carries the policy into the exit code, and a denied build writes no artifact. Thelyric.jsonunknown-key warnings are real diagnostics now (LYR-CLI0017) and count toward the gate.Notes on diagnostics. A duplicate declaration points back at the first one, a missing interface method points at the member it fails (in whatever file it lives), an unknown name suggests the single closest candidate in scope, and an unknown member suggests from the same list completion offers. Rendered indented under the caret block in text — deliberately not in the head-line format a problem matcher reads — as a
notesarray in--json(only when present, so existing consumers read what they always read), and as related information in editors.The first hint.
LYR-SEM0075: avarthrough which nothing is ever changed — no reassignment, no field or element write, nomutcall, not handed over by reference — could be alet. Avarthat documents mutation keeps itsvar.An error that was silent misbehavior: two files claiming one module name is
LYR-RES0007with a note at the first claim, instead of a shadow registration nothing could explain.Editors draw the difference: unused and unreachable code fades, the deprecated instance form is struck through, and every note is a click away. The severity
infoexists on the wire for what later versions will say at it.Guide chapter 19 documents the contract: severities, codes as stable identifiers, the gate, and what warns today. The repository holds itself to it — the standard library, the examples and the templates check in silence, and a test keeps them there.
Changed
The editor clients live in their own repositories (vscode-lyric, jetbrains-lyric) and release their installables there, on their own cadence. Toolchain releases v1.8.0 through v1.9.1 carried them beside the archives; from this release on they are found there. The project moved to the
lyriclangorganization.Two messages stopped promising futures: the duplicate-function hint lost its "in v1" (overloading was rejected for good in v1.5.0), and the block-lambda limit
LYR-SEM0046now states the problem in the message and its way out in a note.
v1.9.1 — 2026-08-19
The formatter reaches the editor. textDocument/formatting is served by the language server —
format on save works wherever the editor offers it, in VS Code and the JetBrains IDEs alike,
with no client update needed.
The answer is one whole-document edit off the buffer as it stands, an empty list when the file already has the shape, and NO edits for a buffer that does not parse: the formatter never writes a guess over broken text, behind the editor's gesture either. The editor's tab settings are deliberately ignored — one shape is the tool's contract, in every surface it has.
The toolchain is otherwise unchanged.
v1.9.0 — 2026-08-19
Two tools. lyric pack turns a program into one standalone executable, and lyric fmt gives
every Lyric file the one shape there is. The language, the standard library, the bytecode
format and the embedding API are untouched; the format stays 3.2, and a .lyrbc built by
1.8.0 packs and runs unchanged.
Added
lyric pack app.lyr— a program becomes one file.lyric pack app.lyr ./app arg1 arg2The result is a copy of a prebuilt stub runtime with the compiled module and a 24-byte footer appended — a byte copy, no linker, no .NET on the target machine. The packed program owns its whole command line (no
--protocol, no wrapper options), runs with every capability like any standalone program, exits withmain's return value, and its panics name your lines — same runtime, same bytes, same backtraces aslyric run.Two new binaries carry it:
lyrpack, which packs a.lyrbcand nothing else, andlyrstub, the runtime half of a packed program. The release archives hold the platform's stub understubs/<rid>/; a bare stub started directly explains itself instead of failing obscurely, and a truncated pack is reported as damaged rather than executed. The format is specified indocs/Pack.md, the guide's chapter 17 says what to know before shipping. The release pipeline packs an example and RUNS the result on Windows and Linux before an archive exists.lyric fmt— the formatter. In place for files and directories,--checkfor CI (writes nothing, exits nonzero when anything would change),--stdinfor editors. No style options.What it keeps: every comment (trailing ones trailing), your blank lines capped at one, your literal spellings (
0xFF,1_000_000). What it decides: line breaks against the 100-column limit, trailing commas exactly where the grammar allows them and only in broken layout, a blank line after the module header and between declarations with bodies. A file that does not parse is reported and left byte-for-byte untouched.The repository holds itself to it: the standard library, the examples and the templates are formatted, and a test fails when they stop being it.
Changed
- The standard library, the examples and the project templates are reformatted with the new formatter. No signature, no name and no behaviour changed — the test suite verifies the reformatted sources compile to the same programs.
Not in this release
- Packed executables that run on macOS. A Mach-O declares its own extent; appended bytes
put the file beyond it, and
codesignrefuses the result — found by the release pipeline's own gate, recorded indocs/Pack.md. The fix is a real Mach-O segment for the payload, deno's route; until then macOS packs for the OTHER platforms via--stub. - Cross-platform packing sugar: a pack is for one platform, and a foreign platform packs
via
--stubwith that platform's stub out of its archive. No--targetuntil someone needs it. - A trimmed stub: 73.5 MB self-contained today; trimming measures 13.0 MB and survives a smoke test, but one smoke is not a gate. Decision material for the next scope check.
- Capability narrowing at pack time: a packed program runs with everything, like any standalone program. Narrowing is a footer field for a future minor.
textDocument/formattingin the language server: the formatter lives in the library the server already uses; the wiring is a later slice.- Format-on-save configuration, formatter style flags: deliberately never.
v1.8.1 — 2026-08-18
A one-line fix for the JetBrains plugin (now 1.2.1): the TextMate bundle provider was registered
under an extension-point namespace that does not exist, so .lyr files rendered uncolored while
everything the language server answers worked. The point is declared by the TextMate plugin but
qualified under com.intellij; the registration moved there, and highlighting appears. The
toolchain itself is unchanged.
v1.8.0 — 2026-08-18
The editors catch up with the compiler. No language change and no format change: the language
server compiles the PROJECT instead of the open buffer, answers everything an editor asks, and
two installable clients ship beside the toolchain — the VS Code extension as a .vsix, and a
new JetBrains plugin.
Changed
The language server compiles the project, not the buffer. Under a
lyric.json, every.lyrfile beneath the source root is one compilation: find-references works in BOTH directions (standing on a declaration finds the uses in files that import it), files nobody has open get their errors into the Problems panel, a deleted file has its squiggles withdrawn, and a change behind the editor — a branch switch, another tool — is picked up through file watches. A file outside any project is compiled from itself, as before. Measured: a 14-file project checks in the same time as a single file — the standard library dominates either way.Find references and semantic highlighting underline the name, not the expression —
xinstead ofp.x,Pointinstead ofPoint { x = 1 }.
Added
- Rename (
F2/Shift+F6), project-wide: the declaration, every use, and theimportclauses that carry the name. What cannot be renamed says why — the standard library, a module, a built-in. Whether the NEW name collides is left to the compile that follows immediately; its diagnostics are the conflict analysis. Applied edits recompile clean, pinned by test. - Workspace symbols: every declaration of the project, searched by name.
- Semantic highlighting: every name colored by what the compiler resolved it to — a type in
an annotation, an initializer and an attribute alike;
letbindings as readonly. An unresolved name stays uncolored, which is the honest signal. - Signature help while typing a call — the declaration as written, the active parameter following the commas. Folding with the closing line kept visible. Inlay hints for the inferred type of unannotated bindings and loop variables.
- The VS Code extension grew up: a restart command, a status item that shows the server
state and version, a
lyric: buildtask wired to the Problems panel, snippets — and the extension ships asvscode-lyric-<version>.vsixon every release. - A JetBrains plugin (
jetbrains-lyric-<version>.zip): the same server in CLion, IntelliJ IDEA, Rider and the other commercial IDEs, 2026.1 or newer — diagnostics, completion, navigation, rename, semantic highlighting, signature help, folding and inlay hints through the platform's own LSP integration. Install from disk; neither client is on a marketplace.
The interpreter stops allocating — and so does the native boundary. No language change and no
format change: the same programs compile to faster, smaller modules, run with far fewer heap
allocations, and a host SDK can now put Vec2 in a native signature. The numbers below come
from tools/Bench (new in this release), Release, per operation, against v1.6.0.
Changed
A function call no longer allocates. Frames are pooled per function; a call went from 176 B to 0 B and from ~50 ns to ~8 ns. Deep recursion still works, exceptions and panics unwind exactly as before.
Small functions are inlined. A direct call to a function of roughly a dozen instructions — a
Vec2.add, an iterator'snext, a getter — is replaced by its body. Callers and callees withtry/deferare left alone, recursion stays a call, and a function that always throws keeps its frame.Objects that never leave their function are dissolved into locals. A
Vec2built, read and assigned inside a loop costs 0 bytes: construction plus method call went from 352 B / 271 ns to 0 B / 8 ns, the operator form (a + b) from 352 B / 252 ns to 0 B / 6 ns. This is what makes vector arithmetic through the v1.5.0 operators usable in a per-frame game loop.for-inno longer allocates its iterator. A range or array loop runs at 0 B per element (208 B before); a range loop is ~3× faster than in v1.6.0. TheIterableroute through an interface is devirtualized where the concrete iterator is provable — aSet/Maploop now calls itsnextdirectly instead of dispatching per element.Modules got smaller. A function whose every call was inlined is removed; the six-function
examples/arith.lyrcompiles to two. An attributed function always survives — the row is a promise to the host.A panic in an inlined function names the caller's frame with the callee's line. The line is right, the frame above it is gone — the trade every optimizing compiler makes. A deliberate
panic(...)keeps its full backtrace, because a function that never returns is not inlined.
Added
The native boundary takes and returns value structs, without allocating. A native signature may use a
structdeclared in the same native module (scalar and string fields only). The declaration stays fully typed on the script side; on the wire it is flattened:module engine.geo; pub struct Vec2 { x: float, y: float } pub fn setPosition(entity: int, at: Vec2); pub fn positionOf(entity: int): Vec2;A struct parameter crosses as its fields — the host registers the delegate it would have written for scalars. A struct return comes back through a buffer the runtime owns (
NativeRegistry.RegisterStructReturning): the implementation fills one value per field, the script sees an ordinary value, and value semantics keeps the shared buffer invisible. Layout disagreements between host and SDK are load errors with the import's name in them.Measured: a
Vec2built fresh and passed in, or received back, costs 0 B per call — the answer to the embedding question that produced this milestone (Erato'spositionOf).A native call no longer allocates its argument array. The
LyrValue[]handed to an implementation is pooled and reused; a one-argument crossing went from 40 B to 0 B, a four-argument one from 88 B to 0 B. The array is therefore a LOAN: read it during the call, copy values out, never store it — documented onNativeRegistry, and every implementation in the standard registry already complied.tools/Bench— the in-process measurement harness behind all numbers above:dotnet run -c Release --project tools/Bench. Allocated bytes and nanoseconds per operation, scalar-loop baseline subtracted, round-robin against JIT tiering, raw-registry boundary probes.
Not in this release
- Struct returns through the embedding layer's delegates.
RegisterStructReturningis aNativeRegistrysurface; aLangVm.RegisterNativeoverload that marshals a C# struct is sugar for a later release — the raw form is the one a game host uses anyway. - Structs from other modules in native signatures — the struct must live in the module that declares the native, which is where an SDK's value types belong.
- Escape analysis across surviving calls — a struct passed to or returned from a Lyric function that stays a function still allocates.
- The remaining optional ops in
for-in: a range loop still runs ~1.9× a hand-writtenwhile; the gap isoptsome/optissome/optgetand block hops, peephole material. - Value structs as a language feature. Deliberately: a
structalready HAS value semantics; this release makes the representation keep that promise everywhere it matters, with no new mechanism.
v1.6.0 — 2026-08-18
Attributes. A program can say things about itself that a tool outside it can read — which functions a host should call, what a script-declared type looks like, what a module is. The bytecode format goes 3.1 → 3.2; both new sections are skippable, so a 1.5.0 runtime loads a 1.6.0 module and runs it unchanged.
Added
Attributes, on a function, a type and the module header. An attribute is a struct type; where it may sit is the marker interface it declares —
OnModule,OnTypeorOnFunction, all new instd.core:import std.core { OnType, OnFunction }; pub struct Component :: [OnType] { } pub struct System :: [OnFunction] { order: int = 0 } @Component pub struct Health { value: int, max: int } @System { order = 10 } pub fn damageTick(dt: float): void { }Conformance decides, not the name — no struct becomes an attribute by accident, the same nominal rule the operators follow. The arguments are the struct initializer restricted to literals; a field the use does not write carries the field's literal default, and a field with neither is an error at the use site, not a hole in the metadata.
An attribute describes; it does nothing. No attribute in this release is read by the compiler, and a runtime that ignores them runs the program unchanged.
Bytecode format 3.2. Section 11 holds the rows — target, attribute type, one value per field in field order, always complete. Section 12 holds field names, ONLY for types a row references: everywhere else the rule stands that field names are not in the bytecode, but a host reading
@Component struct Healthneedsvalueandmax, or it has learned a shape it cannot name.An attributed function survives dead-code elimination: the row is a promise that the index is valid, and the host is a caller the reachability analysis cannot see — the same standing as the entry point.
The embedding API reads the rows.
ScriptModule.Attributesanswers beforeInstantiate— for foreign bytes, the module row is how a host decides whether to load at all. A hit is a call handle:instance.CallVoid(use, …)calls by the index the row carries, so a typo in a script is a compile error instead of a function nobody finds.FieldsOfyields the named, typed shape of an attributed type.The tools show them.
lyric disasmprints each row with its field names,lyrvm infocounts them, and hovering@Systemin an editor answers with the struct.
Fixed
- A duplicate field in a struct initializer crashed the compiler.
P { x = 1, x = 2 }passed the type checker and died in the lowering with an internal exception instead of an error message. It is a diagnostic now (LYR-SEM0070), reported at each repeated field, in struct and class initializers and in enum struct-variant initializers alike. Found while building the attribute checks, which validate their arguments the same way.
Changed
@nameat declaration position is no longer "attributes arrive later". It parses; what the name resolves to is the sema's question, so@testis nowunknown type 'test'instead ofLYR-PAR0038. That code stays on parameters, where attributes remain rejected, with a message that no longer promises the future. The reserved expression form@name(args)leaves the grammar;LYR-SEM0053now says an attribute is not an expression.
Not in this release
- Attributes on parameters, fields and members — top-level declarations and the module header only.
- Attributes the compiler reads (
@Deprecated,@MustUse,@Inline): the moment one attribute changes compilation, the attribute set becomes part of the language contract and the stability promise. That is a separate decision, deliberately not smuggled in here. - Runtime application, Python-decorator style: there is no mechanism by which an attribute wraps or replaces its target.
- Qualified attribute names: names are the bytecode's type names and therefore unqualified. An SDK owns its attribute names the way it owns its native names.
- Completion after
@.
v1.5.0 — 2026-08-18
Operators on your types. Everything resolves through the one mechanism this language has for
polymorphism — the interface a type declares — so there is no operator declaration syntax, no new
opcode, and the .lyrbc format stays 3.1.
Added
==and!=on every type conforming toEquatable<T>. The operator is the method:a == bcallsa.equals(b), anda != bnegates it.struct Point :: [Equatable<Point>] { x: int, fn equals(other: Point): bool { return this.x == other.x; } } let same = a == b;Conformance is required, not the method alone: a type with an
equalsnobody declared asEquatablestays rejected, so no method becomes an operator by accident of its name.<,<=,>and>=on every type conforming toOrdered<T>— onecomparemethod, negative/zero/positive, and all four operators read its sign.string < stringworks, through the conformance the standard library has carried since v1.0; its rejection had promised exactly this change.+,-,*and/on types conforming toAdd<T>,Sub<T>,Mul<T>andDiv<T>— four newstd.coreinterfaces, one method each, homogeneous:T op TgivesT. The built-in numerics conform,stringtoAddalone, so a generic function constrained onAdd<T>serves anint, astringand your vector type in one program.asbeyond the numerics converts throughInto<T>.x as Tisx.into()where the operand's type declares the conformance. Explicit only, one target per type, total conversions only — a conversion that can fail belongs in a named function returning an optional. The numeric casts keep their opcodes and are not overridable.s *= 3andxs *= 2work. The repetition overloads of*had no compound form — an accident of how compounds were checked, recorded as a limit. The compound check rework below delivered them.
Fixed
- A compound assignment never checked its operator.
p += pon a struct passed the compiler and produced an integer addition of two references at runtime — thes += "x"class of bug fixed in v1.1.0, one type over.s &= sandf <<= fpassed the same way. A compound is now typed as the binary it carries: whatevera = a + bsays,a += bsays too.
Not in this release
- Heterogeneous operands (
Vec2 * float): needs a two-parameter interface and a rule for multiple conformances to one generic interface. - Compound assignment through the operator interfaces (
v += won aVec2): the compound lowering evaluates the target's address once and cannot yet route through a call. The diagnostic says to writev = v + w. %on user types, and unary-: no interface exists for either, deliberately.- A conversion out of a builtin (
extend int :: [Into<Cents>]): the orphan rule stops it, and the rule does not look into type arguments. A named function takes its place. - Method overloading, considered and rejected: constraints plus generics are this language's overloading, and the standard library says so itself.
v1.4.0 — 2026-08-17
Completion, and a standard library that says what it does. The language, the command line, the
embedding API and the .lyrbc format are untouched; the format stays 3.1.
Added
Completion. After a
.the members of what stands before it; anywhere else the names in scope.let p = Point { x = 1 }; p. // x, y, and every method, extension and interface default the type hasThe member list is the one the compiler would accept, not an approximation of it: extension methods and interface default methods are in it, which matters because every string method of this standard library is an extension — a list without them would be empty on a
string.In scope: locals, parameters, type parameters, what the module declares and imports, and the builtins. Inner names shadow outer ones, a binding is not offered inside its own initializer, and a loop variable is not offered in the loop head.
Each item carries its kind and, when the declaration has one, the
///block above it.It works while the file does not parse, which is the state it is asked in. The trigger character is
.; everything else is the editor asking on its own.The standard library documents itself where it did not.
std.io.console,std.coreandstd.optionheld 33 public declarations and no documentation at all, so hoveringprintlnshowed a signature and nothing else. All three are written now, interface members included.
Fixed
A struct initializer is a reference to its type. Asking for the definition of
Pointinlet p = Point { x = 1 };used to answer nothing, and find-references did not list it. Both do now, and hovering it reports the type.v1.3.0 listed this under Not in this release because recording it made the type checker read
Pair<int> { a = 6 }.aas a static member access. The receiver question is answered from the expression's type now rather than from that table, so the two no longer collide.
Not in this release
- Completion does not offer keywords.
if,returnand the rest are not symbols; an editor gets them from the grammar it highlights with. - Completion after
importdoes not offer module paths. That is a different source — the file system — and not the scope. - A field reference still marks the whole member access: asking for references to
xmarksp.x. Use sites carry no span for their name alone. - References and completion stop at the compilation. The server compiles the file you are in, so another file of your project that imports it is not searched.
v1.3.1 — 2026-08-17
Fixed
Eight diagnostics pointed at a document that does not exist. Five named
Sprache.md, which has beendocs/Grammar.mdfor some time, and the sections they cited were wrong as well — §10 and §11 of a document that has seven. Following either reference led nowhere twice:attributes are not part of v1 (Sprache.md §10); '@test' and 'lyric test' arrive after v1.0attributes are not part of v1; '@test' and 'lyric test' arrive laterRather than repair the citations, they are gone. A diagnostic names what is wrong, not where to read about it — a citation ages in two ways at once, and both had already happened here. Where a reference carried information (
§11 allows none or one 'string[]'), the message now says it outright.The affected codes are
LYR-PAR0038,LYR-PAR0039,LYR-SEM0053, the lowering's main check, the bytecode reader's global check and two entry-point findings of the IR verifier. No code changed and no behaviour changed — only the wording, so a program that compiled still compiles and one that did not still fails, with the same code on the same span.
v1.3.0 — 2026-08-17
Everything in this release is the language server. The language, the standard library, the command
line and the .lyrbc format are untouched; the format stays 3.1.
Added
Hover shows the documentation you wrote. A
///block above a declaration appears under its signature, for declarations in the file you are editing and in every module it reads:fn cpuCount() -> int How many cores the machine has, for programs that split their work. The VM itself is single-threaded.The text goes through unchanged — there is no doc-comment vocabulary in the grammar, so nothing is interpreted, and nothing is composed from the signature. A declaration without a block is shown exactly as before.
An editor can show the outline of a file (
textDocument/documentSymbol). Types carry their fields, methods, variants and static constants as children; imports, parameters and locals are left out, because an outline says what a file offers.It reads the syntax and resolves nothing, which is why a file with type errors still has an outline — the moment you most want one.
Only the nested form is produced. An editor that does not announce
hierarchicalDocumentSymbolSupportgets no outline rather than the deprecated flat one.Find all references (
textDocument/references), with or without the declaration itself. The answer covers the program reachable from the file you are in, so a call into the standard library is found in the module that declares it.
Changed
- Go to definition selects the NAME of a declaration, not the start of it. Previously the cursor
landed on the first character of the whole declaration — on
forfor a loop variable, oncatchfor a catch binding. An editor that announceslinkSupportnow also receives the full extent of the declaration beside the name, so a peek window shows the declaration and puts the cursor on its name.
Fixed
- Go to definition on a struct initializer no longer jumps somewhere else. In
let p = Point { x = 1 };, asking aboutPointused to land onp— the enclosing binding, which is not what the cursor was on. It now answers with nothing. What it cannot yet do is answer withPoint; see below.
Not in this release
- A struct initializer is not a reference to its type.
Point { … }is bound to no symbol, so neither find-references nor go-to-definition sees it. An annotation (let p: Point) is found. - A field reference marks the whole member access: asking for references to
xmarksp.x, not thexin it. Use sites carry no span for their name alone. - References stop at the compilation. The server compiles the file you are in; another file of your project that imports it is not part of that compile, and its uses are therefore not listed.
- No completion. It is the first question asked at a position where the text does not parse, which is a compiler topic rather than another editor feature.
v1.2.0 — 2026-08-17
Changed
Only a field needs the
,that separates members of a struct or class. A bodiless method used to need a semicolon and a comma in a row:class Builder { fn addExecutable(entry: string, output: string): Artifact;, // no longer fn addTest(entry: string): Artifact; }The rule is strictly more permissive, so every file that was valid stays valid — the comma is still accepted where it is now optional. Two fields still need one, or
a: int b: intwould read as a single field of a type nobody wrote.
Added
lyric newwrites a project that builds.lyric new myapp # lyric.json, build.lyr, .gitignore, src/main.lyr lyric new mylib --lib # lyric.json, src/mylib.lyr — nothing to buildTwo shapes and two flags rather than a template system: with two variants a discovery mechanism is more machinery than content. The name becomes a module name, so it has to be one, and an existing directory that holds something is refused rather than merged into.
The templates are embedded in the binary, so nothing can go missing beside it — and they are real
.lyrfiles in the repository, which the test suite compiles.__name__is a valid Lyric identifier, so a template is compilable Lyric rather than text with holes in it.It is the one command the driver runs itself: it writes files and compiles nothing.
A project may be built by a script,
build.lyr.lyric buildwithout a file argument runs it and compiles what it declares:import std.build { addExecutable }; pub fn build() { let app = addExecutable("src/main.lyr", "out/app.lyrbc"); app.sourceMap(false); addExecutable("tools/mktex.lyr", "out/mktex.lyrbc"); }Every artifact is compiled whole, from its entry file; there is no link step and nothing is shared between two of them but the source on disk.
lyric buildwith a file still means "compile this file" and is unchanged.Nothing is compiled while the script runs — it collects, and the compiles happen once
buildhas returned. That is why an option set on the following line still applies, and why a source file the script generates is finished before anything reads it.It is a Lyric program with the whole standard library and every capability, so it may write files and start processes. Relative paths in it resolve against the directory holding
build.lyr, not against the directory the build was started from.lyric buildin a repository you did not write runs code you did not write, asmakeandcmakedo.New binary
lyrbuild, the second afterlyrreplthat holds both the front end and the runtime: a build script has to run, and what it collects has to be compiled afterwards.A project may say where its modules are, in a
lyric.json.{ // where our own modules live "sourceRoot": "src", "nativeRoots": { "engine": "sdk" }, }sourceRootreplaces "the directory of the entry file" as the module root, andnativeRootsmaps a module path segment to a directory whose modules may declare functions without a body. The file is searched for upwards from the file being compiled, and comments and trailing commas are allowed in it.This closes the gap v1.1.0 shipped with:
lyric checkandlyric buildnow see the native roots a host declares, so a script written against an SDK no longer compiles in the host and fails on the command line.Both keys are optional and without the file nothing changes — that is what makes it an addition rather than a new requirement. A key nobody knows is a warning rather than an error, so a file written for a later version still loads.
It is read and never executed, which is what lets an editor learn a project's layout without running anything from it.
The language server follows a program across its files. Editing a module now refreshes the diagnostics of every open file that imports it, and a dependency is read from the editor's buffer rather than from its last save. Both halves are needed: an overlay nobody re-reads shows nothing, and a cascade over stale text refreshes to the same answer.
What a file depends on is taken from the compilation itself, not from the imports in its text — the resolver already followed them, transitively and through the project's roots, and a second answer to that question would be the one that is wrong.
The cascade goes one level. Two modules may import each other, which is a diagnostic rather than a crash, so a transitive one would not terminate.
CompilerOptions.SourceOverlayis the seam, and it is not editor-specific: it says "compile as if these files held this text", which a host embedding the compiler can use for the same reason.The language server reads
lyric.json. An import of a host SDK no longer shows as an unknown module in an editor while the same script runs correctly in the host — the second half of what v1.1.0 listed as not in it.A broken project file does not stop the analysis. The editor keeps getting diagnostics, resolved by the plain rules, and the reason goes to the client's log; publishing nothing would leave an earlier state on screen with no hint that anything happened. The message names the project file rather than appearing as an error inside the file being edited, and it is said once per change rather than once per keystroke.
Still not there: editing a module does not refresh the diagnostics of the file that imports it. The server analyses one buffer at a time.
v1.1.0 — 2026-08-15
Bytecode format 3.1. A minor of the format may only add skippable sections, so a 1.0 runtime reads a module built by this release and a 1.1 runtime reads one built by 1.0 — with one caveat below.
Added
A host may ship its API as
.lyrfiles instead of generating it.HostOptions.NativeRootsnames directories whose modules may declare functions without a body, keyed by the module path segment they own, andLangVm.RegisterNativesupplies the implementations under the same qualified names. Until now every host function went throughRegisterFunction, which derives the declaration from the delegate — right for a handful, and for an SDK it means the same signature lives in the C# call and in whatever documents the API.Whether a module may declare a native follows the ROOT it came from, never its content, so naming a file well enough is not a way into the host. A module in such a root may hold ordinary Lyric code beside its declarations.
A program may consist of several files. A module path becomes a file path under the directory of the entry file:
import shapes.circlereadsshapes/circle.lyrbeside it. Until now only the standard library could be imported, so every program was one file.Three rules come with it. A file must agree with the path it was loaded from, or the header is an error — previously such a file registered under the name its header claimed and the import that pulled it in reported cannot find module about a file it had just read.
stdresolves against the standard library alone, so nothing beside your program can take its place. And only standard library modules declare functions without a body; in your own modules a missing body is a compiler error rather than a failure at load time.Everything still compiles into one
.lyrbc. There is no separate compilation step per file.A panic names the line it happened on, not just the function:
panic [LYR-VM0002]: division by zero in main.divide (app.lyr:3) in main.main (app.lyr:8)The innermost frame points at the instruction that failed, every frame below it at the call it was waiting on.
The SourceMap section of the bytecode format now has a payload. It was reserved and named in 3.0 and never written. It maps a byte offset in a function's code to a file and a line, one row per position change.
lyrc build --no-source-mapleaves the section out. Without it the file is byte for byte what the same build produced before the section existed, so stripping costs nothing else. The section is written by default: the moment a line number is wanted is the moment nobody planned for it.Paths are stored relative to the entry file's directory, and a file outside it — the standard library sits beside the toolchain — keeps its bare name. Nothing absolute reaches the file, so a module does not carry the directory layout of the machine that built it.
Fixed
s += "x"on a string silently produced the empty string.+on astringis a call tostd.string.concatand on an array anarrcatinstruction, but the compound forms emitted a bareaddwith the operand type next to it. Nothing rejected that in a release build, and the runtime read the two strings as integers, so the variable ended up empty and the program kept running:var line = ""; line += "0F "; // line was "" afterwards, not "0F "Affected were a local, a captured variable and a coroutine local. On an array the same instruction produced a value with no reference, and the next access to it ended the process with a host exception instead of a panic. A field (
obj.s += "x") and an array element (xs[0] += "x") were reported asLYR-IR0001rather than miscompiled, and now work as well.s = s + "x"was correct throughout and is unchanged. Existing.lyrbcfiles are unaffected: the format and its specification were right, the compiler was not.s *= 3andxs *= 2stay rejected — a separate rule in the type checker demands that the right operand be assignable to the left, which does not hold for repetition.s = s * 3works.A runtime accepted an arithmetic opcode with a type it cannot compute on. §5 of the format says
addthroughremrequire a numeric type; the reader checked indices only and never the type tag, so a module carryingadd stringpassedlyrvm verifyand ran. That is why the bug above could reach an output at all — the IR verifier that does catch it runs in debug builds only. Such a module is now rejected at load time withLYR-BC0005.A reader rejected a section id it did not know, with
LYR-BC0003, instead of skipping it. That is the mechanism the format's forward compatibility rests on, and it had never run, because nothing had ever written an unknown section.This is the caveat above: a 1.0.1 runtime cannot read a module carrying a SourceMap, even though the format says it must. Building with
--no-source-mapproduces a module those runtimes accept.
Not in this release
- The command line does not know native roots.
HostOptions.NativeRootsreaches the compiler through the embedding API alone, solyric checkand the language server report an unknown module for an import a host resolves at runtime. Scripts written against an SDK run correctly and look wrong in an editor. - The language server does not know multi-file programs. It compiles the buffer it was given, so
editing
util.lyrdoes not refresh the diagnostics of theapp.lyrthat imports it. Reopening or editing the importing file does.
Both need a place where a project says what it consists of, and putting that on the command line would make a third place where the layout is written down.
v1.0.1 — 2026-08-14
Fixed
A module with both a module-level
letand atry/catchcompiled to a file that would not load. The compiler wrote the Globals section (id 10) ahead of the Handlers section (id 9), and section ids must ascend strictly, solyric runandlyrvm verifyrejected the compiler's own output withLYR-BC0005.lyric checkandlyric buildreported success beforehand, which is what made it look like a runtime problem rather than an emitter one.Only a module carrying both sections was affected; either one on its own was written correctly and is unchanged. No
.lyrbcfile that used to be valid changes — the format and its specification were already right and the writer was not, so the bytecode format stays 3.0.
v1.0.0 — 2026-08-14
The first release with a compatibility promise. Everything below describes the state it ships, not a change against v0.9.0: there is no earlier entry to compare against.
From here on the .lyrbc format and the language carry the promise the versioning describes: a
minor may add, a major may break.
Language
The whole grammar in docs/Grammar.md compiles and runs: functions, structs and
classes, enums with match, interfaces with default methods and :: conformance, generics with
constraints, optionals, exceptions with throws and defer, closures, coroutines, modules, and
extend blocks on own and primitive types.
Fixed against v0.9.0, each of them a case that used to be refused or to fail late:
- An argument position now carries an expected type, so
f(Opt.Some(5))names its instance instead of requiringf(Opt<int>.Some(5)). - A generic struct initializer takes its instance from the surrounding type:
let p: P<int> = P { v = 1 }. Written type arguments still win, and there is still no inference from the field values. - A
typealias works in every position — as a return type and a field type too, not only as a parameter type and a local annotation. static fnis allowed in an enum and anextendbody. An interface member stays non-static: it is reached through a vtable slot, which takes a receiver.- A cyclic type alias (
type A = B; type B = A;) is a diagnostic instead of ending the compiler process.
Toolchain
lyric(driver),lyrc(compiler),lyrvm(runtime),lyrrepl(interactive prompt), andlyrembed.dllfor a C# host.lyric checkanswers the same question aslyric build. It used to stop after type checking and reportokfor programs the backend could not express.- Releases ship a self-contained archive per platform (
win-x64,linux-x64,osx-arm64) that runs without a .NET install.
Documentation
- A static documentation site generated by
tools/DocGen: the guide, both specifications, and a standard library reference generated from the.lyrsignatures. One frozen directory per version.
Not in this release
- No interface inheritance; require both interfaces side by side.
- No operator overloading, so
==and<on user types stay ordinary methods. - No attributes (
@testand the rest). - The source map section of the bytecode format is reserved but not written, so a panic names the function rather than the line.