Versions & changelog

What changed, newest first.

Glyph is an early preview and moves fast. Every release is here with what it fixed and added, so you can see exactly what's in the version you're running, and what's waiting for you in the next one.

Stability, pre-1.0: the language can still change between 0.1.x releases. We hold two lines: your code stays runnable (it always compiles to plain TypeScript you own, a permanent escape hatch), and when syntax changes we aim to make glyph fmt migrate it for you. The full policy is in docs/stability.md.

A release marked Breaking rejects code an earlier version accepted. Usually because the compiler started catching something it had been missing, which is the point of the language, but it can turn a green build red on an upgrade. Read that release's note before taking it.

The marker is applied only where both versions were actually run and the older one accepted the program. It is not inferred from how a note is worded, and an unmarked release older than 0.1.99 has not been tested rather than been found safe. Every release from here on states which it is.
0.1.123 Latest Breaking September 2026

A string literal now has the type it spells

Until this release the set of legal strings lived on the declaration and nothing lived on the value, so "read" was a string and ["read", "write"] was an Array<string>. That made two programs indistinguishable: returning ["read", "write"] where an Array<Mode> is declared, which tsc compiles, and returning an xs: Array<string> in the same place, which it refuses. 0.1.122 declined the pairing rather than get it wrong, and buying the right program that way meant giving up the wrong one.

glyph check --no-tsc
type Mode = "read" | "write"
type Cfg = { mode: Mode }

fn ok()    -> Array<Mode> { return ["read", "write"] }
fn bad()   -> Array<Mode> { return ["read", "nope"] }
fn wrong() -> Cfg         { return { mode: "nope" } }
// 0.1.122: nothing, on all three                (exit 0)
// 0.1.123: [E0204] expected `Mode`, found `"nope"`
//              return ["read", "nope"]
//                              ───┬──
//          [E0204] expected `Mode`, found `"nope"`      (exit 1)

A written string literal carries the one-literal type it spells, an array literal's element type is the join of its elements', and a record literal's fields carry their literals. A written array or object literal is then checked element by element and field by field against the declared type, so the message names the offending literal and underlines it instead of the whole value.

The literal widens back to string where TypeScript widens it: a let with no annotation, an array bound to one, and the element binding of a for over a written array, all three of which widen in the emitted TypeScript too. A const keeps its literal, because a Glyph const emits a TypeScript const and takes_mode(CM) compiles, so widening it would refuse a program tsc accepts. A literal type that arrived through a name is not fresh and is not widened, which is TypeScript's own freshness rule. Every row of that was settled by writing the equivalent TypeScript and running tsc --strict on it, which is why the const row and the for row point in opposite directions.

Four shapes are still undecided and each says why. The key argument of a Record against another Record's, because TypeScript writes Record<string, V> as an index signature covering every key a Record<Mode, V> declares and accepts it both ways. A literal inside Some(...), because a prelude constructor's type is pending generic instantiation and it is the constructor that has no type here, not the literal. A record literal bound to an unannotated let, because nothing synthesizes a record type for an object literal yet. And a concatenation, because a binary expression has no type here at all.

The prelude Result has an address

Result and Option are declared by no project module, so an E0200 over one of them used to carry cause: null, no tool keyed their variants, and glyph fix declined the single most common shape it exists for: match r { Ok(v) => v, }. The identity turned out not to need inventing. The resolver registers std/result with Result, Ok and Err; the emitter writes import { Ok, type Result } from "./.glyph-runtime/std/result" into a program that never imported it; and import std/result { Result } already resolves. One layer was throwing that away.

glyph check --agent --no-tsc, on a match over a prelude Result
"code": "E0200",
"message": "non-exhaustive match on `Result`: missing variants `Err`",
"entity": "main::total",
// 0.1.122: "cause": null,
//          "union": { "declaration": null, "kind": "builtin",
//                     "module": null, "name": "Result" }
"cause": "std/result::Result",
"union": { "declaration": "std/result::Result", "kind": "builtin",
           "module": "std/result", "name": "Result" },
"symbols": [ { "entity": "std/result::Result", "kind": "union",
  "generics": ["T", "E"],
  "variants": [ { "name": "Ok",  "payload": "T", "construct": "Ok(T)" },
                { "name": "Err", "payload": "E", "construct": "Err(E)" } ],
  "path": null,
  "path_absent": "the stdlib is TypeScript the compiler carries and
    stages into the build (`runtime/std/*.ts`), not Glyph source under
    this project, so there is no `.glyph` file holding this
    declaration and no span in one to report" } ]

With the payload shapes in hand, the repair follows.

glyph fix src
glyph fix: applied E0200 in src/main.glyph: added 1 arm(s) to the
           match on `Result`: `Err`, and wrote `import std/process`
           for the arm bodies

  return match r {
    Ok(v) => v,
    // TODO(glyph fix): `Result` variant `Err` is unhandled; write this arm
    Err(payload) => {
      print("unhandled Result variant Err (arm written by glyph fix)")
      process.exit(1)
    },
  }

// glyph check (with tsc): 1 module(s) checked, no diagnostics.

No import is written for the variants, because the prelude already binds Ok and Err; the std/process import is the one every E0200 repair adds for the TODO body. The same rule gives Option its std/option address, Nullable its std/nullable one, and fs.ErrorKind the address std/fs::ErrorKind. glyph query symbol --entity std/result::Result, glyph query variants and glyph query exports --module std/result all answer instead of refusing, with path and range null beside the reason rather than missing. A project that declares its own pub type Result still keys as orders::Result.

Two spellings were considered and rejected. A reserved segment such as <prelude>::Result is collision-proof and nothing else: it gives one declaration two identities, the agent's and the emitter's, and adds a fourth module-path grammar beside project paths, npm-scoped paths and extern/. A discriminator field beside a bare name was already shipped and is the thing that was broken, since the union entity has carried kind: "builtin" next to name: "Result" all along and it buys an agent no next call. The whole surface rests on forwarding one flat string from a diagnostic into the next query, and a string whose meaning depends on a sibling field cannot be forwarded. union.kind stays builtin, because it still answers whether this project can add a variant. The names no stdlib module declares, Array, Record, Schema, Component, Issue, par, print, assert and infer_output, stay unkeyed with a reason each rather than getting an invented std/prelude.

Everything an agent has to know, out of the compiler

glyph llms --json emits one document and no line of it is written by hand. diagnostics: 61 codes, each with the catalogue sentence, the one-line fix, the long --explain text, and the help and note read off a real diagnostic. 38 of the 61 also carry a wrong program from the negative corpus, compiled at the moment you ask; the other 23 say the corpus has no case for the code. prelude: 24 names out of the resolver's own table. stdlib: 36 modules and 327 exports, split three ways. 24 carry a signature the checker models in every position, 76 carry one with a ? where a parameter type belongs and are counted partially_modeled, and 227 say in a signature_absent line that the tables model none. A ? is unknown rendered, so a signature with one is never presented as complete. decisions: 47 parsed out of the spec. tools: the eleven the MCP server serves, each with its glyph query verb and its full manual.

glyph llms --json, trimmed
"stdlib": {
  "modules": [ { "path": "std/array", "exports": [
    { "name": "filter",
      "signature": "fn(Array<T>, fn(T) -> bool) -> Array<T>" },
    { "name": "find",
      "signature": "fn(Array<T>, fn(T) -> bool) -> Option<T>" }, ... ] } ],
  "modeled": 24, "partially_modeled": 76,
  "unmodeled": 227 },
"decisions": { "count": 47, "duplicate_numbers": [43], ... },
"tools": { "count": 11, "tools": [
  { "name": "glyph_symbol", "cli_verb": "glyph query symbol",
    "manual": "...", "input_schema": { ... } }, ... ] }

The code catalogue is one table in the compiler now, and docs/error-codes.md and the diagnostic table in AGENTS.md are written from it by a script that fails on drift. Merging the three hand-maintained copies turned up E0304, which had an explanation and no place in the code list, and an E0111 row whose escaped pipe had gone unnoticed. A test reads the tool list and the document together, so a document naming a tool the server does not serve, or a tool count that disagrees with the list, fails the build. That is what let "exposes five tools" survive two releases against a server serving seven.

Every Glyph fence in those two documents compiles. AGENTS.md was 19 fences with 2 compiled and 17 skipped as fragments; it is 24, with 20 compiled and 4 marked: one is a fragment of a named complete example that is itself compiled, and three are marked needs-deps, which is a different claim (they import an npm package or declare a component). docs/reference/stdlib.md was 4 of 8 and is 8 of 8. Five wrong forms the bootstrap warns about are now programs paired with the diagnostic each draws. Compiling the documents found two things reading them was never going to find: an inline triple backtick in AGENTS.md that read as a fence opener, so 250 lines counted as one snippet, and a worked server in the stdlib reference calling http.serve, which std/http does not export. The function is listen.

The wrong programs are readable too. glyph llms --negative E0200 prints the tests/negative/ and catches/ cases that draw the code, each compiled here, the catches/ ones carrying the TypeScript a tsc --strict project accepts beside the Glyph that refuses it. With no code it lists 69 cases over 40 of the 61 codes and names the 21 codes that have none. That is a different count from the 38 above, which reads only tests/negative/.

And tools/list stopped costing what it cost. Eleven tools carried 31,446 bytes of description in a 45,509-byte reply; the same eleven carry 8,627 in a 20,386-byte reply, which every session pays on connect. Nothing was deleted: the previous text is held verbatim, keyed by tool name, and published in glyph llms --json, so the contract you need at the moment of the call is in the description and the rest is one command away.

A module under std/ is an error instead of dead code

A project file could declare module std/io. It compiled clean and was unreachable: an import of that path resolves to the stdlib module the compiler carries, and the project file is never read. The prefixes were reserved for imports and nothing rejected the declaration.

glyph check --no-tsc, on src/io.glyph declaring `module std/io`
// 0.1.122: glyph check: 2 module(s) checked, no diagnostics.   (exit 0)
// 0.1.123: [E0113] `std/io` declares a module under `std/`, a prefix
//                  reserved for the modules the compiler carries (D15)
//          Help: Rename the module to a path of your own (`io`,
//          `app/io`).                                           (exit 1)

The prefix is matched as a whole first segment, so standard, externals/io, app/std and app/extern/helper are untouched, and an import under either prefix still resolves. This is a dead-code hole on its own account, and it is also the guard the stdlib identities above rest on.

Two smaller things

An imported string-literal union prints its declared name. It used to lower straight to its literal set, so one declaration said expected "read" | "write" through a named import and expected Alias through an alias, and neither matched what the same declaration prints locally. It is expected Mode under both spellings now, and the literal set is read from the declaration through one accessor that assignability, exhaustiveness, the query tools and the emitter all go through. The emitted TypeScript is unchanged.

glyph_impact's signature-type table decides a string-literal union on either side with the same comparison glyph_assignable makes, so the two tools can no longer disagree about one pairing. The first cut of that reported the accepted pairing SAFE, and it was wrong: change_signature_type names no replacement type, and in a project that compiles every pairing is accepted, so SAFE was true of every argument of every call and proved nothing. On one f(s: string), f("x") came back SAFE and f(s) came back WILL_FAIL under one edit, and both break identically. One meaning runs across the table now: an argument whose pairing the relation reads in full is WILL_FAIL, an argument with no rule is UNDETERMINED, and glyph_assignable keeps COMPATIBLE for the acceptance because it is asked about two types with no edit.

One thing this release does not settle, named here rather than left silent. The spec numbers two different decisions D43, the mut-across-await rule and never as the bottom type; glyph llms --json reports the collision in duplicate_numbers rather than hiding it, and renumbering one of them is a change to a public index other documents cite.

A union of your own, named Result

A project can declare pub type Result = | Won | Lost. Import it into another module, match on it with one arm missing, and until this release nothing said so: the compiler read the name as the prelude Result rather than as the declaration the import names, and a bare prelude Result carries no variant list, so the match was never checked. The program compiled, and the emitted TypeScript threw non-exhaustive match at run time. Option and Nullable did the same. Rename the type to Outcome, or declare it in the same module instead of importing it, and the check fired.

An explicit import binding now shadows a prelude name, which is what a local type Result already did and what import std/string already did for a namespace binding. import std/result { Result } is unchanged: the prelude is a curated re-export of the standard library, so those two are one declaration. glyph_variants was reporting 0 match sites across 0 files over the file that held the site, and it counts it now, because the site is keyed.

Breaking. In the checking direction, established by running the published 0.1.122 and this release on the same programs rather than by reading the diff. fn bad() -> Array<Mode> { return ["read", "nope"] } is 1 module(s) checked, no diagnostics. and exit 0 under 0.1.122, and [E0204] type mismatch: expected `Mode`, found `"nope"` with exit 1 here. Eight programs tsc --strict refuses and 0.1.122 accepted are E0204 or E0211 now, so a full glyph build was already failing on every one of them and what changes is which compiler tells you. The four correct programs the old fence existed to protect are accepted by the rule rather than by a declined pairing, the 31 apps and examples/ answer identically under both binaries, and the positive corpus grew from twelve programs to eighteen, each run under 0.1.122 with tsc in the loop before it was added. E0113 is breaking in the same direction and loses nothing: a two-file project declaring module std/io is 2 module(s) checked, no diagnostics. and exit 0 under 0.1.122 and [E0113] with exit 1 here, and no importer could ever reach that file's code. So is the import rule: a project that imports its own Result and matches on it with a missing variant is 1 module(s) checked, no diagnostics. and exit 0 under 0.1.122 and [E0200] with exit 1 here, and every such program threw at run time on the variant no arm named.

0.1.122 Breaking September 2026

A type that lists its legal strings now rejects the illegal ones

type Mode = "read" | "write" declares a finite set of strings, and until this release the Glyph checker compared a value against it by nothing at all. A number, a bool, a record, a string outside the set: all four passed glyph check --no-tsc with nothing but unused-variable lints. tsc caught them on the emitted TypeScript, so a full glyph build was never wrong, but the four surfaces that have no tsc in the loop were: --no-tsc, the language server, the MCP tools and the playground.

main.glyph
type Mode = "read" | "write"

fn takes_mode(m: Mode) -> Mode {
  return m
}

fn main() {
  let a: Mode = 3
  let b: Mode = "rw"
  let c: Mode = takes_mode(true)
  let d: Mode = { x: 1 }
}
// 0.1.121: four unused-variable warnings, exit 0
// 0.1.122: [E0204] expected `Mode`, found `number`
//          [E0204] expected `Mode`, found `"rw"`
//          [E0211] expected `Mode`, found `bool`
//          [E0204] expected `Mode`, found `record`

That is the four tsc reported, at the same four statements, without running tsc. The rule reads the union as what it is at run time, a string, and asks the checker the corresponding string question in each direction, so it inherits the rules that already refuse a record or a container against a string instead of restating them. Two literal sets are compared by set, so a narrower union goes where a wider one is declared. An alias chain is followed on both sides, an imported union is decided like a local one, and Nullable<Mode> is descended with the union intact, so let m: Nullable<Mode> = "read" stands and = "rw" does not. A bare string where the union itself is declared is refused too, because tsc refuses it too.

Four shapes are left to tsc on purpose rather than guessed at. A string produced by a value-position match whose arms are not all written literals is one: reading it as a bare string would reject correct programs, and one in this repository's own corpus was the first casualty of a stricter first cut. A union nested in a record field is another, which is the same boundary the record rules already stop at. A bare string inside a generic argument is the third, and it is the reason fn arr() -> Array<Mode> { return ["read", "write"] } still compiles: Glyph types every string literal string, so that array is Array<string> here while TypeScript reads it as Array<"read" | "write">, and refusing the pairing would refuse the ordinary spelling of a list of modes. The fourth is a call to a generic function that returns its own type parameter: id("read") is a Mode under tsc and a string here, for the same reason. What is refused inside Array<Mode> is what is refused against Mode everywhere else: a number, a bool, a record, a tagged union and a literal outside the declared set.

A deeply nested file no longer kills the process

The parser is recursive descent with no depth guard, so an expression nested two thousand levels deep ran the process out of stack and aborted. That is bad on glyph check and worse on glyph lsp and glyph mcp, which read every file under the root: one pathological file took down the server for the whole workspace. The nightly fuzz targets had been failing on it every night since 2026-09-10.

glyph check --no-tsc, on `const x = ` with 2,000 nested brackets
// 0.1.121: thread 'main' has overflowed its stack
//          fatal runtime error: stack overflow, aborting   (exit 134)
// 0.1.122: [E0011] this array literal nests deeper than the parser's
//                  limit of 64 levels                      (exit 1)
//          Help: Name the inner levels: pull them out into `let`
//          bindings, or into a `fn` that returns one of them, so no
//          single expression, type or pattern is more than 64 levels
//          deep.

The limit is 64 and it was measured rather than picked. One level of the costliest construct, a nested array literal, costs 7,792 bytes of stack in the release build and 8,810 in the debug one, found by binary-searching the smallest thread stack a given depth will parse in. The thinnest stack the parser runs on is a spawned thread's 2 MiB, which is what the language server's workers get, and 64 levels spend under a third of it in the costlier build. For scale, the deepest nesting in the 343 .glyph files in this repository is 16 levels. The limit covers expressions, types, patterns, blocks and JSX children, and the diagnostic lands on the token that would have opened level 65.

The diagnostic is an object your agent can act on

Every diagnostic now carries the pieces a repair needs, and carries them as an explicit null when the compiler does not hold one, so absence has a single spelling: expected and actual as the checker displays them, cause as the module::name of the symbol at fault, alternatives when there is a finite list of things that could legally stand there, related for a union's whole variant list, file as a path relative to the resolution root, and explain pointing at the code's own explanation. The report names that root in project_root, so file opens without a second question about what it is relative to; a tree holding several projects (D41) gets project_roots and a null project_root.

glyph check --agent prints that object with two more keys. constraints are the invariants a repairing edit has to keep, written out so the edit that satisfies the diagnostic does not break the guarantee the diagnostic existed to protect. symbols is the glyph_symbol answer for every symbol the diagnostic names, so the next edit needs no second call.

glyph check --agent, on a match missing two arms over an imported union
"code": "E0200",
"message": "non-exhaustive match on `OrderStatus`: missing variants
            `Paid`, `Cancelled`",
"entity": "main::describe",
"cause":  "orders::OrderStatus",
"related": ["Pending", "Paid", "Cancelled"],
"missing_variants": ["Paid", "Cancelled"],
"constraints": [
  "preserve exhaustiveness: this match must name every case of
   `OrderStatus`, so add one arm for each of `Paid`, `Cancelled`.",
  "do not add an `else` arm: `OrderStatus` is declared in this
   project, and a catch-all forfeits the guarantee that adding a
   variant to it later forces this match to be updated (D9)."
],
"symbols": [ { "entity": "orders::OrderStatus", "kind": "union",
  "variants": [
    { "name": "Paid", "payload": "{ transaction_id: string }",
      "construct": "Paid({ transaction_id: string })" }, ... ] } ],
"symbols_absent": []

Where the repair is fully determined, glyph fix writes it. Three rules landed: the missing arms of a non-exhaustive match, the checker's did-you-mean on an arm head that names no variant, and the one declared field a character away from a field typo. It joins the unused-import rule that was already there.

glyph fix src
glyph fix: applied E0200 in src/main.glyph: added 2 arm(s) to the
           match on `OrderStatus`: `Paid`, `Cancelled`, and wrote
           `import std/process` for the arm bodies and `Paid`,
           `Cancelled` into `import orders`

import orders { OrderStatus, Pending, Paid, Cancelled }
    ...
    // TODO(glyph fix): `OrderStatus` variant `Paid` is unhandled; write this arm
    Paid({ transaction_id }) => {
      print("unhandled OrderStatus variant Paid (arm written by glyph fix)")
      process.exit(1)
    },

// glyph check --no-tsc: 2 module(s) checked, no diagnostics.

The arm leaves through process.exit because that is the one body legal in both positions: it returns never, which contributes nothing to the arm join, so the same text works whether the match owes a value or not. The obvious alternative was measured rather than argued about, and assert(false, "unhandled") in a value-position arm returning string fails tsc with TS2322: Type 'void' is not assignable to type 'string'. A repair that turns one error into another is not a repair.

The union above is declared in another module, and that case was broken until a review caught it before this was tagged. The rule used to check its own work by resolving the candidate file alone, where an imported variant is an unresolved name, so it declined every cross-module match. It checks against the project now, and it writes the variant imports the arms need, or the namespace spelling orders.Paid({ transaction_id }) when the module is imported as a namespace.

Every byte the rule writes is in the report, imports included. Nothing here guesses, so every rule that cannot settle an answer says so and why on the same output as what it applied. Two fields a character away from the typo are two, not one. A union no project module declares has no payload shapes to write patterns from. A variant name already bound in the file by something else says so by name.

The third piece is glyph --explain <CODE> --json, which hands over the code's explanation as data along with a wrong program from the compiler's own negative corpus and the diagnostic this compiler draws for it, built when you ask rather than stored. That includes E0011, the code this release adds: a new code with no wrong program behind it is the one an agent has never seen.

glyph --explain E0200 --json
{
  "code": "E0200",
  "title": "non-exhaustive match",
  "explanation": "A `match` over a tagged union must handle every
    variant. Unions are sealed (D9) ...",
  "help": "Add an arm for each missing variant, or an `else` arm ...",
  "docs": "https://github.com/chadetov/glyph/blob/main/docs/error-codes.md#e0200",
  "counter_example": {
    "name": "alias_of_local_union_not_exhaustive",
    "files": [ { "path": "...glyph", "source": "module main\n\ntype Shape =\n ..." } ],
    "diagnostic": { "code": "E0200", "missing_variants": ["Tri"], ... },
    "corrected": null,
    "corrected_absent": "`tests/negative/` pairs no repaired program with a
      case: an entry is a wrong program and the code it draws. A fix invented
      here would compile nowhere and be checked by nothing."
  }
}

Two more tools, and one answer that was claiming more than it knew

glyph_dependencies answers what one declaration depends on: every declaration its own source names, split by CALLS, REFERENCES and FIELD_ACCESS, each edge with both ends and where it sits. glyph_exports answers what a module makes visible to an importer, straight from the compiler's export query: every pub declaration and every variant a pub union hoists. That is eleven tools, each with a glyph query verb.

Building the mirror of an existing relation is what turned up a bug in the original. Every edge carries a provenance, and PROVED is supposed to mean the compiler read both ends. It was answering PROVED for an edge into a module that does not parse, where nothing had been read at all. It answers UNDETERMINED with the reason now, and because glyph_references and glyph_impact read the same function, both were wrong the same way and both are fixed.

Hover answers at every name declared in another module now, which is the last four positions of a fourteen-position audit and makes it fourteen of fourteen. The predicted cause was wrong and it is worth saying so: the guess was that the member access on an imported record was missing from the checker's type map, and it was not. The tool was reading the file without its project. The editor's own hover is not fixed by this: glyph lsp registers the buffers an editor opened rather than a project, so it still answers null at an imported name, and that is now tracked as its own finding.

Two cells of glyph_impact's signature-type table said the checker had no rule for a pairing the checker decides, one for a container against a container and one for a structural record against a structural record. Both are decided by the same comparison glyph_assignable makes, rather than by a sentence written into the table, so the two tools cannot answer differently about one pairing. The word in the impact table is SAFE, because that table answers about an edit at a site while glyph_assignable answers about two types with no site in the question.

One thing did not get fixed and is worth naming here rather than in a tracker: glyph fix cannot repair a match over Result or Option. No identity keys a declaration the prelude owns, so no tool can hand the rule the payload shapes it writes patterns from, and it declines with that sentence rather than guessing. match r { Ok(v) => v, } is the most common shape this rule exists for and it is the one shape it does not repair.

Breaking. Two things reject programs 0.1.121 accepted, and both were established by running the published 0.1.121 and this release on the same programs under an isolated HOME. The four-statement Mode program above exits 0 under 0.1.121 with four warnings and exits 1 here with E0204, E0204, E0211 and E0204. Every such program already failed a full glyph build, because tsc caught all four, so what changes is which compiler tells you. Thirteen correct programs that write a string-literal union in every position we could think of, including Array<Mode>, Option<Mode>, a record field, a match in value position and a generic call, were run under both binaries and pass under both; they are in the suite as a corpus, because a sweep that looks only for newly caught programs cannot find a newly refused one. The nesting limit is breaking in the same direction and loses nothing: the 2,000-deep file aborts under 0.1.121 with exit 134 and reports E0011 here with exit 1, and no program that reached the old behaviour ever compiled.

0.1.121 Breaking September 2026

Three programs that used to build clean now don't

A prelude container assigned to a primitive was silent. Option<int> into a string, Nullable<int> into an int parameter: glyph check --no-tsc said nothing about either, and tsc only caught the first. The Nullable case is the one tsc can't make, because the emitted const v: number | null = 3 narrows back to number before the call is checked, so the Glyph checker was the only place it could be caught and it wasn't.

main.glyph
fn takes_int(n: int) -> int {
  return n
}

fn main() {
  let o: Option<int> = Some(3)
  let x: string = o
  let v: Nullable<int> = 3
  let y: int = takes_int(v)
}
// 0.1.120: builds clean
// 0.1.121: [E0204] expected `string`, found `Option<number>`
//          [E0211] expected `number`, found `Nullable<number>`

Five containers are covered in both directions: Option, Result, Array, Record and Nullable are never a string, a number or a bool, whatever their arguments. Nullable<T> against a scalar is decided one level in against T, so let v: Nullable<int> = 3 stays correct and let v: Nullable<int> = "three" is E0204. Error messages print an application's arguments now: "found Nullable<number>", not "found Nullable", which named the container and dropped the thing you have to check. An alias of a container isn't covered yet: type MaybeInt = Option<int> passes silently where the container itself is refused, because the alias chain doesn't follow a body that is a generic application.

The third program is the one a newcomer writes first. Order { id: "a", total: 1 } is the TypeScript-adjacent guess for constructing a record, and Glyph has no such form. What the compiler used to say about it was that some code was unreachable, on a build that exited 0: the name resolved to the type, and the braces parsed as a block after the return.

orders.glyph
fn make() -> Order {
  return Order { id: "a", total: 1 }
}
// 0.1.120: [E0108] Warning: lint: unreachable code   (exit 0)
// 0.1.121: [E0228] `Order` is a type, not a value; a value is the record
//          literal `{ id: ..., total: ... }`, with the type on the annotation

The message is read off the declaration, so a record lists its fields, a tagged union lists its variants with their payload shapes, and a string-literal union lists its literals. Order.parse(raw) and Order.is(v) are still legal, because the receiver of a type's own descriptor is the one expression position a type name belongs in. Raising the error is also what takes the unreachable-code warning off, since the lint tier only runs on a module that produced no errors.

One call describes a symbol, and every query has a command-line verb

An agent that wants to write a match over a union it didn't declare had to piece the union together from hover, symbol search and a file read. glyph_symbol answers it in one call, addressed by the module::name a diagnostic already prints or by a position in a file. It returns the kind, the identity, whether it's exported, a record's fields with their types, a union's variants with their payloads and the syntax that constructs each, a callable's parameters and return, an interface's members, whether a match over it must be exhaustive, and the @example text written above it.

glyph query symbol --path src/main.glyph --entity orders::OrderStatus
{
  "entity": "orders::OrderStatus",
  "kind": "union",
  "pub": true,
  "type": "Pending | Paid({ transaction_id: string }) | Cancelled",
  "exhaustive_match": true,
  "variants": [
    { "name": "Pending",   "payload": null,
      "construct": "Pending" },
    { "name": "Paid",      "payload": "{ transaction_id: string }",
      "construct": "Paid({ transaction_id: string })" },
    { "name": "Cancelled", "payload": null,
      "construct": "Cancelled" }
  ],
  "path": "src/orders.glyph",
  "origin": "glyph"
}

Every fact the compiler doesn't hold comes back as null with a <key>_absent sentence saying why, so you never have to tell a missing answer from a symbol that has none. An unannotated const reports no type, for instance, because the checker deliberately doesn't infer a declaration's type from its initializer, and printing the initializer's type would claim uses are checked against something that checks nothing.

Hover picked up the same query and now answers at declaration names, parameters, annotations, variants and a binding's definition site, not just at expressions: thirteen of the fourteen positions an audit probed on a three-module project, against two before. Those fourteen positions are a test now, each with the exact string hover answers or the null it doesn't, so the ratio moves only when the code does. The fourteenth is a field read from an imported record, and it's one of four places hover answers null at a name declared in another module: the import binding, an imported function where it's called, an imported variant used as a value, and that field read. An imported type written as an annotation does answer. glyph_variants carries each variant's payload and construction syntax, glyph_definition carries the identity so its answer chains into glyph_impact, and glyph_symbols carries the identity, pub, the kind and a one-line signature, with an interface reported as an interface rather than as a type.

There's also glyph_assignable, which asks the checker's own comparison whether a value of one type can go where another is declared, and answers WILL_FAIL with the codes the pairing draws, COMPATIBLE where a rule accepts, or UNDETERMINED where no rule covers it. And all nine tools now have a command-line verb, so an agent with no MCP client runs glyph query symbol, glyph query impact, glyph query assignable and gets the same bytes over stdout.

If you parse glyph_diagnostics, read the diagnostics key

The MCP tool used to type-check one file's text on its own, so anything that needed another module was invisible to it: a wrong field on an imported record came back as an empty list, and a match missing arms over an imported union came back as two unused-import warnings naming the very variants the missing arms would have named. It now checks the file inside its project and hands back the same structured diagnostic glyph check --json prints, from the same Rust type, with a test that runs both surfaces on six projects and compares the JSON key for key.

The answer is an envelope rather than a bare array, because what the check couldn't read is part of the answer: diagnostics holds the list, member says whether the project walk reaches the file, unindexed names project files that don't parse, and not_run names the three things glyph check does that this doesn't: tsc, E0104 for an import naming no module, which needs the build's view of node_modules, and E0400 for a failing @example, which is decided by running the emitted project. A client that read the old top-level array reads diagnostics now.

Breaking. The three programs above turn a previously green build red under glyph check --no-tsc. Established by running the published 0.1.120 and this release on the same programs under an isolated HOME: the Option<int> assignment, the Nullable<int> argument and the Order { ... } construction each exit 0 under 0.1.120 and 1 under this release, reporting E0204, E0211 and E0228 in turn.

0.1.120 Breaking September 2026

An imported union passed where a string is declared, without tsc

A function imported from another module and a value assigned across that boundary used to get a pass that the same code in one file never got. Passing a union imported from a sibling module where a plain string parameter is declared drew nothing from glyph check --no-tsc; the identical call with the union declared locally has reported E0211 since 0.1.116.

main.glyph
import lib { Status, Pending }

fn shout(s: string) -> string {
  return s
}

fn main() {
  let st: Status = Pending
  shout(st)
}
// 0.1.119: builds clean
// 0.1.120: [E0211] expected `string`, found `Status`

Two more of the same shape close alongside it. A match over a local alias of an imported union (type Local = Shape, then match s: Local { ... }) was not exhaustiveness-checked at all; it now gets the same E0200 a match over Shape directly has always gotten. And import std/string, the namespace form the stdlib reference recommends, bound the name string without the checker noticing: let x: string = 5 built clean under that import, while the named form import std/string { trim } caught it. Both forms now report E0204. Separately, a user program that declares its own type Option<T> no longer gets the prelude Option's runtime check stapled onto a type of its own.

What changed underneath: the emitter reads the checker's answers instead of computing its own

All four of the above are one cause. The emitter used to re-derive semantic facts the checker had already worked out: which declaration a name resolves to, whether a field exists on a type, whether a bare identifier in a match arm names a variant or binds a value. An audit of the emitter found 49 sites doing this across six families. Three changes remove them: the emitter now holds the same resolver the checker built for the module, so an imported declaration is read once instead of independently re-discovered; it walks one alias chain instead of four separate copies, and decides a field's runtime check from the resolved type rather than a name's last segment; and it reads the checker's own record of which pattern nodes are variants rather than guessing from capitalisation. Landing each of the three changes is what surfaced the four holes above, because the checker had never been asked those questions from the emitter's position before: the emitter was answering them itself.

No program that compiled before emits different TypeScript. Every application under examples/apps/ at its own root, plus the full corpus, was emitted before and after each of the three changes: 684 TypeScript files across 32 roots, byte-identical every time.

Breaking. The four items above each turn a previously silent program red under glyph check --no-tsc. Established by running the published 0.1.119 and this release on the same four programs, under an isolated HOME: the imported-union-as-string call, the match over a local alias of an imported union, and the namespace-import shadowing case each exit 0 under 0.1.119 and 1 under this release, reporting E0211, E0200 and E0204 in turn; a control program that imports and matches a union correctly stays exit 0 on both.

0.1.119 September 2026

The corpus, checked against its own formatter

80 of the 175 Glyph files under examples/ were not what glyph fmt produces: collapsed parameter lists, missing blank lines, a corpus that the diff-stability pillar points to without being in its own canonical form. Every file is reformatted, a second pass changes nothing, and every app and corpus file gives the same exit code and diagnostic count before and after. scripts/check_fmt.py runs glyph fmt --check over examples/ in the release gates and in CI, so the corpus can't drift again without the gate catching it.

The token-count benchmark, corrected to the fixtures that actually compile

None of the three Glyph fixtures behind the density claim was a program the compiler accepted: one used a regex literal Glyph doesn't have, one propagated the wrong error type out of a function, one only passed with tsc turned off. The comparison was Glyph that didn't compile against four languages that did. All three fixtures are rewritten to what Glyph offers, changing what each program does as little as possible, and now pass glyph check with tsc included. The number moves with them: Glyph is 436 tokens to TypeScript's 478 on the three functions, about 9% fewer, not the 29% measured on the fixtures that didn't compile, and it lands about level with Python rather than ahead of it. scripts/check_benchmark_fixtures.py keeps each fixture compiling, in the release gates and in CI.

A hang in the socket tests, and a deadline for every spawned run

The std/net integration test could hang for hours. TCP has no message boundaries, which is the very fact the test exists to check: the test program's client counted reads and exited on the second one, so when the two halves of a split message arrived in a single read on either side, the count never reached two and the client waited on an idle socket that was never going to produce another one. The client now sends its second half only after the first echo comes back and exits on content instead of a read count. Every glyph run the integration suite spawns, 24 call sites, now runs in its own process group under a 120-second deadline that kills the whole tree and reports the test name, the elapsed time, and the output captured so far if it fires.

E0101 names the three real import spellings

The help text for an unresolved import offered a form, myapp/feature, that reads like a package prefix no project has. It now names the three spellings a module can actually have: std/io for the standard library, a bare name for a sibling in the same directory, and the path from the source root for a file in a subdirectory. Writing the fix down found that no program ever reaches this help: a relative import (import ./helper) stops earlier, in the parser, as E0002 with generic help. That gap is scheduled for the diagnostics work below.

What's next

The roadmap now names the next three releases: an agent-native query API over the compiler's own facts, diagnostics reframed as a repair protocol an agent can act on without a second call, and a knowledge surface generated from the compiler rather than written by hand about it. The lanes are in docs/roadmap/releases.md.

Not breaking. Established by running the published 0.1.118 and this release on the same two programs, under an isolated HOME: a two-module program with a bare sibling import exits 0 on both, and a relative import (import ./helper) exits 1 on both, reporting E0002 on both.

0.1.118 Breaking September 2026

Every HTTP request now has a deadline, and a rejected one says so

std/http bounded nothing by default: get against a peer that accepted the connection and said nothing waited for as long as the process lived. All six verbs (get, post, put, patch, del, head) now carry a 30,000ms deadline unless the call sets its own, and a deadline past what setTimeout can hold is refused up front instead of firing after one millisecond and blaming the network. Filing that refusal under an existing HttpErrorKind would have been the wrong answer with confidence attached, so HttpErrorKind gains a fourth member, "argument": the request was never issued because one of its arguments was invalid.

main.glyph
match e.kind {
  "timeout" => io.println("timed out"),
  "network" => io.println("network error"),
  "status" => io.println("bad status"),
}
// 0.1.117: builds clean
// 0.1.118: [E0200] match is not exhaustive: missing case for "argument"

An explicit timeout_ms: 0 on a Fetch sent through send still means no timeout; that stays the one greppable opt-out. Nothing that compiles or runs today changes behaviour, except that a request nobody bounded now fails with kind: "timeout" after thirty seconds instead of hanging.

fold_while and try_fold, for a fold that knows when to stop

array.fold cannot stop early, so a search that knows when it is finished was either written as recursive functions threading an index, or as a fold that evaluates the whole array and looks correct doing it. fold_while checks a predicate before each element; try_fold stops on the first Err.

main.glyph
let total = array.fold_while([1, 2, 3, 4, 5], 0,
  (acc, x) -> acc + x, (acc) -> acc >= 6)
io.println(string.from(total)) // 6, stops before visiting 4 and 5

Both are typed through the existing signature table: a done predicate returning the wrong type, or a try_fold result matched with no Err arm, are diagnosed the same as any other stdlib call. There is no Step<A> continue-or-stop type; a generic stdlib union would be the first of its kind and a match over one gets no exhaustiveness check today, so the stop signal is a predicate the checker already knows how to type.

A line reader for std/fs

Reading a large file meant loading it whole. std/fs gains a handle-based reader: open_lines(path) -> Result<LineReader, FsError>, next_line(reader) -> Result<Option<string>, FsError>, and close_lines(reader) -> void. next_line returns a Result around the Option rather than the Option alone, because a disc error at line 400,000 reported as end of input is a silent truncation: a real read failure is Err, never a quiet None. Reaching the end and hitting an error both close the underlying descriptor, and close_lines is idempotent for an early stop. The reads are synchronous and block the event loop, the same way io.read_line already does on standard input.

A tsc error in a block's last statement now points at that statement

The emitter recorded a source-map checkpoint at the start of every declaration and statement, except the last statement of a block, so a tsc error there was reported at the statement before it. A caret that lands one line early is a line an agent repairing the error would edit instead of the one that actually failed. The tail statement now records its own checkpoint; the verdict a program gets is unchanged, only where the error points.

A resource reached through a type alias is still a resource

type H = Handle with resource type Handle = { fd: number } used to lose the ownership check: let owned a: Handle was tracked and a double consume was E0206, but let owned b: H was rejected outright as not a resource at all. The ownership check now follows the same alias chain the rest of the checker already follows, so an alias of a resource is accepted and tracked, and a drop through either name is caught.

Eleven stdlib modules, documented

store, task, set, path, crypto, math, encoding, log, collections, timers and websocket were shipped and importable with no signature listed for any of them. Every export now has a documented signature, checked against the runtime source and the resolver's own export list. Separately, url.join's Err case is documented: against a valid base the parser resolves almost anything as a relative path, so the failure comes from an unparseable base, not from the string being joined to it.

Breaking. The new "argument" HttpErrorKind turns an exhaustive match e.kind with no catch-all arm red. Established by running the published 0.1.117 and this release on the same program under an isolated HOME: check --no-tsc exits 0 under 0.1.117 and 1 under 0.1.118, reporting E0200. The alias-resource fix and the folds only accept programs that were previously rejected; the tsc position fix changes no verdict.

0.1.117 Breaking September 2026

Nullable<T>, for a field the wire sends as null

A field a real API sends as JSON null had no Glyph type to declare it with. Option<T> only decodes its own tagged form, and an optional field of the base type accepted null but gave the program nothing to match on. Nullable<T> is a new prelude type for exactly that field: it emits as TypeScript T | null, and its check accepts null or a T, rejecting the tagged Option encoding as a value of the wrong shape.

main.glyph
type Frame = { op: int, s: Nullable<int> }

import std/nullable

fn main() -> void {
  return match Frame.parse(json.parse("{\"op\":1,\"s\":null}")) {
    Ok(f) => io.println(string.from(nullable.is_null(f.s))),
    Err(e) => io.println("rejected"),
  }
}
// {"op":1,"s":null} and {"op":1,"s":3} both decode; {"s":{"tag":"Some","value":3}} does not

It is its own type, not an Option: assigning one where the other is declared is E0204 at a let and E0211 at an argument in either direction, and there is no match arm for Some or None on it. The bridge is explicit, std/nullable's to_option, from_option and is_null, one greppable call at the point null enters a program. T may not itself be Nullable or Option (E0227): the first would give two states one runtime spelling, the second would put a tagged object under a null-tolerant field. glyph gen dts and glyph gen openapi route a schema's nullable field to Nullable<T> instead of collapsing it to an optional field. Option<T> is untouched, so this addition rejects nothing that built before.

A bare-name type alias is a second name for its declaration

type B = A, with type A = { x: number }, used to declare a type of its own: a B passed where an A was expected was E0211 on a program tsc accepts, and b.naem on a b: B got no field check at all, while the identical two declarations imported from a sibling module answered both correctly. One spelling had two answers, decided only by which file it was written in.

main.glyph
type A = { x: number }
type B = A

fn takes_a(a: A) -> number {
  return a.x
}

fn main(b: B) -> number {
  return takes_a(b)
}
// 0.1.116: [E0211] argument type mismatch: expected `A`, found `B`
// 0.1.117: no diagnostics. B.parse is A.parse, and b.naem is E0210 as it already was on an a: A

Only a bare name is followed to the declaration it names; a record, a union, a generic application such as Record<string, T>, or a body carrying a where is still the declaration's own type, unchanged by this rule. A where over an alias of a record or union is now E0300 like the inline spelling, since the two are one type once the alias is followed: type Positive = Rec where value.x > 0 with Rec a record built clean before and is refused now.

gen dts anchors a class instead of naming one that doesn't exist

A field typed by a class the .d.ts declares, or by a host type Glyph has no spelling for (RegExp, Date, Map), used to materialize as a reference to a name gen dts never wrote, and the next build failed on it as an unresolved name. It now anchors each as a descriptorless extern_ts alias, so the reference resolves, tsc checks every method call against the real class, and a wire record holding one is still refused parse in the module that declares it, since nothing validated the class at runtime. Every interface member the reader still drops, a method signature among them, now gets a note naming the owner and the reason, and every declaration gen dts, gen zod and gen openapi write is pub, so a generated module can finally be imported by name from the module that owns the boundary.

An imported const and an imported union, closed

A cross-module pub const was typed by its annotation in the module that declared it and Ty::Unknown everywhere it was imported, so ORIGIN.rowz on a Sheet with no rowz field said nothing one import away. It is now E0210 naming the record on both sides of the boundary. Separately, a lowercase nullary variant nested under a payload-carrying variant of an imported union, B(alpha) over an imported tree.G, stopped a valid build at E0305; a new per-variant payload registry resolves it the same way in the CLI, the playground and the MCP server at once.

Two findings from this round are recorded open, not fixed: E0304 only refuses an unverifiable record in the module that declares it, so the same Doc.parse call from a sibling that imports Doc reports nothing (G210); and a tsc error is mapped to the Glyph statement before the one that actually failed, so a caret follows a reader to the wrong line (G211).

Breaking. G205 (the imported const), G207 (the alias where) and the alias hop through G201 each turn a previously silent program red; G91 (Nullable) and G147 (the nested variant) only accept more. Established by running the published 0.1.116 and this release on the same five programs, under an isolated HOME: a where over an alias of a record, and an imported const's misspelt field, each go from exit 0 to exit 1; a B passed where an A is declared goes from exit 1 to exit 0; a Nullable<int> field goes from exit 1 (no such type) to exit 0; and a program using Option<int> alone stays exit 0 on both.

0.1.116 Breaking September 2026

The checker stops accepting five things it shouldn't

Passing a value of a declared union or record where a string, number or bool is declared drew nothing from glyph check --no-tsc, while the same call with a bool argument was E0211. Only tsc caught the union:

main.glyph
type PaymentResult = | Settled | Declined

fn takes_string(s: string) -> string {
  return s
}

fn main(r: PaymentResult) -> string {
  return takes_string(r)
}
// 0.1.115: glyph check --no-tsc says nothing. tsc catches it at build time.
// 0.1.116: [E0211] argument type mismatch: expected `string`, found `PaymentResult`

The reverse direction is caught too: a string, number or bool passed where a locally declared union or record is expected. The one exclusion is the zero-field record type T = { }, which TypeScript lets a plain string satisfy, so Glyph does not disagree with a program tsc --strict accepts.

A bare PascalCase match arm naming a variant of the wrong imported union used to escape as a resolver error rather than a checker one:

app.glyph, matching an Answer imported from model
match a {
  Yes => 1,
  No => 0,
  Zed => 2,   // Zed is a variant of model's Mood, not of Answer
}
// 0.1.115: glyph check --no-tsc says nothing.
// 0.1.116: [E0220] `Zed` is not a variant of `Answer`; did you mean `Yes`?

That is the same diagnostic the module-local check has always raised for an unknown arm head; the imported path now raises it too, with the same nearest-variant suggestion.

Two positions around module-level const are checked for the first time. An annotated const is typed by its annotation, so a wrong field on it is now E0210 the way the identical program with a let already was. And a const's own initializer is checked against that annotation, so const LIMIT: number = "ten" is E0204 instead of compiling clean and only failing under tsc.

Breaking, and deliberately so. Each of these starts catching a program the checker used to wave through, so a green build can go red on upgrade. Established by running the published 0.1.115 and this release on the same four programs, under an isolated HOME: the union argument, the const initializer, and the wrong-union match arm each go from exit 0 to exit 1; a fourth program using the same union correctly stays exit 0 on both.

What a signature change breaks, for every argument

glyph_impact's change_signature_type used to answer every call site NOT_INDEXED, because the checker itself drew no conclusion for a named type against a primitive. Now that the checker compares that pairing, the tool answers per argument: WILL_FAIL with E0211 where the checker's own rules would reject the call, UNDETERMINED with a stated reason where they don't (a type declared in another module, an argument the checker holds no type for, the empty-record exclusion), and NOT_INDEXED only for a function read as a value rather than called. A site is the weakest of its arguments.

What a file's address is, decided

A file's own module line and the path the project keys it by could disagree with nothing saying so, and in shipped code that silently misdirected an exact-or-absent answer to the wrong file's declaration. The ruling: the path is authoritative, and a disagreeing header is not a key. The diagnostic that enforces this inside a marked project is recorded as owed work, not shipped in this release.

A positional variant pattern such as Node(c, k) is rejected in the parser now, sharing E0010 with the declaration spelling, so the emitter can no longer call the same shape unimplemented one line after the parser called it nonexistent. Not breaking: the program was rejected before and is rejected now, with a diagnostic that says why.

The site gained a new answer page, what a change will break before you make it, and deepened the existing one on silent breakage with the two holes this release closed.

0.1.115 September 2026

The language server stopped running a second compiler

Every request to glyph lsp, a keystroke, a hover, a find-references, re-ran the whole front end on a bare string: parse, symbol collection, prelude construction, resolution, and type assignment, every time. A hover cost the same as a fresh diagnostics pass. The server now owns its own CompilerDb, fed by the editor's buffers rather than disk, and a second request over an unchanged buffer executes zero queries.

The server never shares the MCP server's database. Sharing would let an unsaved keystroke rewrite the input the agent's disk-truth answers are computed from, and buffer state and disk state are kept apart on purpose.

A regression, found before anyone else could

The first measurement of the overlay showed a keystroke getting slower, not faster: 15.60ms rose to 33.39ms. The cause was the per-declaration fill layer beneath it, decl_ast cloning every declaration's source bytes and resolved_decl cloning a sliced module per declaration, work that pays for itself across a build but was being paid in full on every keystroke. That layer was fixed; resolved_decl fell from about 15ms per call to 0.29ms, and the overlay shipped unchanged underneath it.

Measured back to back over the real protocol on the 2,205-line minilang example: the keystroke went from 15.96ms to 13.78ms, the editor burst from 64.38ms to 16.97ms, and a repeated workspace-wide references from 13.96ms to 1.44ms. The full per-file table is in benchmarks/lsp-latency/README.md, and the harness that produced it ships under benchmarks/lsp-latency/ so the next measurement is a command rather than an excavation.

One number moved the wrong way: the keystroke's growth exponent, from n^1.49 to n^1.64, because the smallest file gains 30% and the largest gains 14%. Every measured size is faster than before; whether the exponent matters past 2,205 lines is not something this sweep can say.

Not breaking: 0.1.114 and 0.1.115 accept and reject the same programs, checked by running both.

0.1.114 September 2026

Editing one declaration no longer invalidates the others

The compiler's per-declaration answers were keyed by position, so adding a function at the top of a file threw away what it knew about every function below it. They key by name now, and an edit above a declaration leaves that declaration's work intact.

Rewriting a file with identical bytes also cost a full rebuild. The incremental layer compares before it writes, so a tool that formats a file and changes nothing pays nothing.

What this release deliberately does not contain

A third change was planned and is not here. The plan said every relation the agent work depends on sits downstream of one map, so rekeying that map came first. That turned out not to be true of the current code, so the change would have moved nothing anyone can observe. It was left undone, and the wrong claim stays on the record marked wrong. A release that ships tidiness while calling it progress is worse than a smaller one.

A measurement that changed its own argument

Locals are excluded from the semantic model, and the recorded reason was that no question this work answers is about a let. That had never been checked. Measured across the real corpus: 71.7% of diagnostics land inside a function body, and code lines are 72.8% bodies, so diagnostics are not concentrated at declarations and the stated reason was failing its own test.

But 91% of those body diagnostics already name the declaration they sit in, and only 8.7% have a span that binds a local. The rest sit inside a body while addressing a union, a field, or a callee, all of which are already known. So the exclusion holds for a different reason than the one written down: an agent repairing a body diagnostic is told which declaration to open, and the identity it needs next is almost never the local. The conclusion survived, the argument did not.

0.1.113 September 2026

The playground was showing you different TypeScript

The compiler and the playground assembled the emit context separately, and the playground assembled an empty one. The same source file produced three differences, and one of them mattered:

Row.is, from glyph build
(value as Record<string, unknown>).kind === "red" || ... === "blue"
Row.is, from the playground
(value as Record<string, unknown>).kind !== undefined

The page was showing a validator that accepts { kind: 42 } for a type whose declaration forbids it, and Row.parse had no rejecting branch at all. For a language, that is not a rendering difference.

One scan, and an honest note where it cannot reach

The scan behind the six emit tables now lives in one place and both surfaces use it. The emitted bytes are unchanged on both sides, and the compiler lost its hand-rolled copy.

That does not make the two outputs identical, and it cannot. Every one of those tables is keyed by the source module of an imported name, so all six answer questions about sibling files, and the playground holds one file. The obstacle was never the database: the scan is a pass over parsed syntax and takes none. It is that the other files are not in your browser.

So the playground names what it is assuming. Every import that is not std or extern is listed, with what glyph build would write instead. A module that imports only std lists nothing and the notice does not appear, because absence has to mean absence here too.

The diagnostic half is fixed outright rather than disclosed: the playground runs the same lints and reads severity from each diagnostic, so an unused import arrives as a warning the way it does from glyph check. Two comments that claimed the playground ran the same front end as glyph build now say what is actually true.

0.1.112 September 2026

Ask what one change breaks, across every relation that carries it

glyph_impact takes an entity and a change, not just an entity. That is the design: a verdict is a fact about an edit, and with no edit named every answer degrades into a list of things that mention something.

glyph_impact { entity: "value::Value", change: { kind: "add_variant", variant: "Blob" } }
WILL_FAIL  value::render         value.glyph:18     E0200
WILL_FAIL  value::key            value.glyph:32     E0200
WILL_FAIL  bind::literal_kind    bind.glyph:148     E0200
ABSORBS    exec::total           exec.glyph:130
ABSORBS    render::literal_text  render.glyph:128
8 WILL_FAIL, 2 ABSORBS, across 4 files

That is the set the compiler reports when the case is actually added, and a committed benchmark checks the two against each other on every run.

What it refuses to say

Six change kinds, each mapped to the one relation that carries it. The mapping was measured rather than chosen: seven change kinds were probed and every diagnostic landed at a site naming the changed entity, none a hop further, because Glyph never infers a declaration's type from its body. A request for a second hop is answered with the exact first-hop result and a next_query naming the question that would also be exact.

Changing a parameter's type returns nothing but NOT_INDEXED. Passing a declared union where a string is expected produces no diagnostic, where passing a bool does, because the checker reports only a mismatch it can prove. Those call sites are neither broken nor safe, and claiming either would be asserting something the compiler never established.

Building the gate for this found a live defect. UNDETERMINED was returned for any match site with an arm the checker does not model, on the reasoning that the unread arm might absorb the new case. It cannot: such an arm never counts as covering anything, so a case no arm names is missing regardless. Those sites fail to compile and now say so. A verdict meaning "I could not decide" is useful only while it stays rare, and it had begun collecting cases the compiler decides plainly.

0.1.111 September 2026

The loop is proven, not asserted

An agent adds a case to a union, the compiler names what broke, a query names a site the compiler never mentioned because it keeps compiling, and the tree goes green after the named sites are fixed. That had been walked once by hand and tested nowhere. It is six tests now, over the real process.

The loop test was the one easy to write dishonestly, so the honesty is structural rather than a promise. Its fixture hands back paths and counts and nothing else, so a test cannot spell the union, its module, or the variant: those names are not in scope. They also carry a per-run suffix, so a hard-coded one fails instead of passing quietly. Each hop is fed from the previous answer.

Three deliberate breakages prove it bites. The one that matters: repair only the sites the compiler reported, and the tree still goes green, but the test fails because the edits did not go where the answer pointed. A test asserting only that the build passes would have missed it, and that silent site is the entire reason for this work.

The surface an agent talks to, tested for the first time

Every test called the server in process, so the transport was never exercised. The instructions string shipped in 0.1.107 appeared in no assertion at all, which was checked by deleting it and watching 1312 tests pass. The handshake is now exercised as an ordered exchange over a spawned process, and three kinds of line that are not requests get no answer while the server stays up.

The demonstration is a committed benchmark rather than a story. On a CSV query engine in this repository, eleven files, adding one case to the union at its centre: ten match sites across four files, eight that fail and two that absorb, and the compiler afterwards reporting exactly those eight. A text search asked the same question finds twenty catch-all arms, two of them relevant, and says nothing about the eight that break.

0.1.110 September 2026

Ask what a change breaks, before you make it

Until now you could ask which match sites exist over a union. You can now ask what happens if you add a case to it, which is the question anyone actually has.

glyph_variants { name: "PaymentResult", proposed_variant: "Pending" }
PaymentResult
  |-- Success
  |-- Failed
  `-- Pending   <- new

WILL_FAIL    billing::settle    src/billing.glyph:5
ABSORBS      billing::audit     src/billing.glyph:12
WILL_FAIL    billing::report    src/billing.glyph:20

The second line is the one that matters. billing::audit has a catch-all, so it keeps compiling and routes Pending wherever that arm points. No build, no test and no type error will mention it. Fix the two failures and stop, and you have a green tree and a live bug.

Four words, each meaning one thing. WILL_FAIL, ABSORBS, NOT_INDEXED for a site the compiler could not key, and UNDETERMINED for one it keyed but drew no conclusion about. That last word was not in the original design and it earns its place: a site with an arm the checker read nothing from is keyed, so calling it not-indexed would state something false.

The answer stays honest when the program is not

A diagnostic now carries the union it is about and the variants it leaves unmentioned as fields, beside the prose rather than instead of it. Getting from an error to the next question no longer means running a regex over an error message.

Asking about a record used to answer with an empty site list, which is shaped exactly like a union nothing matches on. "The question does not apply" and "nothing is affected" were spelled the same way. And glyph check --no-tsc reported no diagnostics on a program importing a module that does not exist, while the impact surface, asked about the same file, correctly said it could not resolve the type. One unresolvable name, two surfaces, one admitting it did not know and one saying everything was fine.

Relations that answer the same way whichever way you spell the question

glyph_references dropped a namespace-qualified call: import render { label } found three sites, import render with render.label(s) found one. Not an empty answer, which would have been visibly wrong, but a short one that looked complete. CALLS is now distinct from REFERENCES, an edge the compiler proved is kept apart from one a .d.ts asserted, and a record field is addressable as module::Type.field so renaming a field has an impact set.

The exact-or-absent gate went from five invariants with two known failures to thirteen with none. It fails when a known failure starts passing, so neither of those could be fixed without promoting its case to a hard assertion in the same change. What is not here: the tests that prove the repair loop closes, and the demonstration as something you can run rather than read. Both move to the next release.

0.1.109 September 2026

The folds you were writing by hand

std/array gains max, min, sum, max_by and min_by. Picking the highest-scoring element is the core operation of every search, ranking and scheduler, and until now it was a four-line fold each time. One application in this repository wrote it five times in one file.

before
let best = array.fold(moves, None, fn(acc, m) {
  return match acc {
    None => Some(m),
    Some(b) => match m.score > b.score { true => Some(m), false => Some(b) },
  }
})
now
let best = array.max_by(moves, fn(m) { return m.score })

Four of the five return Option, because an empty array has no maximum and answering 0 or negative infinity is the kind of quiet wrong answer this language exists to prevent. sum returns a plain number: the sum of no numbers is 0, which is a real answer rather than a stand-in. On a tie, max_by and min_by return the first element, and that is documented rather than incidental.

The hand-written version was never wrong, just long, and long is what gets mistyped. Every one of those folds type-checks whether or not its seed and its comparison direction are right, so the compiler could not help you with the version you were writing before. Nothing that compiled on 0.1.108 stops compiling here.

0.1.108 Breaking September 2026

The JSON surface stopped saying a broken project was fine

glyph check --json ran no @example gate at all. The same project, the same flags, two answers:

0.1.107
glyph check src --no-tsc          example failed: main::add example #1   exit 1
glyph check src --no-tsc --json   {"ok": true, "errors": 0}            exit 0

The gate ran on the path a person reads and not on the path a tool reads. glyph check's promise is that it cannot report a clean tree that build would fail, and under --json it did exactly that. This is why the release is marked breaking: a project whose example was failing silently now fails out loud, so a green build can go red on upgrade. That is the fix working.

Two more holes of the same shape went with it. A project whose augmented copy would not compile reported "1 example(s) passed" and exited 0. A malformed @example was reported as a missing tsx install. Failures now carry structure rather than prose: a code, the declaration they belong to, and the detail, taken from values the compiler already had instead of parsed back out of its own output.

One declaration, one spelling

0.1.107 gave every diagnostic the declaration it sits in, and gave it three spellings, because three surfaces measured the module half from three different roots. On the layout glyph init writes:

before
glyph check --json   a::f
glyph_diagnostics    src/a::f
after
glyph check --json   a::f
glyph_diagnostics    a::f

The rule now lives in one place: a declaration's module half is measured from its project, and from its own directory when no project marks it. Neither answer changes with where you ran the tool from, which was the whole defect. The fallbacks disagreed too, and a comment claimed they could not.

A diagnostic the compiler could place no longer says it cannot

An annotation's text sits in the gap before a declaration starts, so the walk that assigns a declaration found nothing for an unknown-annotation error and reported none. But the checker raises that error while holding the declaration. It carries the name now, from where it was already known. Absence of an answer is supposed to mean there is no answer, not that we computed one and dropped it.

The example gate also left a copy of your project in the temp directory on every run, since it removed that copy only on the way in and never on the way out. Four thousand had built up on one machine. It surfaced as a flaky test: with that many orphans around, the operating system reuses a process id and a new run collides with a directory left days earlier.

0.1.107 September 2026

An autofix that breaks a file now says so

glyph fix could remove a dead name from an import and leave the file unparseable, while exiting 0 and reporting success. It has done that since 0.1.99.

before
import helper { one, two, three }
fn main(argv: Array<string>) -> number {
after, on 0.1.106
import helper { one }fn main(argv: Array<string>) -> number {
glyph fix:   removed 2 unused import(s)          exit 0
glyph check: [E0002] expected newline after import   exit 1

An import's span ends past the newline that terminates it. Removing a whole import walks that back; the path that prunes individual names, added in 0.1.99, spliced over the raw span and took the newline with it. It survived only when a blank line happened to sit below the import and absorb the loss, which is why the existing tests never saw it.

Both paths now share one helper, so the terminator is preserved rather than reconstructed. Separately, glyph fix re-parses every file before writing it and refuses to write one it broke. That second change is the one that matters: it catches this whole class whatever causes it, and an autofix that can corrupt a file should never be the thing that tells you it worked.

The compiler tells an agent how to update itself

The MCP server now sends an instructions string at connect time, which clients put in the model's context without anyone reading a file. It names glyph_variants, glyph llms, and the two update commands: glyph --update for the installed tool, glyph upgrade for a project's pin.

That channel exists because documentation alone did not carry. glyph --update was already in the bootstrap, and the next agent that needed it still reached for npm.

Two more places a tool answered without saying what it left out

glyph_variants could drop a match site entirely. A site that reaches the queried type through a payload, in a file whose module line disagrees with the path the project keys it by, appeared in none of the three lists the answer returns: not sites, not nested, not unkeyed. The top-level case already had a name-only fallback and the nested case did not. It reproduced on the layout glyph init generates, which is how it went unnoticed.

And a diagnostic now carries the declaration it sits in, as module::name, on both glyph check --json and glyph_diagnostics. An agent that gets an error can go straight to glyph_variants or glyph_references for that entity without re-parsing the file to work out where it was.

Six things the reference got wrong

From an outside author who wrote 1,430 lines of Glyph having never seen it. The gotcha on mut read as "you cannot mutate through a parameter", which is false and would force every state transition to rebuild a whole record; it now says mut is not a declaration modifier and that a parameter's fields are mutable in place. A sibling module is imported by its bare name, which E0101's help implied otherwise. rng.bool lost the ? that says its argument is optional. std/random sat in the "not detailed" list while being detailed. And the two ways to read an undocumented module's signatures are now written down, including that the emitted runtime holds only the modules you already import.

Nothing the compiler accepts or rejects changed. A program that compiled on 0.1.106 compiles here, and one that failed fails the same way; the difference is only that a file glyph fix touches is still valid afterwards.

0.1.106 September 2026

Ask what an added variant would break, before you add it

The compiler worked out which match arms name which variants on every build and then threw the answer away. It keeps it now, and glyph_variants hands it to an agent.

glyph_variants { name: "Status" }
main::strict  line 16  exhaustive       Open, Closed, Archived
main::label   line  8  has_catch_all    Open, Closed, else

then add Suspended to Status
main::strict  E0200: missing variants `Suspended`
main::label   compiles, and routes Suspended into else

The second one is why the distinction is worth having. A match that fails to compile tells you where to go. A match with a catch-all takes the new variant silently and does whatever the catch-all does, and there is nothing to see.

An answer names the declaration, the scrutinee as you wrote it, and the line. Sites the relation cannot key are listed rather than counted, because leaving one out would claim there is no relation when the truth is that the compiler could not tell.

The relation is called mentions rather than covers, and the difference is real: for most arms the compiler knows the arm names a variant without concluding it handles every value that variant can hold. No diagnostic changed in this release. Across 31 applications, the corpus, the negative tests and the paired demos, the compiler says exactly what it said before.

0.1.105 September 2026

A match that could not compile no longer type-checks clean first

Seven places in the checker each decided whether a match had a catch-all arm, and they did not agree. Four of them treated a capitalised bare name as one, on the reasoning that a boolean has no constructors so the name must be a variable.

main.glyph
match ready {
  true => 1,
  Red => 2,
}

// before: no error here, then at emit time
//   E0300 emission for a match mixing literal and
//   variant patterns is not implemented yet
// now:    E0209 non-exhaustive match on bool:
//         `false` not covered

The reasoning was wrong, and the rest of the compiler had already settled it: a capitalised name in a pattern resolves as a reference, not a binding, and every emitted form turns it into a tag test. A pattern that binds nothing and tests a tag absorbs nothing, so calling it a catch-all certified a match the emitter could not build. The compiler was saying your program was fine and that it could not compile it.

All seven now ask one question, and it takes the strict answer wherever two readings were defensible.

Nothing that compiled before is rejected now. Across 31 applications, the corpus, the negative tests and the paired demos, every diagnostic is unchanged and all 1,149 emitted TypeScript files are byte-identical. The cases that changed are ones the old checker passed and then failed later.

0.1.104 September 2026

Ask about a function by name, not by a line number that moves

An agent that recorded a position and asked again after an edit got a confident answer about the wrong thing. Inserting one function above another was enough.

before, against 0.1.103
glyph_references { path, line: 7, character: 3 }
  -> charge, and its one call site

// then `fn audit` is inserted above `charge`
glyph_references { path, line: 7, character: 3 }
  -> audit          // no error, well-formed, wrong entity

now
glyph_references { path, name: "charge" }
  -> charge, and its one call site, whatever moved above it

The position form is unchanged, because an editor has a real cursor and that is the right question for it. What was missing is the question an agent asks: the declaration called charge. Send both and they are cross-checked: if the coordinate has gone stale you are told, naming what sits at the position and what the name resolves to, rather than being handed one of them.

A name that no longer exists says so and lists what the module does declare. Resolving to whatever is nearby is the failure this exists to fix, so it does not fall back.

An @example failure now names the function it belongs to and numbers examples per declaration, so main::triple example #1 stays that even after you add an example to something else. Internally, a declaration has an identity built from its module and its own name, which is the convention three separate parts of the compiler had each arrived at independently.

0.1.103 September 2026

An agent asking where a symbol is used gets an answer in 15ms

The MCP server used to analyse every file in the project on every call. Asking where one function was referenced meant 175 full analyses to return three locations, and it cost the same on the tenth call as the first.

glyph_references, same question, warm
before:  183 ms   (175 files analysed, every time)
after:    15 ms   (answered from the compiler's own memo)

before:  192 ms   first call
after:   128 ms   first call, before any cache can help

The first call got faster on its own: finding occurrences never needed the type map it was computing, which was 47ms spent building 68,425 type entries and discarding them.

Answers still come from what is on disk right now. Every file is re-read and compared on each call, because measuring said that costs 9ms against a 169ms baseline, and the cheaper options trade correctness for milliseconds. A file changed by an editor, a checkout, or another agent is picked up on the next question.

A path is now resolved before it is used, so a symlinked or relative spelling of a file no longer answers differently from its real one, and a file that is not Glyph source is refused rather than treated as a module. The language server is unchanged: its source of truth is your unsaved buffer, not the file on disk, and those are different questions.

0.1.102 September 2026

Housekeeping, and a formatter that stops changing what code means

No new language features. The query layer moves to salsa 0.28, and four things that kept turning up during release checks are fixed.

before, and after
// glyph fmt turned this
0
match i >= n { true => { break }, false => {}, }?

// into a call, because it wrapped the match in parentheses
0(match i >= n { ... },)?

// a match delimits itself; the parentheses are gone

A match used as the target of ? or . was printed wrapped in parentheses it never needed. Formatting again read the leading bracket as a call on whatever preceded it, so the output meant something the input did not. This was present in every release up to now.

Installing on Alpine used to hand you a glibc binary and let the loader fail with a bare "not found". The launcher now recognises musl and says what is wrong. If it cannot tell, it proceeds as before, because a wrong diagnosis on a working machine is worse than the error it replaces.

The Intel Mac binary is now executed in CI rather than only built. Glyph's own stages are about 7% slower under the new query engine on a large project, which is invisible next to TypeScript's own type-checking in a normal build.

0.1.101 Breaking September 2026

A note on an example line no longer verifies a false claim

Writing a // note on the first line of a wrapped @example deleted the assertion. The example still ran, and still reported success, having checked something else.

main.glyph
@example has_bug(3)
  == false
@example has_bug(3) // a note
  == false

pub fn has_bug(n: number) -> bool {
  return true
}

// before: 1 of 2 example(s) failed  <- the noted one passed
// now:    2 of 2 example(s) failed

Two identical false claims, and only one was caught. The comment merged into the annotation's argument text, the assertion's right-hand side went with it, and an example with no comparison left is treated as asserting the expression is true. It fired in both directions: a module of nine examples had five false claims pass, and the same defect failed five true ones.

The parser now keeps a comment out of the argument text it captures, so the assertion survives. Formatting also reaches its final shape in one pass, and a comment written above one annotation stays with that annotation instead of moving to the one above it.

Also here: glyph build --watch rebuilds nested projects and catches edits that land mid-build, a tsc error is reported against the project that caused it, only the standard-library modules a program reaches are written out, and deleting a hand-written extern/ shim no longer leaves a stale copy that fails every later build.

0.1.100 September 2026

Reading a response body as text

A client had no way to ask for a response as text. The body arrives best-effort JSON-parsed, so printing it went through string.from and a JSON body rendered as [object Object], silently.

main.glyph
match http.to_text(response) {
  Ok(body) => io.println(body),
  Err(e) => io.println("no body: ${e}"),
}

// before: string.from(response.body) -> [object Object]
// now:    the exact bytes the server sent

Response now carries the unparsed body alongside the parsed one, mirroring what Request has always done for signature verification. The parsed value is lossy: a text/plain body of 42 parses to a number, so it cannot tell you what arrived. to_text reads the bytes instead.

Two items planned for this release are still open because both are API decisions rather than fixes: whether http.get should require a timeout the way tls.connect does, and how an Option field should read an explicit null from ordinary JSON.

0.1.99 Breaking August 2026

Your editor tells you the type is wrong

A let whose annotation disagreed with its value drew nothing from Glyph. TypeScript caught it at the end of the build and reported it against generated code, and the language server never runs TypeScript, so nothing said anything while you typed.

main.glyph
let x: string = 42

// 0.1.98: nothing in the editor.
//         [TS2322] at build time, about the generated .ts
// 0.1.99: [E0204] type mismatch: expected `string`, found `number`

The error is Glyph's now, at the span you wrote, and the editor shows it as you type. Checked against every application in the repository: all 31 still build with no new diagnostics.

  • Fixed glyph fix removes a dead name from an import list. It used to answer removed 0 unused import(s) on a three-name import with two dead names, reporting success while changing nothing, so the warnings looked unfixable.
  • Fixed E0001 names the escapes that work. It said "an invalid escape" and left you guessing; it lists \n \t \r \" \\ \u{HEX} now, and so does the agent reference, which never mentioned \u{HEX} at all. Someone wrote eleven lines of workaround for an escape that always worked.
  • Fixed std/random has its signatures in the agent reference, and Rng.bool's probability is optional, which is what its own comment had promised.
0.1.98 August 2026

A two-binding loop keeps the guarantee a one-binding loop has

Adding an index to a for used to cost you a compiler check, and the compiler suggested the thing that threw the check away.

main.glyph
type Cmd = { op: "set" | "clear" }

fn run(xs: Array<Cmd>) -> number {
  for i, c in xs {
    match c.op {
      "set" => io.println("set"),
    }
  }
  return 0
}

// 0.1.97: [E0218] non-exhaustive match on `string`
//         Help: Add an `else` arm.  ...which forfeits the guarantee.
// 0.1.98: [E0200] non-exhaustive match on `Cmd.op`: missing `"clear"`

The loop element had no type in the two-binding form, so a string-literal union read through it decayed to plain string. Writing for c in xs instead was checked correctly; adding the index was not. Each loop name carries its own span now, and examples/apps/sheet dropped the annotation it had been carrying for this.

  • Added E0112, a module nothing can reach. No pub, no main, and no import anywhere naming it. That used to build clean and surface later as a TypeScript error about a module with no exports.
  • Fixed An import your @example uses is not unused. The build runs those examples, so a name they reference counts, including one used only in a match arm's pattern or as a type argument.
0.1.97 August 2026

A function keeps its type across a module boundary

Calling a pub fn in another module gave back a value Glyph knew nothing about, so the answer came from TypeScript instead, about code you did not write.

main.glyph
import lib { make }

fn main(argv: Array<string>) -> number {
  let u = make("a")
  io.println(u.naem)
  return 0
}

// 0.1.96: [TS2339] Property 'naem' does not exist on type 'User'.
//         "This is a TypeScript back-end error mapped to your Glyph source."
// 0.1.97: [E0210] type `User` has no field `naem`

The compiler could already reach across a module for a type, a union and a string-literal union. A function had no equivalent, so every cross-module call answered "unknown" and every inference downstream of it stopped. TypeScript caught this particular typo at the end of the build and reported it against generated output; what changes is that Glyph answers it, at the span you wrote, naming the type.

  • Fixed A namespace import and a named import give the same answer. A nested pattern like tree.Node({ left: tree.Node({ ... }) }) compiled under import tree { Node } and was E0300 under import tree. Which spelling you chose decided whether your program built.
  • Fixed A match on a union whose payload comes from another module is checked. Leaving out one of the payload's variants built clean, passed tsc --strict, and threw at run time. It is E0200 now, naming the variant you left out. Found by review of the fix above, which had closed only the other direction across the same boundary.
0.1.96 August 2026

An array pattern that names a variant tests it, and does not bind it

A match over an array whose arm named a variant returned the wrong arm’s value. It built clean and passed tsc --strict, which is the class of bug this language exists to remove.

main.glyph
type Colour =
  | Black
  | White

fn f(xs: Array<Colour>) -> string {
  return match xs {
    [] => "empty",
    [Black] => "one-black",
    else => "other",
  }
}

// 0.1.95: f([White]) returned "one-black". The arm emitted
//         const Black = __m0[0], an unconditional binding.
// 0.1.96: f([White]) returns "other".

A top-level array pattern reached the emitter’s array path before the check that decides whether an element tests or binds, and that path treats every identifier element as a binding whatever its case. The pattern now routes through the same machinery an object field’s { color: Black } already used.

  • Fixed A match on an imported union now checks inside a variant’s payload. Omitting an inner variant of an imported nested payload union built clean, passed tsc --strict, and threw at run time. It is E0200 now, naming the inner union and the variant you left out.
  • Fixed A constructor-shaped pattern over a payload that is not a union is a Glyph error. Ok(Point) over a record used to emit a test on a .tag nobody wrote, so tsc reported it against generated TypeScript. It is E0220 naming the type and the pattern.
  • Fixed A lowercase variant of an imported union works in a nested arm. Err(empty) over an imported | empty | NotANumber(..) lowered both arms to one case label and stopped the build at E0305, so the spelling of a variant decided whether a cross-module match compiled. Fixed for a Result or Option scrutinee, which is the common shape. An imported outer union still hits it; that needs a second registry and is tracked.

Known edge: a payload that is a type alias to a union is not checked by the new E0220. The check accuses only a type that cannot alias a union at all, because an accusation needs certainty and a missed diagnostic costs less than rejecting a program that works.

0.1.95 August 2026

An imported union is checked whether or not it is generic

A match over a union imported from another module could leave a variant out, build clean, pass tsc --strict, and throw at run time. It took the type parameter to do it: delete <K> from both files and the identical program was E0200. A scrutinee from another module goes down its own coverage path, and the gate that reaches it asked whether the type was an imported union. Tree<string> is an application of one, so it matched nothing, the coverage check never ran, and the match went uncounted. The gate reads through the application now, which puts a union’s arity out of reach of the answer.

tree.glyph
module tree

pub type Tree<K> =
  | Leaf
  | Node({ left: Tree<K>, key: K, right: Tree<K> })

pub fn leaf<K>() -> Tree<K> {
  return Leaf
}
main.glyph
module main

import tree { Tree, Leaf, Node }

// 0.1.94: built, passed tsc --strict, threw "non-exhaustive match" on a Leaf.
// 0.1.95: [E0200] non-exhaustive match on `Tree`: missing variants `Leaf`
pub fn label(t: Tree<string>) -> string {
  return match t {
    Node({ left: l, key: k, right: r }) => k,
    Leaf => "leaf",
  }
}
  • Fixed A match on an imported generic union reports the variants you left out. Both import spellings, import tree { Tree } and import tree with tree.Tree, and at a concrete instantiation or an open type parameter alike. This was the last of three checks written against a bare type that stopped applying the moment a parameter appeared; the other two went out in 0.1.91. The unwrap lives in one named function now, so the next check written this way cannot regress the same way.
  • Changed This rejects code that previously compiled. If a match on an imported generic union was missing an arm, it built before and is E0200 now. It was already throwing at run time on the value it had no arm for, so the fix is the arm you meant to write, or an else arm if the omission was deliberate. glyph --explain E0200 has the rewrite.
  • Added glyph --update, which moves the compiler itself. glyph upgrade moves a project’s pinned version in package.json, and there was nothing that moved the tool; doctor, told a global install was behind, printed the project command, which edits a manifest you may not have. Flags act on the tool, the way --version and --explain do, and subcommands act on your code. It only touches an install it can identify, asking npm root -g rather than assuming: a project’s own node_modules, an npx cache, a build out of a Glyph source tree, or a path it does not recognise gets told what to run instead of being overwritten. A project-local install is pointed at glyph upgrade, which is the command that moves a pin. --update --update-dry-run shows what it would run.
  • Changed glyph doctor names both commands when a newer release exists, rather than sending a global install to the one that rewrites a project.
  • Known The coverage check on an imported union still stops at the outer variant. It runs now, and it counts the variants the arms name, but it does not look inside a payload: B(X) over an imported union whose payload is itself a union matches every B instead of reporting the missing Y, and no type parameter is involved. A union your own module declares is checked all the way down.
  • Known An imported payload union still goes by the name’s shape. Unchanged from 0.1.93 and 0.1.94: a capitalized variant in payload position dispatches, a lowercase one stops the build at E0305, and a nested constructor carrying a payload is E0300 under a namespace import (tree.Node({ left: tree.Node({ key: k }) })) where the named-import spelling of the same program compiles.
0.1.94 August 2026

A red-black tree, written in Glyph

There is no compiler change in this release. What it carries is an application, examples/apps/leaderboard/main.glyph, and the reason to cut a version for it is that the application compiles. Three of the last four releases each fixed a piece of one shape: a match arm that nests a constructor pattern inside another constructor pattern’s field, over a union that names itself in its own payload and carries type parameters while doing it. That shape is Okasaki’s balance, the four rotation cases of a red-black tree. Here it is, written out and running.

The app is a speedrun leaderboard over an append-only JSON log. Every command re-reads the log and folds it into the tree, then answers by walking down it: a player’s rank, the top N, how many submissions fall inside a score range. Each node carries its subtree size, which is what makes those O(log n) instead of a pass over every entry, and a submitted score is refined at the point it leaves plain text, so -3 is rejected as expected Score (int where value >= 0) and 2.5 as expected Score (int).

tree.glyph
module tree

pub type Color =
  | Red
  | Black

pub type Tree<K, V> =
  | Leaf
  | Node({ color: Color, left: Tree<K, V>, key: K, value: V, right: Tree<K, V> })

// The left-left rotation: a Black node whose Red child has a Red child.
pub fn balance<K, V>(t: Tree<K, V>) -> Tree<K, V> {
  return match t {
    Node({ color: Black, left: Node({ color: Red, left: Node({ color: Red, left: a, key: xk, value: xv, right: b }), key: yk, value: yv, right: c }), key: zk, value: zv, right: d }) => Node(
      {
        color: Red,
        left: Node({ color: Black, left: a, key: xk, value: xv, right: b }),
        key: yk,
        value: yv,
        right: Node({ color: Black, left: c, key: zk, value: zv, right: d }),
      },
    ),
    other => other,
  }
}
  • Added leaderboard, an order-statistics red-black tree with a CLI on it. A persistent tree keyed by score, augmented with subtree sizes, answering rank, top-N and range-count queries over a log it never rewrites. It is the eleventh single-file app in examples/.
  • Changed Nothing in the compiler. No Rust source moved between 0.1.93 and this release, so the binary behaves identically. If you are already on 0.1.93 there is nothing here to upgrade for.
  • Known The union has to be declared in the module that matches on it. Import it from a sibling and spell the import as a namespace (import tree, then tree.Node({ left: tree.Node({ key: k }) })) and the arm is E0300, where the named-import spelling of the same program compiles. A match on an imported generic union is also not checked for missing variants, so it can omit one, build clean, pass tsc --strict, and throw. The missing-variant check is fixed in 0.1.95; the namespace spelling is still open.
  • Known An imported payload union still goes by the name’s shape. Unchanged from 0.1.93: a capitalized variant in payload position dispatches, a lowercase one stops the build at E0305, and Ok(Point) over a record fails through tsc naming a .tag you never wrote.
0.1.93 August 2026

A nested variant tests the payload instead of binding it

Err(Blank) beside Err(e) reads as “skip a blank line, report every other parse error”. It compiled to two case "Err": labels on the outer tag, and the first one bound the whole payload under the name Blank. Every error took the first arm and the second was dead code the compiler still wrote out. Nothing said so: Glyph reported no diagnostics and tsc --strict has nothing to say about a duplicate case label, so the build was green and the wrong arm answered. The typechecker had the right reading the whole time. Delete the second arm and it reports a non-exhaustive match on the payload union, which means the checker read Blank as a variant while the emitter read it as a new binding. The arm dispatches on the payload’s own tag now, under any outer variant.

repl.glyph
module repl

import std/result { Result, Ok, Err }

pub type ParseError =
  | Blank
  | BadWord({ word: string })

pub fn parse_command(line: string) -> Result<string, ParseError> {
  return match line {
    "" => Err(Blank),
    "quit" => Ok("quit"),
    else => Err(BadWord({ word: line })),
  }
}

pub fn step(line: string) -> string {
  return match parse_command(line) {
    Err(Blank) => "",                 // a blank line: nothing to report
    Err(e) => "unknown command",      // every other parse error
    Ok(cmd) => cmd,
  }
}
  • Fixed A variant in payload position dispatches on the inner tag. Err(Blank), Full(Black), Ok(None): one case on the outer tag with an inner switch on the payload, rather than one case per arm with the first swallowing the rest. The rule the compiler applies to a bare name is the one the typechecker already used for exhaustiveness: the payload union’s own variant list decides, and the name’s shape decides only when that list is out of reach. This changes what an existing program does. A match written this way still compiles, and the arm that could never run before runs now, which is the arm you wrote.
  • Fixed Capitalization stops deciding it. Glyph accepts a lowercase variant name, and until now only a capitalized one dispatched, so Err(blank) miscompiled one character away from a spelling that worked. For a union declared in the same file the case of the name no longer changes what the arm means.
  • Added E0305, for two arms that reach the same case label. That shape is how every bug in this class shipped green, so the switch is guarded independently of whatever rule decides a name is a variant. The next lowering that reaches for one tag twice fails the build instead of picking an arm at run time. glyph --explain E0305 has the rewrite.
  • Changed This rejects code that previously compiled. match r { Err(e) => log(e), Err(other) => report(other), Ok(v) => v, } built, and ran log for every error. It is E0305 now. Delete the arm that could never run, or give the two arms patterns that test different values, which is what the second one looked like it was doing.
  • Known An imported payload union still goes by the name’s shape. The variant list of a union from another module is not something the emitter can read yet, so a capitalized variant dispatches and a lowercase one stops the build at E0305. A loud failure on a valid program, and the fix is still ahead. Separately, a constructor pattern over a payload that is a record rather than a union (Ok(Point)) now fails through tsc naming a .tag you never wrote, where it should be a Glyph diagnostic.
0.1.92 August 2026

A derived return type survives a match

When a combinator’s return type is derived from the argument you passed it, as Schema<infer_output<Shape>> is, the compiler inserts one cast at the return, because the body assembles a value the type system cannot prove carries the shape-derived type. The cast lived in one place, and two ways of writing a return went around it. A match in return position becomes a switch whose arms carry their own return, and a tail E? returns the unwrapped payload directly. Either one dropped the cast, so a function that compiled as return { name: "object", parse: ... } stopped compiling the moment a match sat between the return and the value. What you saw was a TS2322 against the generated TypeScript, on well-formed Glyph the compiler had already accepted. Both sites go through the same return now.

schema.glyph
module schema

import std/result { Result, Ok, Err }

type Schema<T> = {
  name: string,
  parse: fn(input: unknown) -> Result<T, string>,
}

fn number_schema() -> Schema<number> {
  return { name: "number", parse: fn(input) {
    match input {
      is number => Ok(input),
      else => Err("expected number"),
    }
  } }
}

fn object_schema<Shape: Record<string, Schema<unknown>>>(shape: Shape, strict: bool) -> Schema<infer_output<Shape>> {
  return match strict {
    else => { name: "object", parse: fn(input) { Err("unimplemented") } },
  }
}

type Point = {
  x: number,
  y: number,
}

pub const point_schema: Schema<Point> = object_schema({ x: number_schema(), y: number_schema() }, true)
  • Fixed A match in return position keeps the derived-type cast. The program above built clean and then failed tsc with a TS2322: the value the arm returned was not assignable to the shape-derived type the signature declares. Delete the match, return the same object directly, and it compiled. That is the tell. The cast was attached to one spelling of return rather than to returning, and every return a switch arm emits carries it now.
  • Fixed A tail E? does too. The other lowering that wrote its own return. In a function whose return type mentions infer_output, a body ending in r? returns the unwrapped Ok payload, and that return went out uncast for the same reason.
  • Added The corpus combinator is checked against tsc with a match in it. The end-to-end test builds object_schema the way an application writes it and runs tsc --strict over the output, so the failure this started as is what fails if it comes back. Two emitter tests pin the cast in the generated TypeScript for both lowerings.
0.1.91 August 2026

A type parameter no longer switches the checks off

Add a <K> to a union and two things stopped working, both quietly. A nested pattern under one of its payloads was refused with E0300, so the rebalance arm 0.1.90 made writable had no spelling over a tree that carries a key type. And a match on that union was not checked for exhaustiveness at all: omit a variant and the program built, passed tsc --strict, and threw at run time, where the same program on Tree is E0200. One function answered both questions and it only looked at the bare form of the type, never the applied one. It unwraps the application now, so a union's arity stops being something an arm can feel.

Nesting through a generic union's payload
module tree

type Tree<K> =
  | Leaf
  | Node({ left: Tree<K>, key: K, right: Tree<K> })

pub fn shape(t: Tree<string>) -> string {
  return match t {
    Node({ left: Node({ key: lk }), key: k, right: r }) => "deep:" + lk + "/" + k,
    other => "leaf",
  }
}
  • Fixed A nested pattern under a generic union's payload. The arm above was E0300, "cannot determine how this payload is stored". Nothing under the payload got a recorded type, because the lookup wanted a bare Tree and was handed Tree<string>, so the emitter had nothing to read back when it came to decide flat-versus-boxed. It refused rather than guessing, which was the right instinct and the wrong answer. The declaration's parameters are substituted into the payload now, the way a record's field types already were.
  • Changed A match on a generic union is checked, and some that built now fail. Through 0.1.90 a match on a union your own module declares was checked for missing variants only when the union took no parameters. Add a parameter and the check was skipped entirely, so this built clean and threw non-exhaustive match on the first Leaf. It is E0200 now. Add the arm:
    module depth
    
    type Tree<K> =
      | Leaf
      | Node({ left: Tree<K>, key: K, right: Tree<K> })
    
    pub fn depth(t: Tree<string>) -> int {
      return match t {
        Leaf => 0,
        Node({ left: l, key: k, right: r }) => 1 + depth(l),
      }
    }
  • Fixed The recursion into a payload works too. A generic union whose payload is itself a union reports the missing inner variant, which the local path could never reach before: it needs the outer variant set to answer first, and for an applied type there was no variant set at all.
  • Known Move the union to another module and the coverage check goes away again. match t { Node({ key: k }) => k, } on an imported Tree<string> still builds, still passes tsc --strict, and still throws at run time; delete the <K> and it is E0200. An imported scrutinee goes down a separate, shallower path that this fix does not touch, and the applied form does not even reach it. That path has a second hole worth knowing about, and it has nothing to do with type parameters: it never looks inside a variant's payload, so B(X) over an imported union whose payload is itself a union matches every B instead of reporting the missing Y.
0.1.90 August 2026

A pattern can reach more than one level into a record

A variant with several fields carries a record, so an arm that wants to recognise a shape has to look through the record's fields. That position took a name and nothing else: you could rename a field, and that was all. A variant tag there was E0009, and a nested constructor fell off the parser. The Okasaki red-black rebalance is four rotation cases each named by a two-level shape, and it had no spelling in Glyph at all. A field holds a pattern now.

One rotation case, as one arm
module tree

type Color =
  | Red
  | Black

type Tree =
  | Leaf
  | Node({ color: Color, left: Tree, value: int, right: Tree })

pub fn rotate(t: Tree) -> Tree {
  return match t {
    Node({ color: Black, left: Node({ color: Red, left: a, value: x, right: b }), value: y, right: c }) =>
      Node({ color: Red, left: Node({ color: Black, left: a, value: x, right: b, }), value: y, right: c, }),
    other => other,
  }
}
  • Added Any pattern after the colon. A variant tag, a nested constructor, a nested destructure, a literal, an array pattern. Reaching through a payload needs one more fact, because a variant's record payload is spread flat into the tag object while every other payload sits under value: the compiler reads that off the matched type rather than off the arm's syntax, so a union declared in a sibling module matches the way a local one does, and Wrapped({ inner: Some(n) }) knows to read .value.
  • Changed An arm that tests a field no longer covers its variant. A field pattern can fail, so match t { Leaf => .., Node({ color: Red, ... }) => .. } is a non-exhaustive match on Tree until a sibling arm or an else takes the rest. Coverage is proved over a set of tags, not over a product of fields, so a Red arm and a Black arm are not read as exhausting Node between them. That is the reading which cannot let a match fall off its end at run time.
  • Fixed A match on a plain record can no longer throw. A record scrutinee has no tags to count, so it was declined by every exhaustiveness check there is. Once a field could test a value, match p { { x: 0, y: y, } => .. } compiled with no diagnostics, passed tsc --strict, and threw on the first call. It is E0226 now: every arm can fail and no arm is a catch-all. No published release had this hole, because until this one that pattern was a parse error.
  • Changed { on: true } tests the field. A true, false or void after the colon used to read as a binding named after the keyword, which matched every value. Harmless while a field could only bind, wrong the moment it could test.
  • Changed [Black] at the top of a match is rejected, and used to compile. The array exhaustiveness check counted a bare name as matching anything whatever its case, so [Black] covered length 1 and a match that got the other lengths from a rest arm certified as exhaustive. It also emitted a length test and no tag test, so [Red] took that arm and got the wrong answer. A PascalCase element is a variant reference here the way it is everywhere else, and the match is now E0208 on arrays of length 1. Bind the element and match it:
    module colors
    
    type Color =
      | Red
      | Black
    
    pub fn describe(xs: Array<Color>) -> string {
      return match xs {
        [] => "empty",
        [only] => match only {
          Black => "one black",
          Red => "one red",
        },
        [a, b, ...rest] => "many",
      }
    }
  • Changed E0009 is retired. It named a variant in a field position, which is the feature. glyph --explain E0009 keeps the entry and says so, so an agent holding an old transcript gets an answer rather than an unknown code.
  • Known The array arm still lowers to a binding. Only the exhaustiveness half of [Black] is closed. The lowering is G138 and the correct version of it already exists a few hundred lines away, in the chain the field patterns above go through. It travels with G130, the same disagreement one level up, in 0.1.91.
  • Known Two spellings still refuse a nested constructor. A field holding a constructor that carries a payload needs the compiler to know how that payload is stored, and it cannot work that out in two cases, both E0300. One is a union generic over its own parameter: type Tree<K> = Leaf | Node({ left: Tree<K>, key: K, right: Tree<K> }) matched with Node({ left: Node({ key: k }) }), where dropping the type parameter compiles. The other is a namespace import: the same union in a sibling module matches under import tree { Node } and refuses under import tree with tree.Node, generic or not. A field that binds, and a field holding a tag with no payload, work in every case.
0.1.89 August 2026

A bool binding you can match on

A binding in Glyph has one type for the whole of its life. TypeScript narrows a binding to whatever it last saw assigned, and its boolean is the union true | false, so let done = false arrived at tsc typed false. Matching on it built clean in Glyph and then failed with Type 'true' is not comparable to type 'false': an error about a switch you never wrote, on a program the Glyph checker found nothing wrong with. A string-literal union type failed the same way, and so did ==.

This builds now, and used to fail the tsc pass
module status

fn classify() -> int {
  let done = false
  match done {
    true => { return 1 },
    false => { return 0 },
  }
}
  • Fixed Matching a bool or a string-literal union binding works. The match scrutinee is pinned to the type the checker gave it before the emitted switch sees it, so the arms have something to match. It covers a let, a mut, and a type alias of either declared in the same module.
  • Fixed == and != over those bindings work too. They failed as TS2367, "this comparison appears unintentional", which is a different message for the same cause. Each operand is pinned from its own type and ignores what sits next to it, so done == failed between two bool bindings is covered the same way done == true is.
  • Changed A literal outside the union still fails, and now names the type. m == "nope" where m is a Mode is still rejected; the message says Mode rather than the one member m happened to hold. Fixing this at the assignment instead would have been less code and would have swallowed that check, because "nope" as Mode type-checks where let m: Mode = "nope" does not.
  • Added An uptime monitor in the examples. pulse resolves each target with std/dns, dials a certificate-verified std/tls connection, writes an HTTP/1.1 request by hand and reads the status line back off the socket's callbacks. Every check lands as one JSON line in a history file. It is the app that found the bug above, while turning a timer callback into a value an async fn can await.
  • Known A bool alias read through another module is not covered. pub type Ready = bool in one module, then let r: catalog.Ready = false and a match, still fails: the emitter follows an alias only inside the module it is emitting. String-literal unions are covered across modules in all three import spellings, because the checker hands those over as a set of literals.
  • Known Full(Black) still miscompiles. A variant whose payload is a union rather than a record emits two case "Full": blocks, the first of which binds the payload instead of testing it. That is G130, and it moves to 0.1.90.
0.1.88 August 2026

A variant's shape comes from where it is declared

Matching a variant through its namespace, outcome.Failed(f), read the payload's shape from your module instead of from the one that declared it. When the two disagreed the emitter reached for a field that does not exist at runtime, and the build failed somewhere unrelated or the value came back wrong.

What the arm emitted, before and after
// before: the shape was looked up by name in the consuming module,
// so a flat payload was read as if it were wrapped
const f = __m0.value;   // .value never exists on a record payload

// after: the shape comes from the module that declared the variant
const f = __m0;
  • Fixed The variant's shape is read from the scrutinee's own module. This is the rule G75 settled, applied one layer deeper: a type's identity comes from where it is declared, never from how you happened to spell the import. The precise lookup runs ahead of the older by-name heuristic, because behind it the heuristic still won whenever two modules declare the same variant name with different payloads.
  • Fixed A typo in a variant payload reports the typo. Node(int, 5, int) used to be reported as an arity error about a variant carrying one field, with the span shrunk to the first field and the 5 never mentioned. The payload tail now propagates the real parse error, so the arity diagnostic is reached only when every field actually read as a type.
  • Changed That arity diagnostic now quotes your own fields. Its help was a fixed string naming a different program's variant, so the message and the help could disagree about how many fields you wrote. It is built from your source now, with field names left as holes because a wrong name is worse than an obvious blank.
  • Added A dev-loop tool in the examples. watchrun polls for changes, filters them through globs from a real npm dependency, debounces a burst into one run, spawns a subprocess, and streams its output to a log while enforcing a timeout. It blocked twice on missing language features and this is the round where it built with no workaround.
  • Known Full(Black) still miscompiles. The same shape where the payload is a union rather than a record is G130, scheduled for 0.1.90.
0.1.87 August 2026

The exit code your program recorded is the one it leaves with

A CLI that computes a verdict inside main and records it, rather than returning it, exited 0 regardless. The wrapper we generate assigned process.exitCode unconditionally after main returned, so a main declared -> void had whatever it recorded written back to zero on the way out. Every shell and CI check reading that code saw success.

This exits 1 now, and used to exit 0
module audit

import std/process
import std/io

pub fn main(argv: Array<string>) -> void {
  let failures = 2
  io.println("checked, ${failures} failing")
  match failures > 0 {
    true => process.set_exit_code(1),
    false => {},
  }
}
  • Fixed A recorded exit code survives a void return. The generated entrypoint only assigns process.exitCode when main actually produced a number. An unset code is still 0 to Node, so nothing else changes: a main that returns a number still wins over an earlier recorded one, and a program that records nothing still exits 0.
  • Note The doc said this worked. set_exit_code's own comment described the program that computes a verdict inside main and records it. That program was the one that did not work, which is the kind of gap a doc cannot catch and an app does.
0.1.86 August 2026

A variant name in a pattern matches, or the compiler says so

A pattern that names a variant inside a record field looked like it tested that variant and did not. It bound the field to a new name that shadowed the constructor, so the arm fired for every value, the arms below it became unreachable, and both glyph check and tsc --strict reported nothing.

What the arm used to mean
module boxes

import std/io

type Color = | Red | Black
type Box = | Full({ color: Color, label: string }) | Empty

// the form that works. the one that used to compile and
// silently take the wrong arm was:
//   Full({ color: Black, label: l }) => "black box"
// it bound `Black` to the field, so a Red box printed "black box"
pub fn describe(b: Box) -> string {
  match b {
    Full(f) => match f.color {
      Black => "black box",
      Red => "red box",
    },
    Empty => "empty",
  }
}
  • Fixed A variant name in an object pattern's field is rejected, not silently rebound. E0009 names the field and points at the form that works. The AST has no slot for a sub-pattern inside a record field, so lowering it was never possible; what was possible was saying so instead of guessing.
  • Changed This rejects code that previously compiled. A pattern like Full({ color: Black }) used to build and silently take the wrong arm. It is an error now. Rewrite it as Full(f) => match f.color { ... }, which is what it was always doing wrong.
  • Known The same shape one level up still miscompiles. Full(Black), where the payload is a user-defined union rather than a record, still emits two case "Full": blocks whose first binds the payload. That is G130, and it is scheduled for 0.1.90.
  • Fixed The newest release notes are compiled now. The 0.1.85 entry published a call that does not exist, because the doc gate skipped this page entirely. It skips the history still, since a 0.1.3 note documents 0.1.3, but the entry describing the current compiler is checked. The home page's version pill is checked too; it had advertised v0.1.72 for thirteen releases.
0.1.85 August 2026

A TLS dial you can bound

An uptime monitor is the first program that cares what happens when a host accepts your connection and then says nothing. tls.connect had no deadline, so one unresponsive endpoint held the whole run open with no handle to close. It now takes one as a plain argument rather than an options field, so nothing can default it away, and refuses a deadline it cannot actually hold.

A dial that cannot outlive its budget
module probe

import std/tls
import std/io

// the deadline is an argument, not an options field:
// nothing downstream can default it away
pub async fn reachable(host: string) -> bool {
  match await tls.connect(host, 443, 3000) {
    Ok(conn) => true,
    Err(e) => {
      io.println("${host}: ${e}")
      false
    },
  }
}

// and a deadline node would silently clamp is refused, rather
// than failing in 1ms and blaming the 35 days you asked for:
// "a TLS dial deadline must be at most 2147483647ms, got 3000000000"
  • Added tls.connect takes a required deadline. A dial that has not completed its handshake in time fails as an error you can match on, and the socket is destroyed rather than left holding the process open.
  • Fixed A deadline node cannot hold is refused, not silently clamped. Node truncates a setTimeout past 2147483647ms to 1ms, so asking for 35 days used to fail after a millisecond and report it as if the 35 days had elapsed. That now names the limit instead.
  • Known The deadline covers the socket, not name resolution. A dial still resolving a hostname has nothing for destroy to reach, so it can answer on time and still hold the process open. Documented rather than claimed shut, because we could not wedge a resolver to test it without changing machine config.
  • Known std/http still bounds nothing by default. get and post take no deadline. A request against a listener that accepts and stays silent was still pending at 45 seconds. Same shape as this fix, and it needs its own release because the choice between a required deadline and a changed default is a change to a guarantee.
0.1.84 August 2026

A subprocess you can watch while it runs

Node builtins have worked out of the box for a while, but child_process only offered the blocking calls: run the command, wait, get everything at once. A tool that reports a long build's progress needs the other one, and writing it meant installing @types/node first.

Streaming a child's output as it arrives
let child = spawn("git", ["status", "--short"])

// ask for text and node decodes for you, holding back a
// character whose bytes straddle two chunks
child.stdout.setEncoding("utf8")
child.stdout.on("data", fn(chunk) { io.print("${chunk}") })
child.on("close", fn(code) { io.println("git exited ${code}") })
  • Added spawn, with the pipes typed the way node types them. Four overloads matching @types/node, a stream module carrying the Readable and Writable subset, and ChildProcess with nullable pipes beside ChildProcessWithoutNullStreams for the plain call. The pipes sit on the base interface, so a value does not change its guarantee depending on whether you bound it to a local or passed it to a function.
  • Added CI proves the shim exports nothing node does not. A type declared inside an ambient module is exported from it, so a name we invent builds green with nothing installed and fails the moment you install the real package. That is now checked, not watched for: 74 exported names across 15 modules, against @types/node at latest.
  • Known Names are checked, shapes are not. A few declarations still take string where node takes BufferEncoding, and our Readable is an interface where node's is a class, so a duck-typed value compiles here and fails there. Narrowing a declaration people already build on changes a guarantee, so it gets its own release rather than riding along inside a feature.
0.1.83 August 2026

Install @types/node and your build still works

The external-imports guide tells you to install @types/node when you want the full node builtin surface. Doing that broke every build, inside our own runtime, on a program that contained nothing but an empty main. Four errors, none of them in your code.

A file with no imports, after npm i -D @types/node
// your whole program
pub fn main() -> number { return 0 }

// what you got
std/net.ts(282,34): Property 'buffer' does not exist on type 'string | NonSharedBuffer'
std/process.ts(41,3): Type 'string | number' is not assignable to type 'number'
  • Fixed The bundled runtime type-checks against the real @types/node. Our shim declared two node APIs more narrowly than node has them, and the runtime was written against the narrow types. process.exitCode was typed number where node also accepts a numeric string, and a socket's data chunk was typed as a buffer where node delivers text once setEncoding has been called. Both the shim and the code that read it were fixed, not just the line the compiler pointed at.
  • Added A check that installs the real package. The first attempt at this shipped a guard that built a stand-in @types/node by copying our own shim, so it checked that the shim agreed with itself and passed against the exact release that was failing. CI now installs @types/node@latest, builds a bare main, and fails if the bundled shim was written at all.
  • Fixed The release smoke test notices a partial install. npm drops an optional dependency it cannot fetch and still exits 0, so within seconds of a publish the launcher can install alone and report success. The check now looks for the platform package on disk instead of trusting the exit code.
0.1.82 August 2026

The download runs, and the package carries its license

If you installed from npm, none of this affected you. If you downloaded a tarball from the Releases page and followed the instructions on it, you got permission denied, because every archive we have ever published carried the binary without its execute bit. The npm packages were always correct, which is exactly why nobody caught it: the smoke test that runs before each release only exercises npm.

What was in the tarball, and what is in it now
// before
-rw-r--r--  glyph

// after
-rwxr-xr-x  glyph
  • Fixed The binaries on the Releases page are executable. GitHub's artifact upload strips the Unix mode, and only the npm half of the pipeline restored it. The build now packs each binary into a tar before upload so the mode travels inside the archive, and the release job extracts a finished archive and checks the bit before publishing. Confirmed against v0.1.10, v0.1.80 and v0.1.81: all three shipped mode 0644.
  • Fixed Every npm package ships its license text. All six declared MIT OR Apache-2.0 and contained neither file.
  • Fixed The reinstall hint names this package. When a platform package is missing, the launcher used to suggest npm install glyph. That is an unrelated static site generator. It now says npm install @glyphlang/glyph, with a test so it cannot drift back.
  • Changed A release checks itself before it publishes. npm versions are immutable, so the pipeline now does everything checkable first: the tag must match every version string in the repo, the commit must be on main and have passed CI, each binary is run on its own platform and asked for its version, and all six packages are dry-run before the first real publish. Publishing to a version that already exists stops the release instead of half-completing it.
  • Known The x86-64 macOS binary is not executed by CI. The macOS runner is arm64, so running it there needs Rosetta, which the image does not guarantee. Its mode is checked like every other; nothing runs it.
0.1.81 August 2026

Generated output drops into your app as-is

An outside engineer built a React Kanban board with the domain core in Glyph and the UI in TSX, and had to reverse-engineer two path aliases (one in tsconfig.json, one in vite.config.ts) before his app would compile the output. Nothing documented them. This release removes the need for both.

Emitted imports, before and after
// before: resolved only under the generated tsconfig's paths map,
// which your app's tsc and bundler never read
import { schema } from "std/schema";

// after: resolves anywhere, no configuration
import { schema } from "./.glyph-runtime/std/schema";
  • Fixed Compiled modules import into a host project with zero config. Every std/* specifier in emitted code is relative to the bundled runtime, the same rule the bootstrap import has always used. Verified against the Kanban app's own modules: a stock Vite scaffold's tsc --strict, a real vite build, esbuild, and tsx all take the output untouched.
  • Fixed The prelude types travel with the code. The bootstrap module every emitted file imports now carries a /// <reference> to the ambient prelude declarations, so Issue and Schema exist in whatever compilation includes your generated files, not just the compiler's own.
  • Added The hybrid layout is documented. The deployment guide covers embedding a Glyph domain core in an existing TypeScript app: the directory shape, the pub requirement, and the @types/node caveat for Node-flavored std modules. There's a new answers page for the question.

Old projects wired with the aliases keep working; the alias just goes unused. Hand-written extern/*.ts that imports std/* bare still compiles, because the generated tsconfig keeps its paths map. Not in this release: a watch mode, so a hybrid app still reruns glyph build after domain edits.

0.1.80 August 2026

A server is a resource now

Breaking: serve is gone from std/net and std/http, replaced by listen. If you call it, this is the change to read.

Before, and after
// before: resolved only when the server closed, and nothing could close one
match await http.serve(8080, handler) {
  Ok(_) => 0,        // this arm could never run, and you had to write it
  Err(m) => 1,
}

// after: resolves when the port is bound, and hands back the server
match await http.listen("127.0.0.1", 8080, handler) {
  Ok(server) => io.println("on ${number.to_string(net.port(server))}"),
  Err(e) => match e.kind {
    "in_use" => retry_elsewhere(),
    "denied" => give_up(),
    "unavailable" => io.eprintln("no such address"),
    "other" => io.eprintln(e.message),
  },
}
  • Fixed A client could kill your server by hanging up. A socket the server handed you, with a data handler and no error handler, ended the process on a peer reset. The example in our own reference was that exact shape, so the documented way to write a server was a remote kill switch. Every accepted connection now gets a default error handler before your handler runs.
  • Fixed A client could exhaust your server's memory by posting forever, and could strand a request permanently by disconnecting mid-body: the read waited only for an end that never came, so the request was retained for the life of the process with nothing in the log. Bodies are capped at 8 MB with a 413, and a disconnect now ends the request.
  • Added A WebSocket server, RFC 6455 over std/net: the handshake, masked frames, all three payload-length encodings, fragmented messages reassembled before delivery, ping answered with pong. A server connection is the same Socket a client is, so one vocabulary covers both ends.
  • Added Binary WebSocket frames (on_binary, send_bytes) and subprotocols (connect_with, protocol). on_message now delivers text frames only; a binary frame used to be decoded as UTF-8 and handed to it, which is right for JSON and silently corrupts everything else.
  • Improved A bind failure is structured: in_use, denied, unavailable and other lead to different decisions, so none of them is a substring you scrape out of a message. net.port(server) means listen(host, 0, ...) is usable, and host has no default, because a standard library that will not ship a switch for turning off certificate checking should not quietly bind every interface either.

Why serve was deleted rather than kept. Its Ok branch could never run, and Glyph has no if: a match on a Result must be exhaustive, so every caller was forced to write an arm that could not execute. Six programs in this repo did, one of them printing a line that would never print. A convenience that makes every user write dead code is not a convenience.

Most of this release came out of an adversarial review of the design before it shipped, and the worst thing it found was not the thing being reviewed: the process kill above was in code that had already been released. Four of its claims were reproduced against the compiler and node before any of them were acted on.

0.1.79 August 2026

The host boundary

Four modules for the calls a program used to make raw, and the first application in the tree built on a real npm package.

The last raw host call, wrapped
match await net.serve(4000, fn(sock: Socket) {
  net.on_text(sock, fn(line) { net.send(sock, greet(line)) })
}) {
  Ok(_) => io.println("stopped"),
  Err(why) => io.eprintln("cannot listen: ${why}"),   // a used port is a value
}
  • Added std/net: TCP as a server that accepts many clients and a client that talks to one. Events are individual functions, so no callback parameter needs narrowing and there is no event-name string to misspell. chat/daemon.glyph was the last raw host call in the examples tree and is ported.
  • Added std/url: the host's WHATWG parser, the one fetch and a browser use. https://evil.com@example.com/ has host example.com, and a parser written by hand answers evil.com. No node dependency, so it runs in a Web Worker.
  • Added std/dns: every lookup async and every result a Result, because a name that does not exist is an ordinary outcome that node throws for.
  • Added std/tls: TCP with the certificate checked, and no argument to turn that off. connect resolves after the handshake, so the failure arrives before there is anything to write to an unverified peer.
  • Improved The std/bytes codecs are 14x to 30x faster. to_hex went from 160 ms per megabyte to 5.3, to_base64 from 135 to 9.7. They were growing a string a few characters at a time and scanning the alphabet per input character; they now build into a typed array and read through a lookup table. A delegating fast path to node's Buffer was considered and rejected, because it would have made a guarantee depend on which host the code ran under.

Pick on_text or on_data, and the difference is not cosmetic. TCP is a stream of octets with no message boundaries, so a multi-byte character can be split across two packets. Decoding each chunk on its own turns one é into two replacement characters, and it only shows under load or with non-ASCII input. on_text holds a decoder per socket and emits whole characters; on_data hands the octets over untouched.

The npm interop gate now has an application behind it. examples/apps/feeds reads an RSS feed with fast-xml-parser: imported by name, constructed with new, returning an any that a generated descriptor turns into a checked value. No adapter, no hand-written .d.ts, no escape to TypeScript.

Two edges found on the way and left open rather than papered over. A client cannot say that a response body is text, so an XML or CSV body arrives as an unknown that only string.from narrows, and that same line renders [object Object] for a JSON body. And a server cannot be stopped once started, in std/net or std/http, so graceful shutdown is still unwritable.

0.1.78 August 2026

Bytes

Two applications written independently, one a PNG reader and one an authenticator, stopped on the same sentence: Glyph had no bytes. Every boundary in the standard library was string-in, string-out, so a file whose first byte is 0x89 could not be read and an HMAC could not be given a real key.

Now writable
let key = bytes.from_base32(secret)?
let mac = crypto.hmac_sha1_bytes(key, counter)
let png = fs.read_bytes(path)?
match bytes.starts_with(png, signature) { // 137 80 78 71 13 10 26 10
  true => read_chunks(png),
  false => Err("not a PNG"),
}
  • Added std/bytes: an immutable sequence of octets, a Uint8Array at run time so it hands to a host API unwrapped. The sequence operations take their names from this type’s peers in std/array and std/string: len, get, slice, concat, join, equals, index_of, starts_with. Hex, base64, base64url and base32 codecs, and a UTF-8 bridge in both directions.
  • Added fs.read_bytes, write_bytes, append_bytes: a file read and written undecoded. read_text on a PNG replaces every byte that is not valid UTF-8 with U+FFFD and reports success.
  • Added A _bytes form of every digest and HMAC in std/crypto, plus SHA-1 for the protocols that specify it, hmac_sha512, random_bytes, and timing_safe_equal. Which form you pick changes the answer: a key with a byte that is not valid UTF-8 loses that byte on the way through a string, so the text form of a real key computes a different MAC than the specification says.

Every decode returns a Result that names the position it rejected. This is the expensive half and the reason to use the module. Node’s Buffer is silent on malformed input: Buffer.from("zz", "hex") is an empty buffer and no error, base64 decoding skips any character outside the alphabet so a base64url string decodes to quietly wrong bytes, and toString("utf8") substitutes U+FFFD and reports success, which turns a truncated read into plausible-looking text. Every codec here is written out instead and refuses all three. to_text scans to find the first byte that cannot start or continue a valid sequence, so you get not valid UTF-8 at 2 rather than a verdict.

Writing the codecs out bought something that was not the motive: the module reaches for no host API at all, only Uint8Array, TextEncoder and TextDecoder, so a bundle that touches only std/bytes runs in a Web Worker.

What a published vector proves is only what its inputs exercise. The codecs are pinned against RFC 4648 and the HMAC against RFC 6238. Breaking hmac_sha1_bytes on purpose, to route its key through a string, left the RFC 6238 assertion passing, because that vector’s secret is ASCII and survives the trip unchanged. The test now also pins an HMAC over a key containing 0xff, where the string route gives 4ab779f0… instead of c543ef42….

Reading a big-endian integer out of a header is still arithmetic you write, and hex literals still do not parse, which makes a 256-entry CRC32 table written in decimal hard to read. std/websocket still decodes a binary frame as text; the type it was missing now exists.

0.1.77 August 2026

A lost update is now a compile error

Two of these running at once both read 0, both write 1, and you get 1 instead of 2 — from a build reporting no diagnostics and a clean tsc --strict.

Rejected as of 0.1.77 (D43, E0225)
async fn bump(c: Counter) -> number {
  let before = c.n
  await timers.sleep(1)     // another task can run here
  mut c.n = before + 1      // and its write is silently discarded
}
  • Added E0225: a field of a parameter read before an await and written after it. The fix is to move the read after the await, so the value written is the one that is current when it is written.

The rule is narrow, and the narrowness is the design. It fires only through a parameter, which is the only thing a caller can also have handed to another task, and only on a field write, since rebinding a whole parameter changes this function’s copy rather than the caller’s record. A local counter across an awaitmut rounds = rounds + 1 in an async loop — cannot be raced by anything and is not reported. An earlier draft of the check flagged two such lines in a real app in this repo; a false positive on an ordinary counter is exactly what teaches people a check is noise, so it was narrowed until the whole example tree, 145 modules, was clean while the failing case still failed.

0.1.76 August 2026

Tell the agent what the answer is

From reading a session log an outside author shared: 14 agents and 3,377 lines of Glyph, in which the compiler produced eleven diagnostics total. Eight of the eleven were one agent guessing.

  • Improved An unknown import now names the answer. Hunting one function in std/random cost eight builds: int, next, float, number, range, int_range, between, shuffle. The module exports exactly seeded, and the compiler knew that every time, because checking against the list is what produced each error. It says so now: `int` is not exported by `std/random` (exports: Rng, seeded). A near miss gets the intended name instead — string.repeeat is (did you mean `repeat`?).
  • Fixed for i in array.range(n) no longer builds an array to count with. It was the slowest of the three ways to scan a collection, by a factor of nearly three, and it is the one that reads like a counting loop. It lowers to a real counting for now, with both bounds evaluated once.

Counting a match over 81 elements, 200,000 rounds: for c in cells 33 ms, array.filter with a closure 62 ms, for i in array.range(...) 61 ms — down from 168. Iterate the collection directly when you do not need the index; indexing costs a bounds check per element, which is what turns an off-the-end read into an error rather than an undefined three frames later. The closure is not the thing to avoid. The performance guide now carries this table, which it did not before, and an outside team wrote a benchmark harness to find it out.

0.1.75 August 2026

The emitted imports survive having their types stripped

Found by an application built outside this project: a tic-tac-toe game whose engine and AI are 3,377 lines of Glyph running in a Web Worker.

  • Fixed A name with no runtime binding is no longer emitted as a value import. import std/option { Option, Some, None } emitted import { Option, Some, None }, and Option is a type: it has nothing behind it at run time. tsc quietly drops such a name, which is why every build was green. A type stripper does not, and the import then fails to link against a module that genuinely has no such export. Every emitted import now marks those names import { type Option, Some, None }, which is the spelling a tool with no type information can act on.

This affected two kinds of name. The standard library declares 25 of them across 16 modules, and a table now lists them with a check that fails in both directions: a name missing from it emits unmarked and will not link, and a value name wrongly added would be dropped and take a binding the program needs with it. The second kind was missed on the first attempt and found by re-testing against the application rather than the reduced case: a Glyph plain alias like type Board = Array<Cell> emits a type and nothing else, where a record or tagged union also ships a validator under the same name.

If you build with a bundler that already elides unused type imports, such as esbuild, nothing changes for you. If you use a stripper (swc, node --strip-types, Bun) or set verbatimModuleSyntax, this is the difference between an output that links and one that does not. The whole emitted tree, runtime included, is now clean under verbatimModuleSyntax.

0.1.74 August 2026

Three ways the compiler reported green and was wrong

A build that says no diagnostics and tsc --strict passed is the one claim everything here rests on. Three separate things could say it and be wrong.

  • Fixed A loop index could be a string. An array’s pairs bind a number index, a record’s bind a string key, and the emitter guessed the record form whenever it could not tell. So for index, key in w.keys made index + 1 compute "01" instead of 1, out of a completely green build. It no longer guesses: an iterand the checker settled emits directly as before, and one it did not defers to the run time, which knows what the value is.
  • Fixed glyph gen dts reported success for a file that cannot compile. Two source types that flatten onto one Glyph name were both written out, with a success line and exit 0; the next build failed. The check now runs on the names actually being written, lists every colliding source, and writes nothing. --rename Source=GlyphName resolves it and is recorded in the generated header, so glyph regen replays your choice.
  • Fixed A relative import carrying a file extension resolved to nothing. export * from "./x.js" is mandatory under moduleResolution: nodenext, so every ESM-authored package hit this and materialized zero types. glyph gen dts date-fns went from 0 types to 280.
  • Fixed A standard-library type imported by name lost two checks. import std/http { HttpError } and import std/http disagreed: under the named spelling a bogus field produced no Glyph error, and a match covering every case was told to add a catch-all. A guarantee must not depend on which legal spelling brought a type into scope.
  • Added A default import, so callable npm packages work. import express { default as app }. A package whose export is a function — express, lodash, debug, chalk, commander — had nothing Glyph could import. as is legal only after default, so renaming an ordinary import is still an error.
  • Added std/intl: plurals, money, dates, lists, collation. plural_category returns the six CLDR categories as a closed set, so a match over it is exhaustive and a missing one is named. An app branching on n == 1 is wrong in most of the world; Polish alone needs one, few and many.
  • Improved A generic record’s parse is typed from the call’s type arguments, so Wire.parse<number>(raw) gives a real Wire<number> and a field typo is a Glyph error at the field instead of a TypeScript one pointed at the whole function.

What is still missing, since the same round found it. gen dts reads interface and type declarations, so a package whose surface is classes (marked’s Lexer, Renderer), or whose fields use computed types like Omit, or whose declarations reference Intl, leaves names the generator flags in a note and the build reports as unresolved. Importing such a package directly needs no generation and is unaffected; this only bites boundary validation.

0.1.73 August 2026

A project changes compiler on purpose

A new release could reach your build through an npm install you ran for something else. Now it cannot, and you can still find out one exists.

  • Changed glyph init pins the compiler exactly, with no ^. On a 0.x version a caret still floats the patch, so ^0.1.72 accepted every later 0.1.x — and a 0.1.x release may add a diagnostic that rejects code which compiled yesterday, which is usually the point of the release. Those two together meant a green build could go red without a line of your source changing.
  • Added glyph upgrade moves the pin and runs npm install. --dry-run shows what would change, --to <version> names one (including an older one), --no-install leaves the install to you. It reads a caret as well as an exact pin, so a project scaffolded before this release can be moved onto one.
  • Added glyph doctor reports this compiler against the published one and links the release notes. Finding a newer release never changes its exit code, so it stays safe in CI, and --offline skips the lookup entirely.
  • Added glyph init says to commit your package-lock.json, because an exact pin only buys a reproducible build if the lockfile is committed too.

No network dependency was added to the compiler, and that was the constraint. It has never had an HTTP client and still does not: npm is already required to install Glyph at all, so npm view answers the one registry question without pulling a TLS stack into a compiler that had none. Retries are off and the timeout is three seconds, because npm’s defaults would leave an offline doctor waiting most of a minute. Only doctor and upgrade ever look; build, run and check never do.

0.1.72 August 2026

A typo answers in Glyph’s own voice

The same misspelling used to get two different answers depending on which way you had written the import.

  • Fixed string.repeeat(s, 2) is [E0105] import: `repeeat` is not exported by `std/string`. Writing import std/string { repeeat } had always been that error, because a named import is checked against the module’s export list; reading the same name off a namespace was left to the back end, which answered TS2551 about a property. Namespace member reads are recorded during resolution now and held to the same list. Turning it on found a test fixture that had been calling fs.write for months, where the function is write_text: it ran with type-checking off, and nothing else looked.
  • Improved Passing an async fn where array.filter, find, any, sort, or fold want a callback is E0211 pointing at the callback, instead of a TypeScript error about Promise<boolean> pointing at the whole statement.

One known edge, written down rather than papered over. array.map, flat_map, and zip still accept an async callback and hand back an array of pending values, which prints as [object Promise]. The modeling that would stop it also rejects par.all(array.map(items, async fn ...)), Glyph’s own way of running work concurrently, because an array of pending values has no spelling in the language. That is a decision about the type system, not a missing table entry, so it is open.

0.1.71 August 2026

The last place a type went missing

One shape built with 0 error(s) and threw at run time, and it had been there since the standard library was first modeled.

  • Fixed A match on string.index_of with no None arm is a compile error. It used to build clean and throw non-exhaustive match on the first input that did not contain the search string, because the function could not be modeled and its result was therefore untyped, so the exhaustiveness check never ran. Six standard-library functions were in that position for the same reason: each takes a trailing argument the caller may omit, and the arity check compared one number against one number, so modeling them would have reported an error on every call that omitted it. Parameters can be optional now and the check reads a minimum and a maximum, so string.index_of, string.slice, string.pad_start, string.pad_end, array.slice and json.stringify all carry their real return types.
  • Improved Diagnostics render through ariadne 0.6. The location header gains spaces inside its brackets; nothing else about the output changes.
  • Fixed Two concurrency defects, both found by running the tests on a slower machine than a laptop. A helper script was named from the process id alone, so parallel gen calls shared one file and the first to finish deleted it out from under the next. And a concurrent run of the same program could report a directory swap as an IO error, when the directory it was writing to had been correct when it chose it.

Nothing behaves differently at run time. Every one of the 124 modules in the examples tree builds unchanged; what changed is that four call shapes that used to reach run time now stop at the compiler.

0.1.70 August 2026

An index that is wrong, and a type that can be called Error

Two entries that had been sitting on decisions rather than on work. Both were measured before anything was written, and in both cases the options already on the table lost to one that was not.

  • Fixed An out-of-range index says so. cells[999] type-checked clean, passed tsc --strict, and handed back undefined where the compiler had promised a value, which then travelled until something dereferenced it somewhere else. Glyph was worse than Rust here, which tells the same lie in the type but stops at the bad index. xs[i] keeps its type and the read is bounds-checked, so out of range throws with the index and the length. array.get(xs, i) returns Option when absence is something the program handles.
  • Fixed A domain type may be called Error again. A union with an Error variant emitted a top-level function that captured every new Error(...) the compiler wrote below it, so the name was taken away. A module that shadows one of those globals now captures the real one under a private alias, and the author's name emits verbatim. The spreadsheet example reads Number | Text | Empty | Error again, which needed 162 captured references. Array is the exception and stays reserved: it is how every Glyph program spells an array, so a local one redefines a language type rather than shadowing a global.
  • Added cargo clippy gates every push, and CodeQL analyses the Rust, the TypeScript and the workflows. Turning clippy on found a test that never ran, another registered twice, and two doc comments detached from the functions they documented.
  • Improved Supply chain: every GitHub Action pinned by commit, every workflow read-only by default, Dependabot on the Rust and Actions dependencies, and the release now attaches its provenance bundle so the attestation is checkable without a network call. Two advisories cleared.

One behaviour change to know about. A program that read past the end of an array and carried on with undefined now throws at that read. That is the point, and it is the only way existing code can behave differently after this upgrade.

0.1.69 August 2026

Every open gap, and a client that can be bounded

Each open entry was re-run against the previous release before anything was written, which changed the work: one of them turned out to be three separate gaps under a single number, and another had already closed.

  • Fixed A parse could report success for a field it never checked. Every record type carries a runtime validator, and for a field whose type the compiler cannot see into (a socket, anything reached through extern_ts) the generated check was "is it present" while the message read field `sock` must be Socket. Validating such a record is now E0304, refused where it is written and naming the field. Declaring one is untouched, so holding a socket in a record is still ordinary. unknown is not caught by this: it claims nothing, so presence really is the whole check.
  • Fixed A string-literal union lost its exhaustiveness the moment it crossed a module. kind: ColType accepted any string once the type was imported, which is the same hole that was closed for match a few releases ago and was still open for record fields.
  • Fixed An HTTP client can bound and observe a request, and say why one failed: HttpError.kind is "timeout", "network" or "status", so a slow site and a dead one stop both arriving as status 0. http.send takes the whole request as one record carrying a timeout and a redirect policy, and the timeout aborts rather than racing a timer against a call that stays in flight. Response.url is where the response actually came from, so a followed redirect is visible. http.head is there too.
  • Fixed json.parse<T> reports the same field paths T.parse does. It collapsed every field failure into one issue reading expected T, and the one-step form is the one the guide teaches.
  • Fixed Two locally bound closures can call each other, so event-driven code stops having to lift both to top level and thread the shared state through a record.
  • Added never is spellable. A function that runs until the process is killed says so in its signature instead of a doc comment, and drops the unreachable return and the dead match arm it needed to stay exhaustive.
  • Improved A tsc error over an is arm that re-reads its scrutinee now explains that is narrows the binding, rather than reporting only the type mismatch it caused.
  • Improved .types/ says what it can and cannot do. A declare var there is a global, and Glyph resolves names from modules, so it satisfies tsc and stays invisible. A host global the standard library does not wrap is a gap in the standard library.

Three changes can reject code that compiled before. E0304 refuses to validate a record holding a field nothing can check. Response gained a required url field, and HttpError a required kind, so either one built by hand needs the new field. Both are the kind of change the pre-1.0 line allows, and both are a compile error rather than a surprise at run time.

0.1.68 August 2026

Null is absence, and a loop keeps its type

A wire type could not read the payload it was written for. field?: T accepted an omitted key and a present value, and rejected an explicit null, which is what every real API sends. A Discord gateway frame carries "s": null in every HELLO.

  • Fixed An optional field treats a JSON null as absent. The declared type is T, and null is not a value of T, so a key holding null is a key holding no value. glyph gen openapi had documented exactly this mapping while the runtime did not implement it, so a generated type rejected the payload it was generated from. Option<T>'s tagged encoding is untouched.
  • Fixed A for binding carries the iterand's element type, so exhaustiveness survives a loop. A match over a string-literal union inside a for went from "a string match can never be exhaustive, add an else" to "missing variants pro": from advice to switch the check off, to advice to satisfy it.
  • Known The two-binding for i, x in xs still loses its types, because the AST carries no per-binding spans. And an is arm that re-reads its scrutinee still reports a TypeScript message rather than a Glyph one.
0.1.67 Shipped August 2026

Three entries that had outrun their evidence

Three backlog items scheduled together, and all three closed by establishing what the compiler already does rather than by changing it. Two had been overtaken by fixes made for other reasons; the third asked for a feature that would cost the guarantee it wanted an exemption from. No language change ships here.

  • Fixed The empty map is spelled ({}), it compiles, and glyph fmt keeps the parentheses. A formatter test now pins that: un-spelling a workaround puts the file back into the error it was formatted out of.
  • Decided Glyph will not get untagged primitive unions. Tagged unions are sealed so a match over one is verifiable. Name the cases when you own the type; take the value as unknown and narrow with is when it arrives from somewhere you do not. --explain E0111 now shows both.
  • Resolved Optional fields are readable. What is not allowed is reading one into a non-optional T, and tsc draws that line correctly. They belong on a wire type consumed by its own parse, decoded into a domain type carrying Option<T>.
0.1.66 Shipped August 2026

Reading a key that may not be there

A Record<K, V> has arbitrary keys, so m.name cannot be checked, and it was typed V anyway. When the key was absent the value was undefined under a type saying otherwise, and nothing reported it: a mistyped column name read off a database row compiled clean, passed tsc --strict, and rendered as the text "undefined".

  • Fixed E0224 rejects reading a key out of a map and points at record.get, which returns Option<V>. Writing a key is untouched, because building a map is safe, and so is array indexing: a bound is a value a program can check with array.len, a map key is not.
  • Fixed == is value equality on every type. It lowered to === unconditionally, so it silently meant reference equality for records, tagged unions and arrays: Some("a") == Some("a") was false with no diagnostic, while the same expression written as an @example compared structurally and passed. A test could report success on code that did not work.
  • Fixed A match arm producing no value could throw the compiler's own "non-exhaustive match" at run time, on a match that was exhaustive.
  • Fixed glyph run with no path means the project you are standing in, so the four commands a new user is handed no longer end in a usage error. A scaffold now pins the compiler that wrote it, so npm install makes a checkout buildable with no global install.
  • Added io.print, io.eprint, io.is_terminal, io.stdin_is_terminal, process.set_exit_code, and examples/apps/jobq: a durable job queue with an HTTP API, a SQLite store and workers.
  • Known A map arriving from another module or the stdlib is still read unchecked, so sqlite.Row does not yet get E0224. That needs stdlib named types modelled as more than a field set.
0.1.65 Shipped August 2026

An app does not need TypeScript

Two example apps were reaching for the language Glyph exists to replace. The Discord bot needed a hand-written declaration file and six escapes to raw TypeScript to open a socket and run a timer; the chat server needed one to reach net. Every such line is a line the Glyph type checker does not see. The measure for this release was that both apps had to lose their TypeScript entirely, and both did.

  • Added std/timers: after, every, cancel, unref, sleep. Scheduling is a global in JavaScript and Glyph resolves module names rather than ambient globals, so before this there was no way to run something later without declaring Node's timers by hand.
  • Added std/websocket. Each event is its own function taking what that event carries, so no handler parameter needs narrowing and an event name cannot be misspelled: there are no event-name strings. on_close is handed the close code, because that is what separates an outage worth retrying from a rejection that will be rejected identically forever.
  • Added Six more Node builtins type-check with nothing installed: net, timers, events, child_process, dns/promises, zlib.
  • Changed Both apps were re-run, not merely rebuilt. The chat server still holds three concurrent TCP clients; the bot still passes a cooperative gateway and three adversarial ones. A CI check now fails the build if any app carries a .d.ts or an extern_ts, so the answer to a missing capability is to extend the stdlib.
  • Known A host global the stdlib does not wrap is still unnameable: ambient declare var in .types/ is invisible to the resolver. And an Option field still cannot be read from ordinary JSON.
0.1.64 Shipped August 2026

A match that was exhaustive could throw

Writing a Discord gateway client turned up a miscompile that twenty rounds of example apps had missed. A match arm that produces no value, one ending in a mut, emitted neither a return nor a break when it sat inside a lambda. The generated switch case ran straight into the compiler's own default: throw new Error("non-exhaustive match"). Twelve lines reproduce it: it compiles clean, tsc --strict passes, and it throws at run time on a match that is exhaustive.

  • Fixed The break now depends only on being inside a switch case, not on whether the arm is in return position. The same code inside a top-level fn was always correct, which is how this survived: nothing in the suite put a valueless arm inside a lambda, and socket callbacks are lambdas containing matches. A nested match had the identical hole one level down.
  • Added examples/apps/discord, a working gateway client: handshake, identify or resume, heartbeat on the server's interval, sequence tracking, detection of a connection that is open but dead, exponential backoff, and commands. The protocol and state machine are pure and carry 37 @example rows; one module touches the socket.
  • Changed Verified against an adversarial gateway written from Discord's docs rather than from the client: an unprompted opcode 1, a close with code 4004, and a server that greets you then stops answering. The cooperative mock had passed while the bot ignored opcode 1 and retried a rejected token forever.
  • Known Ambient global declarations in .types/ are invisible to the resolver, so WebSocket and the repeating timers cannot be named directly and new WebSocket(url) is an unresolved name. And an Option field still cannot be read from ordinary JSON: null, an absent field and a bare value are all rejected.
0.1.63 Shipped August 2026

A Glyph program can be a server

The chat trip was given the same assignment twice: a server several clients talk to at once. The first time it quietly shipped a session replayer instead. The reason turned out to be one line in the runner, and it failed in the worst way, which is silently. glyph run app.glyph --serve 4100 printed nothing, not even the line inside the listen callback, and exited 0. Every long-lived program was affected: servers, watchers, bots, REPLs.

  • Fixed Returning from main no longer stops the process. The generated entrypoint called process.exit as soon as main came back, which Node honours immediately even while the event loop holds live handles, so a server bound its port and died in the same tick. The exit code is now assigned, leaving Node's own rule in place: leave when there is nothing left to wait for. A program that only computes still exits the moment main returns, with the same code.
  • Fixed A nested project's .types/ declarations reach its own type check. The generated tsconfig.json included **/*.ts, which reaches into nested projects' output, while .types/**/*.d.ts covered only the outer project's directory. So an app passed on its own and failed as part of the tree with Cannot find name 'net'. Each project now excludes the output of the projects nested inside it.
  • Fixed A main that throws still terminates, but only once stderr has drained. console.error is asynchronous when stderr is a pipe, which is every CI job, so the old exit could truncate the diagnostic it had just written.
  • Added examples/apps/chat is a real multi-client TCP server. Line reassembly, event routing and the room engine are pure and checked by @example; one file touches sockets. Verified with three concurrent clients: a room post reaches that room and nobody else, a direct message reaches exactly two, and three clients dropping at once are each announced under the right name.
  • Known Nothing sets the exit code once main has returned, so a listener that fails to bind exits 0 unless the program calls process.exit itself. And there is still no way to write down that a function does not return, so a server's main carries a return 0 that is never reached.
0.1.62 Shipped August 2026

A program can answer while you are still typing

Building a chat client turned up something that had been true since std/io was written: io.read_line was never a line reader. It slurped stdin to end of input, split the result, and handed out the pieces. Piping a file works. Typing into it does not, so the program sat silent while you typed and every answer arrived at once after Ctrl-D. That made a whole category of program unwritable in Glyph, and three apps in the repository had been quietly shaped around it.

  • Fixed io.read_line returns as soon as a full line arrives and does not wait for stdin to close. stdin is read one chunk at a time into a shared buffer, so a prompt/read/respond loop answers while the writer is still connected. On (printf 'a\n'; sleep 2; printf 'b\n') the two lines now print 1ms and 1405ms in; before, both printed at the end.
  • Fixed A trailing \r is stripped, so CRLF input reads the same as LF, and input that ends without a newline still hands back that last line once before None.
  • Changed io.read_to_string drains the same buffer instead of re-reading the descriptor, so read_line and then read_to_string gives you the rest. Called first it still returns all of stdin.
  • Fixed Three example apps became interactive with no change to them: minesweeper redraws the board between moves, the text adventure answers look while you type, and minilang --repl evaluates a line and prints the result before reading the next.
  • Known Two things an interactive program still cannot do. std/io has no write-without-a-newline, so a > prompt has to be a line of its own, and nothing reports whether stdin is a terminal or a pipe, so a program that behaves differently for each has to be told by a flag.
0.1.61 August 2026

A directory can say it is a project

A local import resolves from a root, and until now that root was whatever directory you handed to glyph build. That made a project uncombinable with anything: an app whose modules resolved perfectly on its own stopped resolving the moment you built the directory above it, and the failure named neither imports nor layout. A package.json carrying a "glyph" key now marks its directory as a resolution root, nearest one wins, so a tree of projects builds in one command and each project still builds alone.

  • Added D41. A directory holding a package.json with a "glyph" key is a module-resolution root. The key already existed: glyph init writes it and glyph publish reads it, so this honours a marker the toolchain already had rather than inventing one. It amends D15 and does not replace it, since imports are still slash-separated from a root and still never relative.
  • Added A project's imports resolve inside its own root only. A nested project cannot reach an enclosing one and an enclosing project cannot reach into a nested one, which is what packages/foo already means to anyone who has used a workspace. Reaching another project is what npm is for, and the error says so.
  • Fixed glyph build over a tree of projects builds every one of them. The whole examples directory, 105 modules across six apps and a corpus, now compiles and type-checks in a single invocation. It could not before.
  • Changed With no marker anywhere, nothing moves: the directory you passed is still the only root. Every existing single-project build behaves exactly as it did.
  • Known A bare import naming nothing at all is still handed to tsc rather than rejected, because at the Glyph stage import modle and import react are the same shape and rejecting one would reject the other. The cross-project case is caught: importing a sibling project's module reports where that module actually lives and that npm is the way to reach it.
0.1.60 August 2026

The compiler stops blaming the wrong line

Five small things, none of which changes the language. Three are the same defect in different clothes: the compiler knew something was wrong and reported it somewhere you could not act on. A type you declared named Issue came back as a tsc error about generated code you never wrote. An import that resolved to nothing came back as a non-exhaustive-match error on a match that was exhaustive, so the message asked you to add an arm for a case that could not happen. Those are worse than no message. A wrong error costs you the time to chase it and then the trust you had in the next one.

  • Fixed Declaring a type named Issue or Record is E0110, reported at the declaration. Every descriptor's parse writes Issue[] whether or not you did, so a local Issue won the name and the build failed with TS2353 pointing into dist/. Record sits with the JavaScript globals rather than the prelude names, because it is a TypeScript built-in and saying otherwise would make the message wrong about where the name comes from. Schema, Component and Option stay legal: the emitter writes those only because you wrote them in an annotation, so declaring one shadows nothing.
  • Fixed A local import that names no module is E0104. A local import path resolves from the build root, the directory you passed to glyph build, not from the importing file's directory, so building an enclosing tree left the failure silent and the imported type degraded. The message names the module and, when a matching .glyph file exists elsewhere under the root, says where it actually is. An npm package is not caught by this: the build first collects every declare module under <root>/.types/ and in the bundled Node shim, plus every package in your node_modules.
  • Fixed A wrong type and a failed predicate stopped reading alike. Against type Password = string where long_enough(value), the number 42 reports expected Password (string) with code: "type", and only a string that fails the predicate gets the where clause in its message and code: "refinement". Both used to carry the refinement text, so a client that sent the wrong JSON type was told its password was too short.
  • Changed glyph run <dir> runs that directory's main.glyph, which is what glyph build <dir> already meant. Two commands disagreeing about what a directory is costs you once, and after that you stop trusting either.
  • Known The refinement split is covered by unit tests, not by an app. No transcript step in examples/apps/auth_api posts a wrong-typed password, so the branch that would answer 400 where the old code answered 422 has not been seen doing it. The union descriptor's parse still emits a bare expected Name with no code, so records and unions do not classify alike. The LSP does not report E0104: it analyzes text with no build root, and guessing one would produce exactly the false positives this check was built to avoid.
0.1.59 August 2026

The boundary knew which rule you broke and would not say

A record's parse checked three separate things about every field and reported all three with the same sentence. A missing password, a password that was a number, and a password that was a string of four characters failing string where value.length >= 8 all came back as field `password` is missing or has the wrong type. One of those is a client that forgot a field and one is a client that chose a weak password, which is a 400 and a 422, and the validator had computed the difference and then dropped it. Building a signup and login API in examples/apps/auth_api, the way out was a second copy of every payload type with the constraints removed, parsed a second time on the failure path, purely to recover the bit the first parse already had. Two types, two parses, a helper each, and a constant restating the rule that was already written on the type.

  • Fixed Each field is tested absent first, then wrong, with a message for each. An absent required field reads field `password` is required; a present one of the wrong type reads field `email` must be string, naming the type as the declaration spells it. An optional field (f?: T) is never reported missing.
  • Fixed A where refinement names the predicate it enforced: expected Password (string where value.length >= 8). The rule is written once, on the type, and the string in the 422 body greps back to that line. This is the half of D39 that was specified and not delivered.
  • Fixed Arrays no longer pass the object test. typeof [] === "object", so a record with no required fields accepted [] outright, and a posted [1, 2, 3] came back as one misleading issue per declared field with nothing saying you had sent a list. Both is and parse exclude arrays, and parse says expected Signup (an object), got an array.
  • Added Issue carries an optional code: "missing", "type", "refinement", or "unexpected". Branch on that instead of matching message text. It is optional, so every Issue you already construct or consume compiles unchanged.
  • Added A field whose type has its own descriptor is validated by that type's parse, with the field name spliced onto the front of each nested issue's path. A refinement two levels down reports its own message at ["body", "password"] rather than a flat check at the top.
  • Changed examples/apps/auth_api dropped the workaround: two duplicate payload types, two helper functions, the restated rule constant, and both re-parse blocks are gone, and the four boundary responses in its transcript are now four different answers.
  • Known A module that declares its own type named Issue shadows the prelude one and breaks every descriptor in that module, because parse annotates its error array as Issue[]. The fix is the one Result already uses, referencing the prelude type through an injected alias, and it has not landed. A field typed by something with no descriptor of its own, an unconstrained type parameter or a type from a hand-written .d.ts, still gets the flat check and a "type" issue.
0.1.58 August 2026

An imported record arrived without its fields, and the loop over it counted in strings

Splitting a program into modules used to cost you two checks. A record type declared in one file and used in another arrived with nothing known about it, so sheet.rowz on an imported Sheet drew no Glyph error at all, and for i, r in sheet.rows emitted Object.entries(sheet.rows) instead of sheet.rows.entries(). That binds i to the string "0", so i + 1 concatenates: a row labelled line 01 where it should read line 1, and line 11 for row 2. tsc catches the arithmetic uses of that index and reports them against a variable Glyph bound for you. It cannot catch the ones where a string works and is wrong, which is interpolation, concatenation, and record.get keys. The query engine in examples/apps/csvql carried three let hoists and a four-line comment whose only job was to tell the emitter what an imported field's type was.

  • Fixed An imported type keeps its identity. It lowers to a new Ty::Imported { module, name } keyed on the declaring module and the name that module declares, so import catalog { Sheet }, import catalog with catalog.Sheet, and import catalog as c with c.Sheet all mean the same type. A typo'd field is now E0210 saying type `Sheet` has no field `rowz`, naming the record rather than saying record, and for i, r in sheet.rows binds a number with no annotation. The declaration is fetched on demand and lowered on the declaring module's side, which is the part a consumer cannot do, so a self-referential type and a two-module cycle both terminate with no cycle guard.
  • Fixed The three hoists in csvql are gone, along with the comment explaining them. table.build loops straight over sheet.rows and spec.columns, bind.fields_of over spec.columns, and the app prints byte-identical output for all twelve queries. Emitted TypeScript for every other app under examples/apps/ is unchanged.
  • Fixed Visibility answers the same way whichever spelling names the type. import lib { Secret } on a non-pub type was always E0105; import lib plus a lib.Secret annotation reported nothing, and once an imported type had a field set that silence would have handed out a private type's fields. Both report E0105 now.
  • Known Assignability does not cross yet. Passing a catalog.Sheet where a table.Row is expected is still a tsc error rather than a Glyph one, because whether the cross-file rule should be nominal or structural is an open language question. An interface's member list does not cross either, for the same reason: giving it the shape a record's field set gets would quietly redefine what structural satisfaction means.
0.1.57 August 2026

The compiler asked you to delete an exhaustive match

A string enum is one of the few places TypeScript already does well, and Glyph adds the part it is missing: match every value and you need no else, drop one and the build fails. That stopped at the file boundary. type ColType = "text" | "int" | "real" | "bool" declared in one module and imported into another lost its four values on the way across, so a match covering all four came back E0218, with help text reading "Add an else arm. A number/string match with only literal arms can never be exhaustive." That was false about the code in front of it, and doing what it said turns a compile error into a silent runtime fallthrough. This came out of writing a query engine over CSV, where the author did what it said: a dead else => None shipped in the app, with a comment recording what it cost.

  • Fixed An imported string-literal union keeps its literals. A match covering every one of them compiles with no else, and omitting one is E0200 naming what you left out, the same as if you had declared the type in the file you are matching in. All three spellings work: import catalog { ColType }, import catalog with a catalog.ColType annotation, and import catalog as c with c.ColType.
  • Known Record types do not cross yet. A record imported from a sibling module arrives without its field set, so a typo'd field on it reaches tsc instead of stopping at Glyph, and for i, x over one of its array fields binds i as a string. Hoisting the field into an annotated let is the workaround. It is the same hole this release closed for unions, but the fix has to live on the declaring module's side, so it is separate work.
0.1.56 August 2026

How you imported the union decided whether it was checked

Exhaustive match is the guarantee Glyph leans on hardest, and it was off for one of the two ways you can import a union. match c { model.Yes(_) => …, model.No(_) => … } over a three-variant union reported no diagnostics, passed tsc --strict, and then threw Error: non-exhaustive match at run time. Writing the same match with import model { Yes, No } was E0200. This came out of writing a statechart replay engine, where every module carried an eighteen-name variant import so its matches would be checked, with a comment in the source explaining the import lists as a syntax rule. They were not a syntax rule. They were a workaround.

  • Fixed A qualified arm is checked like a bare one. import model with model.Yes(_) arms, and import model as m with m.Yes(_), are held to the same exhaustiveness bar as import model { Yes } with bare arms. The lookup used to try to resolve Yes as a symbol, which under a namespace import it never is, so it found no union and checked nothing. It now resolves the arm through the head of the path.
  • Fixed The standard library's unions get it too, which is the half that mattered most. option.Option<T> and result.Result<T, E> used to lower to an unknown type, because the table of two-segment stdlib types held only the three fs.* entries. So the most-used union in the language lost its exhaustiveness check to a one-token change in how you imported it: match o { option.Some(s) => s } with None missing was green through both checkers. Both now lower to the same type the named import produces.
  • Fixed A misspelled qualified variant is E0220 with the nearest-variant hint, on the arm. It used to enter the covered set unexamined and come back from tsc as a TS2678 about a literal union type, twenty lines from the name you mistyped.
  • Known When E0200 lists the variants you missed, it quotes their names for a union declared in the same module and for the prelude ones, and leaves them bare for a union imported from another Glyph module. Two code paths build that list and only one of them formats. Cosmetic, and it predates this release.
0.1.55 August 2026

A loop index that was a string

Read a Record, match the Option it hands back, walk the array inside it with an index. Three ordinary things, and the chain miscompiled: the two-binding for took the record lowering, so the index bound the string key "0" instead of the number 0, and the program printed 01:x where it should print 1:x. No diagnostics, and tsc --strict passed. This came out of writing a dependency resolver, where the fix for it had been a let path: Array<string> annotation with a comment saying the annotation was load-bearing.

  • Fixed std/record is modeled in the checker. All six of get, has, keys, values, set and remove carry their return type, and the value type is read off the record you pass, so record.get(t, k) on a Record<string, Array<string>> is an Option<Array<string>> and the Some(p) arm binds an Array<string>. It was the last of the three core modules with no types at all: record.get used to be Unknown, which meant everything downstream of it was too. The ordered walk gets it as well, since record.keys(t) is an Array<string> and array.sort(record.keys(t), cmp) now keeps its element type.
  • Fixed An empty array arm no longer sinks the match. The arm join compared types by equality, and [] is an Array<Unknown>, so None => [] read as disagreeing with an Array<string> arm and left the whole expression untyped. The join now goes one level in: with the same head and the same arity, arguments join pairwise and Unknown takes the other side, so those two arms agree on Array and the loop after them binds a number.
  • Known Two arms with different heads still join to unknown, and so does a match where one arm's type is undecidable. Projecting a known arm's type onto an arm nothing is known about would be a guess, and this join decides how a for lowers, so a guess there is a wrong program rather than a missing hint.
  • Known An iterand whose type is honestly unknown, such as a call into one of the nine stdlib functions still outside the modeled table, keeps the record lowering and binds a string index. Annotate it Array<T>. Whether that case should be an error instead is an open call.
0.1.54 August 2026

A declaration can't quietly take a name the module needs

Every top-level name you write reaches the emitted TypeScript verbatim, including the constructors of a tagged union. So a variant named Error emits export function Error(...) at module top level, and the new Error(...) the compiler writes below it calls your variant instead. The name is legal TypeScript, so nothing downstream noticed. You saw it as a tsc error twenty lines away, about something else. This came out of writing a spreadsheet, where a cell is a number, a label, nothing, or an error, and two of those four words are already taken.

  • Fixed E0110 rejects a top-level fn, type, const, component, or variant whose name is already bound in every emitted module: the JavaScript globals the emitter refers to (Object, Array, Promise, Number, Error) and the prelude names in scope without an import (number, par, print, assert, and the primitive type names). The span is your declaration, which is where the rename goes. The list is derived from the emitter rather than from a general list of JavaScript globals, and a test greps the emitter and fails when a new global reference appears without an entry. Number was harmless until int shipped and started emitting Number.isInteger, which is how this reached a release. Date, Math and JSON stay free, because nothing Glyph emits mentions them.
  • Fixed type Key = string | number is E0111 instead of a clean build that means something else. In Glyph A | B declares a tagged union whose members are variant constructors, so that line declared variants called string and number and emitted an export const number that shadowed the prelude. It got its own code because rejecting it teaches nothing: the message says what the line parsed as, points at named variants, and names extern_ts("string | number") for a raw TypeScript union at a boundary.
  • Added A reserved-words reference: the 32 keywords, the 33 TypeScript reserved words behind E0109, and the new shadow list, in one table with the reason each name is taken. A test keeps the page in step with the compiler.
  • Known You still can't name a type or variant Error, Number, Object, Array, or Promise. The error tells you to rename, and renaming is still what you have to do. Making the name usable means mangling Glyph names in the emitted TypeScript, which changes what a stack trace, a grep over dist/, and a hand-written extern/ shim see. That is not scheduled.
  • Known A union of two primitive types has no spelling in Glyph other than extern_ts, whose contents Glyph's own checker treats as opaque. Untagged unions touch exhaustiveness, runtime descriptors, and is, so this is a type-system call rather than a patch.
0.1.53 August 2026

The checker knows what the standard library returns

Glyph's typechecker modeled the standard library's function signatures and nothing about its types, so a value stopped having a type the moment it came out of std/string, std/array or std/fs. You noticed it as a loop index that was a string, or a match on a filesystem error that nothing checked.

  • Fixed Return types for the fixed-arity half of std/string and std/array. string.split is an Array<string>, string.starts_with is a bool, array.find is an Option<T>, and the element type travels, so array.filter(names, keep) over an Array<string> stays one. The visible payoff is the loop: for i, part in string.split(text, ",") binds i as a number with no annotation, where before it took the record lowering and handed you a string key.
  • Fixed A match e.kind on an fs.FsError is exhaustively checked. fs.ErrorKind is a closed set of six, and the checker now carries its variants and its payload, so covering all six needs no else arm, leaving one out is E0200 naming fs.ErrorKind, and fs.ErrorKind.Other({ code }) binds code as a string. A typo like e.mesage is E0210 against your Glyph source instead of a tsc error against generated TypeScript. fs.FsError and fs.FileInfo have the same treatment.
  • Added async fn(A, B) -> T is a type you can write, wherever a type goes. It emits (a0: A, a1: B) => Promise<T>. Before this a function returning an async thunk or a record of async handlers could not be annotated at all, because a plain fn() -> T emits () => T and an async body does not fit it. The two are held apart by Glyph, not by tsc: a plain function where an async one is expected is E0204 at a return and E0211 at a call argument.
  • Fixed The spec now describes both string forms the compiler has always accepted. "..." decodes escapes, """...""" does not, both interpolate, and """ is legal anywhere a string is rather than only after @doc. Nothing about the compiler changed here; the documentation was wrong about the language.
  • Known Six functions with an optional trailing argument (array.slice, string.slice, string.index_of, string.pad_start, string.pad_end, json.stringify) plus array.map, flat_map and zip are still untyped, so a value straight out of one of those needs an annotation before you iterate it with an index. The arity check compares one number against one number and has to learn a range first.
  • Known Strings index by UTF-16 code unit and will keep doing so. There is no chars or char_at and none is planned, because an accessor that can return half of a surrogate pair is worse than no accessor. To walk codepoints, encode with encoding.hex_encode and read two hex digits at a time.
0.1.52 August 2026

A parsed value is a real Result

Glyph's typechecker said T.parse returned Result<T, Array<Issue>>. The generated TypeScript said something narrower, so passing a parse result straight back from a function returning a Result was a tsc error. Two checkers, two answers.

  • Fixed T.parse returns the prelude Result, so it composes with everything else that takes one. return User.parse(req.body) from a function returning Result<User, Array<Issue>> compiles, and User.parse(body).map_err(to_http_error) works instead of failing with TS2339. The old emission was a bare { tag, value } object, written that way so a module holding a type would not depend on std/result; the cost of that was the whole combinator API. A module with a pub type in it now imports std/result at runtime even if it never writes Result, which is the price of the fix.
  • Added array.range(count) and array.range_from(start, end). A counted loop had no source in the language, so apps kept hand-rolling one: for i in array.range(rows) replaces it. range clamps like string.repeat, so range(-1) is [] and a fractional count truncates. The second argument to range_from is an exclusive end bound, the same reading array.slice gives one, so range_from(2, 5) is [2, 3, 4].
  • Fixed glyph fmt keeps a multi-line string that interpolates. A literal with no ${...} was copied verbatim; one with interpolation was rebuilt from its parts, so the real newlines came back as \n and the string collapsed onto one line. The documented multi-line form was unusable under format-on-save. Both kinds are copied verbatim now.
  • Known The formatter still has no layout rule for a .-chain that does not fit. The && / || / ?? half landed in 0.1.51; the chain half is waiting on a call about whether breaking array.map(...).filter(...) at its first dot is acceptable, since that would put array and .map on separate lines and grep "array.map(" would stop finding it.
0.1.51 August 2026

Ask whether it compiles without running it

There was no way to type-check a single file. glyph build refused anything that was not a directory, so the only door into the typechecker was glyph run, which starts your program. glyph check is that door.

  • Added glyph check [path] takes a .glyph file or a directory, runs the same pipeline a build runs into a temp directory it deletes on the way out, and type-checks the emitted TypeScript with tsc --strict. Nothing is written into your tree and your program never starts. --no-tsc stops after the Glyph stages; --json gives an agent the same diagnostic shape glyph build --json does. Two things to know: a file is checked in the context of its own directory, so a broken sibling fails the check, and your @example tests do not run here, because running them would run your code.
  • Fixed A failing build no longer opens with a green line. The Glyph-stage summary was printed before the TypeScript stage ran, so “no diagnostics” could sit directly above a wall of tsc errors. It now prints after every stage that can turn the build red.
  • Fixed Hyphenated arguments reach your program. glyph run app.glyph --min -12.50 used to be rejected by the argument parser before main saw anything. Flags glyph owns still bind to glyph, so put -- in front of a program flag that collides with one.
  • Fixed --no-tsc is the one name for skipping the TypeScript stage, on build, check, and run. --no-check keeps working as a hidden alias. Separately, glyph build --json reported success on a machine with no tsc installed while the same build's text output reported failure; both now agree that a stage which could not run is not a pass.
  • Fixed Three things that already worked and nobody could find: math.max was buried in a slash-grouped reference line, so grepping for it found nothing; the two std/time import lines were described once, incorrectly; and multi-line strings were only ever mentioned in a spec decision. All three are now documented where you would look for them.
  • Known glyph fmt preserves a multi-line string only while it has no ${...} in it. An interpolating string is reprinted from its parts and the raw newlines come back as \n, so use \n in that case rather than fighting the formatter.
0.1.50 August 2026

glyph fmt stops being the thing that breaks your build

Three formatter problems, one of which was a correctness bug: running glyph fmt on a file that built could leave you with a file that did not. You run a formatter without reading its output, so that is the worst thing it can do.

  • Fixed X => ({}) in a match arm means the empty record. It parses, builds, and passes tsc --strict. glyph fmt used to reprint it as X => {}, which is an empty block, and the formatted file failed to build. The printer now keeps the parentheses on the one shape that would change meaning without them, so the output reparses to the same program and emits the same TypeScript.
  • Fixed The 100-column print width now applies at every element count. A list of one or two elements used to skip the width test entirely, so array.map(xs, fn(item) { ... }) stayed on one line however long it got, and the formatter's own output held 142-column lines while the guide said it wrapped at 100. A nested list also measured its width from column zero instead of where it actually starts, which is fixed with it.
  • Fixed Repeated annotations of one kind keep the order you wrote them. Several @example lines above a function used to come back sorted by their argument text. The formatter sorts annotations by kind, which is what D27 asks for; the sequence within a kind is yours.
  • Fixed A one-statement match arm body prints on one line. X => { break } used to expand to three lines, because the parser wraps a bare arm body in a block and every block printed multi-line.
  • Known The formatter still has no rule for breaking a long method or operator chain, so it breaks the innermost argument list instead. The width fix makes that easier to spot: a long || chain now breaks a call's arguments in the middle of the chain rather than sitting on one over-wide line. Separately, the width check measures a list up to its closing bracket and does not see what follows, so a fn signature whose -> T { tail crosses 100 columns reads as fitting.
0.1.49 August 2026

Four things tsc caught and Glyph did not

Four apps in this repo each filed a version of the same complaint: glyph build said ok, and the mistake showed up later, either as a TypeScript error on generated code or as undefined at run time. This release adds four diagnostics, all reported against your own source with your own line numbers.

  • Added E0222: await in a plain fn used to build clean and fail at tsc with TS1308. The innermost enclosing callable decides, the same rule TypeScript uses, so a synchronous lambda inside an async fn is flagged and async fn(x) { ... } is not. An await in a module-level const initializer has no enclosing callable and the emitted module accepts it, so that one case is still allowed.
  • Added E0223: X => {} in a match you assign emitted case X: { break; }, so the binding was undefined while its type said otherwise. It now reports wherever the position is decidable: a let, a mut, a return, or the tail of a function with a declared return type. An arm that means nothing where the match is a statement is untouched.
  • Added E0008: a bare x = e said "unexpected token: Equals". It now says assignment requires mut, points at D5, and glyph --explain E0008 shows the before and after. It covers r.field = e and xs[0] = e, and it fires inside a match arm, where the old message was about a missing comma.
  • Fixed ? works in an expression-form match arm. None => lookup(b, name)? was refused as "not implemented yet" while the identical code in a block arm compiled, so one app carried the block form and a comment explaining why. The arm body now hoists its unwrap the way any other statement value does; the app lost eight lines and emits byte-identical TypeScript.
  • Fixed The two positions where ? still cannot go say why. E0302 is an arm of a match nested inside a larger expression, which compiles to a closure the return would escape. E0303 is a position with no statement to hoist into, such as a match scrutinee. Both suggest binding first.
  • Fixed A => { "Content-Type": "application/json", } parses as an object literal instead of a block, since a block cannot start with a string literal followed by a colon.
  • Known There is still no way to write an empty record as an arm body. {} is an empty block and stays one, => ({}) builds but glyph fmt reprints it as => {}, and until that is settled an arm that means "the empty map" needs a named constructor. E0223 also needs a decidable position, so a match in the tail of an unannotated lambda is still silent.
0.1.48 August 2026

The link checker stops working around the standard library

One app in this repo walks a directory, scans Markdown for links, and checks them over HTTP. It could not do any of those three things with the standard library alone. It imported node's readdirSync directly, worked out which paths were directories by reading them and inspecting the errno, hand-wrote a 180-line character scanner because regex.find_all drops capture groups, and used a fail-fast pool that threw away every result when one host went down. This release adds the four missing pieces and then rewrites the app until none of the workarounds are left.

  • Added fs.read_dir returns the entry names one level down, fs.is_dir answers with a bool the way fs.exists does, and fs.stat returns a FileInfo of is_dir, is_file, size in bytes, and modified in epoch milliseconds. There is no recursive walk and no glob: a walk is those three plus path.join in about ten lines.
  • Added FsError.kind was { tag: string } with NotFound as the entire taxonomy. It is now the closed set NotFound, IsADirectory, NotADirectory, PermissionDenied, AlreadyExists, and Other({ code }) carrying the raw errno for anything unnamed. EACCES and EPERM both arrive as PermissionDenied.
  • Added regex.captures_all(pattern, text) returns the capture groups of every match, one inner array per match. It follows captures: groups start at 1, so the whole match is not in the array, and a group that did not participate is "".
  • Added task.pool_settled(limit, tasks) is task.pool with all_settled's failure behaviour. Same bound, one outcome per task in order, never rejects. The docs now also say what fail-fast costs: a pool rejection discards the other workers' results, it does not stop them, so a fail-fast pool over 500 URLs still sends all 500.
  • Fixed The app lost its readdirSync import, both e.kind.tag == "EISDIR" probes, and 97 lines of character scanner, replaced by two regex patterns and a 12-line dispatch. Two behaviour changes came with it, each measured against the app it replaced. On a tree with an unreadable subdirectory the new version prints permission denied and counts the path; the old one dropped it silently. With one of three fetches throwing, pool_settled printed all three rows and named the failing URL, while pool printed nothing and died on an unhandled rejection.
  • Known Nothing checks a match on FsError.kind. Glyph's typechecker models what a stdlib function returns, not the shape of a stdlib type, so a match that forgets PermissionDenied compiles clean and throws at run time. Write the else arm. read_dir also returns entries in the OS's order, so sort them yourself when the output has to be reproducible.
0.1.47 August 2026

The eleven standard-library functions every app kept hand-rolling

Five dogfooding trips in a row filed the same finding: std/string and std/array are short of the basics, so every program writes them again. Six apps in the repo carried their own repeat, five their own pad_start, and every total was a mut in a loop because there was no fold. This release adds the functions and then deletes the copies.

  • Added std/string gains repeat, pad_start, pad_end, slice, index_of, replace_all, trim_start, and trim_end. Indices are UTF-16 code units, the same space len and split already use, and slice matches array.slice: exclusive end, negative indices counting back from the end.
  • Added std/array gains fold, index_of, and flat_map. fold takes the collection, then the seed, then a callback that gets (acc, x).
  • Added Three deliberate breaks from TypeScript. repeat returns "" on a negative count where TS throws, which is what makes repeat(pad, width - len(s)) safe. Both index_of functions return Option<number> instead of the -1 sentinel, which is a number that type-checks everywhere a real index does. Replacement ships only as replace_all, so nothing looks like String.prototype.replace, which quietly replaces one occurrence.
  • Fixed The hand-rolled copies are gone from all seven apps in examples/apps/: 191 lines of helper bodies deleted outright, 211 lines net. linkcheck's four line scanners take a string instead of an array of characters, its -1 sentinel became a match on Option, and shortlink's five regex.replace_all calls (a compiled regex per literal needle) became string.replace_all.
  • Fixed mut is how you find every place state changes, and with no fold that search came back full of counting loops. Seventeen fold sites landed and grep -c "mut " over examples/apps/ went from 192 to 161. The rewrite is proved by output: all seven apps ran against fixed inputs before and after, 26 files of stdout, exit codes, and HTTP responses, all byte-identical.
  • Known There is no codepoint-aware chars or char_at yet. Shipping one means choosing whether std/string indexes UTF-16 code units or codepoints, and the two answers disagree on any non-BMP string. Glyph's own checker does not model the two index_of return types either, so a match on one with no None arm builds clean, passes tsc --strict, and throws at run time.
0.1.46 August 2026

std/http can serve a web page, not just JSON

A response was { status, body } with nowhere to put a header, and the server chose the content type from the body's shape: a string went out as text/plain, anything else as JSON. So a 302 Location and a text/html page were both unspellable, and the only way to serve a browser was to write your own server on node:http in a hand-written .ts file, which puts your entire request path outside the type checker. A URL shortener in the repo was doing exactly that, in 121 lines it should never have had to write.

  • Added Response carries headers: Record<string, string>, and three constructors build one: html(status, body), redirect(status, location), and with_header(resp, name, value), which returns a new response because Glyph has no record-field mutation. The field is required rather than optional, so reading resp.headers never needs an absence check.
  • Added form(req) parses an x-www-form-urlencoded body into a Record<string, string>, decoding + as a space along with percent escapes. It reads the raw body, so req.body is unchanged for handlers that parse it themselves.
  • Fixed The server writes your headers to the wire and only infers a content type when you did not set one, compared case-insensitively, so every program written before this release sends the same bytes. Header values are stripped of every character Node refuses to write: CR and LF stop response splitting through a location built from a query parameter, and the rest stops an emoji in a shortened URL from throwing out of a path with no Result and taking the process down.
  • Fixed The type checker knows html, redirect, and with_header return a Response. Without that a handler's Ok(http.html(...)) typed as unknown and was checked only by tsc on generated TypeScript, which is the leak the release exists to close.
  • Known There is no percent encoding for building a URL, so the shortener still hand-writes url_encode. On the client side there is still no request timeout, no redirect policy, no head, and no final URL after a redirect.
  • Added examples/apps/shortlink/main.glyph is rewritten with the shim deleted: 615 lines to 494, imports nothing outside std, and still serves the form, the HTML, and the 302.
0.1.45 August 2026

A value that came out of a match keeps its type

Glyph has no if, so match is how you branch, and the type checker recorded nothing for it: every match expression in every program typed as unknown. Bind one to a let and everything after that point was flying blind. A field typo on the binding came back as a TS2339 on generated TypeScript instead of Glyph's own E0210, and a two-binding for over one of its fields picked the wrong lowering: i came out as the string "0", the build was clean, tsc --strict was clean, and the program printed 01:a where it should print 1:a. An expense splitter found it, and was carrying an Array<T> annotation with a comment above it explaining why the annotation could not be deleted.

  • Fixed A match takes its arms' type when they agree. An arm ending in return, break, or continue diverges and does not vote; every other arm contributes the type of its value. Arms that disagree, or an arm the checker cannot decide, leave the result unknown exactly as before, so nothing that compiled stops compiling. There is no widening and no union of arm types.
  • Fixed T.parse has a signature: Result<T, Array<Issue>>, for the record, tagged-union, and refined-primitive types that emit a runtime descriptor. This is the boundary between untrusted input and typed data, and it was opaque to the checker, which undid every inference downstream of it. A plain alias like type Cents = int emits no descriptor and still gets no parse.
  • Known An iterand whose type the checker really does not have, a call into a stdlib function it does not model such as array.slice, still binds a string index with no diagnostic. Keep the let ys: Array<T> = ... annotation there. A generic type's parse is also still untyped, because its descriptor takes one runtime checker per type parameter.
  • Added examples/apps/settle/main.glyph, the app that found it. Split group expenses evenly, by exact shares, or by weights, in whole cents, then settle everyone up in as few payments as possible.
0.1.44 August 2026

Editing a hand-written shim rebuilds instead of running the old one

glyph run caches a build under a fingerprint of your sources so a repeated run of unchanged code just executes. The fingerprint hashed every .glyph file and every .d.ts under .types/, and stopped there. A .ts you wrote yourself under <src>/extern/ was left out, even though the build stages it into the output and tsc checks it with the rest of your code. So you edited your shim, ran the program, and got a clean type-checked build of the version you had before the edit. A URL shortener found it: the app declared its response shape twice, once in Glyph and once in the shim, and the tsc pass that was supposed to keep the two in agreement was reading the stale copy.

  • Fixed The fingerprint hashes every .ts and .tsx under <src>/extern/, by path as well as by contents, so editing a shim, renaming one, adding one, or deleting one rebuilds and re-runs tsc. Before the fix, renaming a field in the shim left the app compiling against the old declaration; now it is a TS2353 on the .glyph line that builds the wrong object.
  • Fixed A symlinked shim under extern/ is followed, so a .ts that lives outside the source tree is hashed by its target's contents while still counting under its own path. A symlink cycle terminates instead of hanging.
  • Known A file under extern/ that is not .ts or .tsx does not bust the cache, which is intended: a README.md next to your shim is copied into the output with it, but nothing type-checks or runs it, so there is nothing to rebuild. Symlinked .glyph sources are still skipped by the source walker, so keep your Glyph files as real files.
  • Added examples/apps/shortlink/main.glyph, the app that found it. Shorten a URL, redirect a visitor, count the clicks, with the HTML and the 302 handled by a hand-written server shim that the app now imports its response type from instead of declaring it twice.
0.1.43 August 2026

Your @examples run on every build

Glyph puts tests next to the code: an @example above a function, or a @run fence inside a @doc, is an assertion the compiler is supposed to execute. It executed them only when you passed --test, so a plain build of a project whose own assertion was false printed no diagnostics, printed tsc --strict passed, and exited 0. The --json output that agents and CI read was worse: it returned before the examples ran at all, so even --test --json reported "ok": true. A tournament bracket app found it by carrying 23 examples that a plain build never touched.

  • Fixed glyph build runs every @example and @doc @run with no flag to remember, and a false one fails the build. The bracket app now reports 23 example(s) passed; break one assertion and the build prints example failed: bracket example #16 and exits 1, with the tsc --strict passed line withheld.
  • Fixed --json reports the same verdict. The output carries an examples object with total, ran, skipped, and failures, and a failure makes ok false with a matching exit code. The two channels can no longer disagree about whether a project passed.
  • Fixed A missing tsx on a project that has examples is now handled the way a missing tsc already was: no success line, "ok": false, exit 2. A build that could not run its verification does not get to look verified.
  • Changed --test is replaced by --no-test, which skips the checks and prints how many it skipped so the bypass shows up in the log. --test is still accepted and does nothing, so existing scripts keep working.
  • Known The runner shells out to tsx. A project with examples and no tsx on PATH fails where it used to build quietly, so an offline or minimal-image build needs tsx installed or --no-test. A project with no examples does no work and needs nothing.
  • Added examples/apps/bracket/main.glyph, the app that found it. Single-elimination brackets: seeding by standard bracket order, byes when the entrant count is not a power of two, reporting results that propagate winners through the feeder slots, and an ASCII bracket that re-renders after every match.
0.1.42 August 2026

A match you assign is a switch, not a closure

Glyph has no if, so match is the conditional, and in real code it is usually the right-hand side of a binding. The emitter had two ways to lower it, a flat switch and a closure for a match used inside a bigger expression, and it picked between them by asking whether any arm had a block body instead of whether the match was the whole value being assigned. Three unrelated-looking failures came out of that one condition, and all three passed glyph build and then failed at tsc. A Markdown link checker found them.

  • Fixed An await in a match arm no longer lands inside a synchronous arrow. let cache = match args.offline { true => no_cache(), false => await fetch_all(urls), } emits the await directly in the enclosing async function. It used to emit TS1308.
  • Fixed An accumulator that reads the binding it assigns works. mut in_fence = match fence { true => !in_fence, false => in_fence, } inside a loop went through an untyped closure and TypeScript refused to infer a variable from itself (TS7024).
  • Fixed mut x = match ... with a block-bodied arm compiles. It had no match path at all in the emitter, so it was a hard emit error while the identical let form worked. This was the oldest open item on the dogfooding list.
  • Fixed A break or continue in an assigned match arm labels its loop, so it leaves the loop instead of just the generated switch. And an empty array literal in an arm is pinned to never[], so an unannotated binding does not become an evolving any[] (TS7034).
  • Known A match nested inside a larger expression still compiles to a closure, so its arms have to be single expressions and a return in one would return from the closure. Hoisting it to its own let removes the restriction. An await in that position now makes the closure async and awaited.
  • Known Glyph does not check async context itself. await in a plain fn compiles here and is caught by tsc on the emitted TypeScript, one stage later than it should be.
  • Added examples/apps/linkcheck/main.glyph, the app that found it. It walks a directory of Markdown, pulls out inline links, reference definitions, autolinks, and image sources, skips anything inside a fenced block or a code span, resolves relative links on disk, checks file.md#anchor against the target's headings with GitHub's slug rules, and fetches external URLs once per unique URL through a bounded pool.
0.1.41 August 2026

Every descriptor the compiler emits, the compiler can find

A type carries a runtime descriptor so data crossing a boundary is checked against what the type declares. The descriptors were emitted. The code that decided whether to call one recognized a record and a tagged union defined in the same file, and nothing else. Everything else got a check that the key was present, which validates nothing about the value under it. A scheduling app found it: type Instant = string where is_instant(value) rejected Instant.parse("no") and accepted Block.parse({ start: "no" }).

  • Fixed A where refinement runs its predicate wherever the type appears, not only at a direct Name.parse. A record field typed Name, an Array<Name> element, an Option<Name> payload, a union variant's payload, json.parse<Name>, and is Name narrowing in a match all call the descriptor now.
  • Fixed A field typed by a record or union imported from another module is validated against that type's descriptor. This is every non-generic cross-module composition in every multi-file Glyph program: Outer.parse({ i: 42 }) returned Ok where i was typed by an imported record whose descriptor was emitted, exported, and already imported as a value in the same file. The namespaced form (import types, then a field typed types.Inner) works too; it was previously not handled at all.
  • Changed A boundary that used to return Ok on unvalidated data now returns Err. Nothing in the syntax changed and nothing was relaxed, but a program that leaned on the old presence check will start failing at the boundary, which is where you want to find out.
  • Known A type imported from a plain .d.ts still gets the presence check, because there is no descriptor to call. glyph gen dts materializes those with real descriptors. The registry is deliberately the authority here, so no bogus X.is is emitted for a type that has none.
  • Known The namespaced form is wired for field positions. match v { is types.Inner => … } and json.parse<types.Inner>(s) still take the unresolved path. Same lookup, not done yet.
  • Added examples/apps/schedule/main.glyph, the app that found it. A free-slot finder across several people's calendars: a JSON boundary, ISO-8601 instants, interval merging, and a text grid of the day. It now types its timestamps as type Instant = string where is_instant(value) and deleted the hand-rolled validation pass that existed only because the refinement stopped at the field.
0.1.40 August 2026

glyph run reports what glyph build reports

glyph run app.glyph printed the program's output and exited 0. glyph build on the same directory, seconds later, printed a warning on that same file and an error on a sibling. Same compiler, same sources. The run path built everything, read the list of emitted files out of the report, and dropped the diagnostics on the floor.

  • Fixed glyph run prints every diagnostic the build computed, warnings included, on the file you named and on its siblings, and follows the program's output with glyph run: N error(s), M warning(s) in the source tree. Nothing had decided to suppress them; run_file returned only an outcome, so there was nowhere for them to go. It now returns the outcome plus the diagnostics, and the CLI does the printing.
  • Fixed The warm run cache carries the diagnostics with it. A repeat run of unchanged sources skips the build, so a naive fix would have printed the warning once and then gone quiet, which reads as a warning that went away. A build now writes its diagnostics into the staging directory, so they move into the fingerprint-keyed cache with the rest of the output, and a cache entry whose diagnostics are missing or unreadable counts as a miss and rebuilds instead of reporting a tree it never checked.
  • Known A sibling module that fails to compile still does not change the exit code. It is unavailable to import and the program runs; you now see its error, but glyph run exits with whatever main returned. Whether telling is enough or the exit code should follow is a deliberate open question: changing it fails trees that run fine today.
  • Added examples/apps/adventure/main.glyph, the app that found it. A ten-room text adventure: a keyed world where every exit names a room id, a parser over free-form stdin, world rules that depend on state (the cellar is dark until the lantern is lit), and a save file that has to validate back into a World. Piping a script plays it deterministically.
0.1.39 August 2026

time.parse_iso parses ISO-8601, and nothing else

It was a bare Date.parse underneath, so it took free-form text, read an unpadded date in whatever timezone the process ran in, and reported an impossible day as a success. The reference docs said None if invalid. Now they are true.

  • Fixed time.parse_iso returns None for "January 5 2026", "2026-1-3", "2026-13-01", "2026-02-31" (which used to come back as March 3), and "2026-02-29" in a non-leap year. It accepts a bare YYYY-MM-DD as UTC midnight, or YYYY-MM-DDTHH:MM(:SS)?(.sss)? with an explicit Z, +HH:MM, or -HH:MM. Three stages do it: an anchored shape check before Date.parse sees the string, the existing NaN check, then arithmetic validation of the year/month/day triple with real month lengths and leap years, because calendar rollover is reported as success and no NaN check can see it.
  • Changed A datetime with no offset ("2026-01-03T10:00") is rejected rather than read as local time. That is the deliberate part: the same string would otherwise name a different calendar day depending on which machine ran the program, and time.year/month/day are documented UTC. Anything that stops parsing because of this release contradicted both the function's name and its docs.
  • Added Two documentation corrections, both of which were making people write worse code than the compiler required. The two-binding for i, x in xs form is implemented and appeared in no file a reader would find, so it is now in the spec (D21), the agent bootstrap, and the cookbook. D22 claimed a ${...} interpolation could only hold a literal, an identifier, or a member access; the parser has always accepted the full expression grammar there, calls included.
  • Known A two-binding for gets a string index when you iterate a call's result directly (for i, x in array.slice(xs, 1)), because the emitter falls back to the object form when it can't see an Array type. It compiles and tsc passes. Bind the array to a let with an Array<T> annotation first and the index is a number.
0.1.38 August 2026

The formatter keeps a comment where you wrote it

A // comment written inside a record, a union variant list, an array or object literal, an argument list, or above a match arm used to be re-emitted above the next declaration or statement, where it read as documentation for something else. It now stays with the item it was written above.

  • Fixed glyph fmt flushed pending comments at declaration and statement granularity only, so any comment inside a construct drifted downward. One pass over a nine-line file produced three separate corruptions, including a comment that escaped its const and landed above an unrelated type. Nothing warned: exit 0, tsc passed, and the mangled output was a fixed point, so glyph fmt --check in CI accepted it. Comments are now flushed above the item that followed them in source, with a drain before the closing delimiter.
  • Changed A construct holding an interior comment always takes the one-element-per-line form, at any element count and any width, so the comment has an item to sit above. type Shape = { w: int, h: int } still collapses to one line; document a field and the record stays expanded. Across the whole examples/ tree exactly one file's output changes, to what its author originally wrote.
  • Known A comment is still always emitted on its own line, so one written at the end of a code line (w: int, // width in cells) moves to the line above the next item. It no longer crosses a declaration boundary, but it does move.
0.1.37 August 2026

A misspelled match arm is a compile error, not a silent catch-all

A capitalized match arm head is a reference to a variant, not a fresh binding. A typo that names no variant used to be read as a catch-all that made the match look total; now it stops the build and suggests the nearest real variant.

  • Fixed A PascalCase arm head that names no variant of the union (Loadign for Loading, or a variant from the wrong union) is now E0220 with a nearest-variant suggestion, instead of being read as an irrefutable binding that swallowed the arm. Before, the typo passed exhaustiveness and misrouted values at runtime; the variant you actually skipped is now reported as E0200 right alongside. Covers all three arm shapes: bare Loadign, payload-bearing Loadign(x), and qualified Feed.Loadign. Scope is a union decidable in the same module; a union imported from another file is checked for coverage but not yet for this typo.
0.1.36 August 2026

Generic validators check the same across module boundaries

A generic type's .parse<T> and is checks now thread their per-element runtime check even when the generic type is defined in another file and imported. Which module the type lives in no longer changes what gets validated.

  • Fixed Imported.parse<User>(v) on a generic type imported from another module now validates each element as a User, the same as a module-local Paginated.parse<User>. Before, the checker argument was dropped across the import and the call failed tsc. The build now carries a project-wide map of each generic type's arity and resolves an imported receiver through its import symbol.
  • Fixed The same fix backs the is side: match v { is Imported<User> => ... } narrows across modules instead of erroring. A qualified receiver through a namespace or aliased import (bm.Box.parse<User>(v)), a multi-parameter type (Pair.parse<X, Y>), and a nested type argument (Box.parse<Box<User>>, validated deeply) are all covered.
0.1.35 August 2026

Drop to TypeScript when you need to, and bound your concurrency

The last four of the sixteen gaps the webhook app surfaced. Interop is now first-class in both directions, and "at most N at a time" is a one-liner.

  • Added import extern/<name> reaches hand-written TypeScript under <src>/extern/: for a node-stream loop, a new Promise, anything Glyph can't spell. The build stages it, tsc type-checks it with your Glyph code (a wrong argument is a real error), and a rebuild never deletes it. Relative imports stay illegal in Glyph source; this is the one reserved, greppable path to a local .ts.
  • Added task.pool(limit, tasks) runs task thunks with at most limit in flight and joins the results in order. A fast task starts the next immediately, so "dispatch to N destinations, K at a time" no longer needs a hand-rolled batch loop.
  • Changed The bundled node shim covers the common http-server surface (req.on("error"), server.listen(port, callback), optional writeHead/end args), so a hand-written extern server type-checks with nothing installed; @types/node still supplies the full surface when present.
0.1.34 August 2026

A whole app in Glyph: I/O boundaries and code that reads like Glyph

Building a webhook receiver end to end surfaced sixteen gaps. These closed twelve of them, in two batches: the I/O boundaries a real service hits, then the papercuts that made otherwise-natural code verbose.

  • Added The unparsed request body. http.raw(req) returns the exact bytes a client sent, so you can verify an HMAC signature over the payload and keep a signed-webhook receiver entirely in Glyph. The parsed req.body is unchanged.
  • Added fs.append_text and fs.make_dir, both returning a Result. Append is the right primitive for a log (no read-the-whole-file-then-rewrite), and make_dir is idempotent mkdir -p.
  • Added glyph fmt --check. It writes nothing and exits non-zero when any file is not already formatted, so CI can gate on formatting without a copy-and-diff hack.
  • Added Async closures: async fn(x) { await ... }. A task thunk can now await inside a closure, so bounded fan-out like par.all(array.map(xs, async fn(n) { await work(n) })) type-checks and runs, annotated or not.
  • Changed match handles nested literal payloads (Ok(true) => ..., Ok(false) => ...) and a value-position let x = match whose arm returns from the function, so a Result<bool, E> short-circuit and an early-return-on-error read the way you'd write them. Inline unions like string | number are now nameable in a signature.
  • Changed T.parse returns the documented Result<T, Array<Issue>>, naming the field that failed, instead of a single generic string. The formatter keeps a short call or record on one line when it fits, instead of breaking every three-argument call.
  • Fixed Installing @types/node no longer reddens the build. A shim from an earlier build could merge-conflict with @types/node's crypto types; the build now removes it.
0.1.32 July 2026

Fluent await and discriminated unions from a .d.ts

  • Fixed await on a fluent chain awaits the async terminal: await cursor.find({}).to_array() awaits to_array, so the mongodb cursor pattern no longer needs a split line.
  • Added A TypeScript .d.ts union of variants that share a string-literal tag materializes as a Glyph tagged union plus a parse_<Name> dispatcher that reads the tag and validates into the right variant.
0.1.31 July 2026

Taint tracking

  • Added std/taint: Tainted<T> and Trusted<T> are structurally distinct, so a sink typed Trusted<string> (a SQL runner, a shell command) cannot receive a Tainted<string> without going through sanitize first. A SQL injection path becomes a compile error.
0.1.30 July 2026

Refinement types

  • Added where refinements: type Amount = int where value >= 0, type Rating = int where value >= 1 && value <= 5. The predicate is woven into the descriptor, so Amount.parse(-1) is an Err at the boundary, not just "is a number".
0.1.29 July 2026

Exact large integers

  • Added The bigint prelude type, literals 123n, kept distinct from number by tsc (no mixed arithmetic). A bigint field's descriptor rejects a JSON number rather than truncating an account id past 2^53.
0.1.28 July 2026

Exact money math

  • Added std/decimal, exact base-10 fixed-point over BigInt: 0.1 + 0.2 is exactly 0.3, with no precision loss past 2^53. Construction validates and returns a Result; operations are methods (price.add(tax)). Money is never a float.
0.1.27 July 2026

Real database interop, and two bugs it surfaced

  • Fixed std/sqlite is tsc-clean under @types/node, and a JS-only package's @types/<pkg> companion now resolves, so pg, react, and express type-check instead of reporting an implicit any.
  • Added A databases guide proving Postgres and MongoDB work end to end; every program in it was compiled against the real client types first.
0.1.26 July 2026

new for class-based npm clients

  • Added new Client(args) for interop, type-checked against the real constructor (pg's new Pool, mongodb's new MongoClient, kafkajs's new Kafka). It exists only to construct a type from a package; Glyph gains no class declarations of its own.
0.1.25 July 2026

A persisted database, and a real app on it

  • Added std/sqlite over Node's built-in SQLite (no native install, no flag); rows come back as unknown, so the database is a validated boundary like any other.
  • Added examples/apps/tasks/main.glyph, a persisted, validated task API with data that survives a restart and no hand-written validators anywhere in it.
0.1.24 July 2026

Imported-union resolution and shift operators

  • Fixed A match over a tagged union imported from another module is now held to full exhaustiveness (the imported-union type-resolution pass), retiring the recurring cross-module-Unknown class the dogfood loop kept surfacing.
  • Added Bitwise shift operators << >> >>>, completing the operator family.
0.1.23 July 2026

Imported-union match fixes

  • Fixed An empty-block match arm no longer falls through into the next case, and an imported tagged union's no-payload variant match lowers to a real case instead of a binding catch-all.
0.1.22 July 2026

Interfaces as ordinary types

  • Changed A structural interface is usable as an ordinary parameter or return type, with member access and structural assignability checked, not only as a generic bound.
0.1.21 July 2026

Cross-module payload-binding fix

  • Fixed A Variant(v) pattern that binds the whole payload emitted v.value and failed tsc when the union was imported from another module. The whole object now binds correctly across a module boundary.
0.1.20 July 2026

Bitwise operators

  • Added Bitwise operators & | ^ ~, surfaced writing a reproducible PRNG in pure Glyph. They emit verbatim to TypeScript, number-typed, at JS precedence.
0.1.19 July 2026

Dogfooding the stdlib in Glyph

  • Changed A TypeScript reserved word (class, switch, new, typeof) used as an identifier is now E0109, caught in the resolver instead of emitting broken TypeScript.
  • Added Negative-number literals as match patterns (-1 => ...).
0.1.18 July 2026

Governance, distribution, and editor tooling

  • Added The standard OSS surface: a Code of Conduct, a security policy, governance and maintainers docs, an RFC process, and a deprecation policy; plus distribution and deployment guides.
  • Added glyph bench (ns/op per bench_*), and LSP warning-tier lints, a remove-unused-import quick-fix, and inlay type hints on untyped let.
0.1.17 July 2026

Stdlib breadth, tooling, and docs

  • Added Four stdlib modules: std/regex, std/set, std/path, and std/crypto (sha256/512, HMAC, UUID); std/time gained format_iso/parse_iso and UTC calendar accessors.
  • Added glyph fix (safe autofixes), glyph init --template <cli|web|lib>, diagnostics that link to the error-codes reference, and a large guide expansion.
0.1.16 July 2026

Language-design completeness

  • Added Structural interfaces and generic bounds (fn f<T: Named>(x: T)); module visibility (private by default, pub to export, so the public API is grep '^pub'); digit separators in numeric literals; and defer for deterministic cleanup on every exit path.
  • Added std/task: all (concurrent join, fail-fast), race, and all_settled over the promise model.
0.1.15 July 2026

Materialize the types real SDKs actually ship

  • Added glyph gen dts now materializes the shapes real packages ship: a declare namespace tree, types split across files and re-exported from an index barrel, and generics kept first-class (interface Page<T> becomes type Page<T> with a descriptor that validates each item as its type argument).
  • Added Leaf-value validation. A string enum materializes as a string-literal union ("free" | "pro"), so parse checks membership and a match over it is exhaustive without an else. An integer field becomes int, so a wire 3.5 fails parse where a plain number would pass.
  • Added Generated wire types carry @open, so a forward-compatible API that adds a field does not break parse, while every declared field is still validated. Records stay strict by default in the language.
  • Added Node builtins (fs, path, http, and the rest) type-check out of the box, with @types/node loaded automatically when installed.
  • Changed gen dts flags a reference it can't resolve or a type name that collides across files with a note, instead of emitting a wrong-typed validator silently.
0.1.14 July 2026

Use your npm dependencies without an adapter

  • Added An installed package that ships its own types (or has an @types/...) now type-checks and runs with no hand-written .types/ stub. The build points tsc at your project's node_modules. Real zod works inline: z.object, z.string, .parse, all checked against zod's own types.
  • Added glyph gen dts <package> and glyph gen zod <package> resolve an installed package by name and materialize its types into committed Glyph types with runtime validators, so a value crossing the boundary gets a real T.parse instead of a presence check.
  • Added JSX prop spread: <input {...register("email")} />, the react-hook-form idiom, now lowers to an object spread in the props with no adapter.
  • Added extern_ts("..."), a scoped escape hatch for the TypeScript idioms Glyph's grammar does not spell (a value-derived z.infer<typeof s>, a raw runtime call). It emits raw TypeScript that tsc still checks, in both type and expression position, and every use is greppable.
0.1.13 July 2026

Close the boundary

  • Changed The type check is no longer silently optional. glyph run, build, and publish now stop with a non-zero exit when tsc is missing instead of quietly running unchecked. Pass --no-check if you really want to skip it.
  • Added An unknown annotation is now a hard error (E0221). A typo like @puer no longer compiles as if it meant something; the recognized set is @example, @doc, @redact, @open, @pure, @public.
  • Changed The manifesto states the productivity claim as a hypothesis to measure, not a number we have earned. No figure until the study runs.
  • Added A CI gate that keeps every package version in sync and flags when the published npm release falls behind the repo.
0.1.12 July 2026

Docs: the README finds its agents

  • Changed The npm README now documents the glyph mcp Model Context Protocol server and the glyph lsp language server: the agent and editor surface that shipped in 0.1.11. No code changes.
0.1.11 July 2026

Wired into your agent

  • Added glyph mcp: a Model Context Protocol server that exposes Glyph's own analysis to a coding agent as tools: diagnostics, the type at a cursor, where a name is defined, every reference to a symbol across the project, and symbol search. The same analysis the editor uses, so it can't drift from the compiler.
  • Added Workspace-wide find-references and rename in the language server. A module-level rename edits the declaration, every reference, and each importing module's import binding across the whole project, complete and safe, with the new name validated first.
  • Changed infer_shape is now infer_output and generalized: it derives a validator combinator's output type by matching any parser-shaped field structurally, so your own Codec<T> works, not only a type literally named Schema.
0.1.10 July 2026

Types that keep their word

  • Added infer_shape<Shape>: a validator combinator's output type is now derived from the shape you pass it, and the compiler checks your annotation against it: a schema and its type can no longer drift apart (drop a field from the shape and it won't compile).
  • Added Generic types validate at runtime. Paginated.parse<User>(body) checks every element as a User, not just for presence, and match v { is Paginated<User> => ... } narrows the same way.
  • Added The typechecker catches more shape mismatches itself (a scalar where a function or record is expected, a function with the wrong return type) instead of leaving them to tsc. Function-typed fields are now validated by typeof, not presence.
  • Fixed glyph fmt no longer drops a generic bound (<T: Bound>), and the one invisible compiler-inserted cast is narrowed so every honest generic function emits cast-free.
  • Changed The site now states exactly what the compiler guarantees and where the guarantee stops. Every claim is backed by working code.
0.1.9 July 2026

The trust release

  • Added A signed supply chain: npm packages published with provenance, GitHub Release archives carry SLSA build attestations, and every archive has a published SHA-256, all verifiable (npm audit signatures, gh attestation verify, sha256sum -c).
  • Added A 10-minute Start Here tutorial, a "why not just tooled TypeScript?" answer, a debugging/ops answer, and an "is it worth it without AI?" answer. The answers section is now a guided 14-page set with a real index.
  • Added Record descriptors are strict by default now: they reject undeclared keys (an @open type opts out).
  • Added glyph doctor checks your node/tsx/tsc toolchain; glyph init pins typescript/tsx; llms.txt now inlines the full diagnostic catalogue and teaches T.parse.
  • Fixed Running a library with no main gives a friendly E0310 instead of a raw Node stack trace, and concurrent glyph runs no longer race on the temp directory.
  • Added A CONTRIBUTING.md with issue and PR templates, a stated pre-1.0 stability policy, and a site link-check in CI.
0.1.8 July 2026

Install fix

  • Fixed The published compiler binary is now executable on install: a packaging step dropped the Unix execute bit, so npx @glyphlang/glyph failed with EACCES. The launcher now also restores it defensively.
0.1.7 July 2026

Works with React, speaks to agents

  • Added JSX fragments (<>...</>) and member-expression tags (<Ctx.Provider>), so React Context and multi-child returns work.
  • Added Machine-readable diagnostics (glyph build --json) and standard v3 source maps (.ts.map) on every emitted file.
  • Added Bounded generics (<T: Bound>), a shared-state std/store, and glyph regen to refresh generated code from its spec.
  • Added A warning tier (unused import, unused binding, unreachable code) plus exhaustiveness for number/string matches (E0218) and real @redact PII masking (E0219).
  • Added Discriminated-union generation from an OpenAPI discriminator, and gen dts on the TypeScript 7 native compiler.
  • Fixed Template interpolations get distinct spans (go-to-definition works inside "${x}"), and a payload-carrying first union variant (Wrap({...}) | Empty) parses.
0.1.6 July 2026

Sharper errors and diagnostics

  • Added TypeScript back-end errors are mapped back onto your .glyph source (same caret and stable code) instead of pointing at the generated .ts.
  • Added A warning tier: diagnostics can be warnings that are surfaced without failing the build. The first is E0217, which warns when a Result is discarded and its error silently ignored.
  • Fixed A literal \${ in a string stays literal instead of silently interpolating, and non-ASCII text in a template string ("café ${x}") is no longer mangled.
  • Fixed Binding a whole record payload in a nested match (Err(BadQty(b))) no longer emits code tsc rejects.
0.1.5 July 2026

Typed clients and servers from your API spec

  • Added glyph gen openapi --client generates a typed std/http client: one async fn per operation, with typed path parameters and request bodies.
  • Added glyph gen openapi --handlers generates server handler stubs plus a route dispatcher that matches the method and path (array patterns capture /tasks/{id} for you).
  • Added glyph gen zod materializes a module of zod schemas into committed, descriptor-bearing Glyph types.
  • Added Untrusted request input is typed as Option: header and query_param return Option<string>, so a missing value can't slip past a match. The std/http client gains put/patch/del.
  • Fixed glyph gen dts resolves TypeScript from the target file's own project first, so a pinned typescript@6 works even when the global install is 7.x.
  • Site A new Client & server answer walking through the generated client, router, and zod materialization.
0.1.4 July 2026

TypeScript 7 compatibility for glyph gen dts

  • Fixed glyph gen dts now detects the TypeScript 7 native compiler (the new default from npm install typescript), whose API it cannot use, and prints a clear message pointing you at typescript@6 instead of crashing.
0.1.3 July 2026

Generated types, not hand-written DTOs

  • Added glyph gen openapi <spec> --out <dir> turns an OpenAPI 3, Swagger 2, or JSON Schema document into committed Glyph types: one real record per schema, each with a runtime descriptor, so bodies validate for free.
  • Added glyph gen dts <file.d.ts> --out <dir> materializes a TypeScript declaration file into first-class Glyph types you own and can validate, instead of an ambient, unvalidated phantom.
  • Added A guide, Typed APIs, and a runnable REST API example: declare a type, get a validated request boundary with no separate zod schema.
  • Note Generation is wire-faithful: constructs Glyph cannot represent exactly (a string enum, an undiscriminated oneOf) narrow with a printed note rather than emit a validator that would reject real payloads.
0.1.2 July 2026

Correctness, JSX, and the guided site

  • Fixed A match over a Result whose error union has a no-payload variant no longer silently dispatches to the wrong arm.
  • Fixed A misspelled constructor in a match arm is rejected, not silently treated as a catch-all that masks a missing case.
  • Fixed An incomplete bool match (missing false) and a JSX <match> missing a case are now compile errors, not runtime throws.
  • Fixed A multi-parameter component is a clear compile error instead of silently mis-binding props; record payloads bind correctly.
  • Added The typechecker checks call arity (E0213); unreachable match arms are flagged; the ? error-type rule extends across the stdlib boundary.
  • Added Hyphenated and scoped npm imports (react-hook-form, @hookform/resolvers/zod), aria-*/data-* JSX attributes, and significant JSX whitespace.
  • Added Targeted diagnostics: if/else point at match (E0006), range patterns say so directly (E0007); json.parse deep-validates Record fields.
  • Added std/http gains a server: serve, Handler, Request, Response, query, and path, a thin, errors-as-values wrapper over node:http.
  • Added glyph init scaffolds a runnable starter project, and glyph llms prints the agent bootstrap offline.
  • Added A one-document agent reference (AGENTS.md / llms.txt), a standard-library reference, and an editor-setup guide.
  • Added Quoted string keys in object literals, and clearer hints that suggest the Glyph spelling for common TypeScript type names.
  • Site A guided Answers section, a Hardening page, a dedicated Benchmarks page, and the landing page + playground on glyphlang.io.
0.1.0 June 2026

First public preview

  • Added The language and its Rust compiler (lex → parse → resolve → typecheck → emit), driven by glyph build, glyph run, and glyph fmt.
  • Added The core standard library, exhaustive match, Result and the ? operator, runtime type descriptors, and JSX / component.

Install or upgrade with npm install -g @glyphlang/glyph. The full commit history lives on GitHub.

← Back to home