Versions & changelog
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.
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.
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.
Result has an addressResult 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.
"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: 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.
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.
"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.
std/ is an error instead of dead codeA 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.
// 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.
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.
ResultA 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.
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.
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.
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.
// 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.
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.
"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: 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.
{
"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."
}
}
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.
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.
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.
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.
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.
{
"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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
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.
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.
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.
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.
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.
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.
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.
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.
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:
(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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
glyph check --json ran no @example gate at all. The same project, the same flags, two answers:
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.
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:
glyph check --json a::f glyph_diagnostics src/a::fafter
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
No new language features. The query layer moves to salsa 0.28, and four things that kept turning up during release checks are fixed.
// 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.
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.
@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.
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.
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.
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.
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.
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.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.std/random has its signatures in the agent reference, and Rng.bool's probability is optional, which is what its own comment had promised.Adding an index to a for used to cost you a compiler check, and the compiler suggested the thing that threw the check away.
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.
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.@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.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.
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.
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.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.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.
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.
tsc --strict, and threw at run time. It is E0200 now, naming the inner union and the variant you left out.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.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.
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.
module tree
pub type Tree<K> =
| Leaf
| Node({ left: Tree<K>, key: K, right: Tree<K> })
pub fn leaf<K>() -> Tree<K> {
return Leaf
}
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",
}
}
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.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.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.glyph doctor names both commands when a newer release exists, rather than sending a global install to the one that rewrites a project.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.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.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).
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, } }
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/.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.E0305, and Ok(Point) over a record fails through tsc naming a .tag you never wrote.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.
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,
}
}
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.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.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.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.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.matchWhen 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.
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)
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.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.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.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.
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",
}
}
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.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),
}
}
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.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.
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,
}
}
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.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.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.{ 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.[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",
}
}
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.[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.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.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 ==.
module status
fn classify() -> int {
let done = false
match done {
true => { return 1 },
false => { return 0 },
}
}
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.== 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.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.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.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.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.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.
// 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;
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.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.Full(Black) still miscompiles. The same shape where the payload is a union rather than a record is G130, scheduled for 0.1.90.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.
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 => {},
}
}
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.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.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.
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",
}
}
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.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.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.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.
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"
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.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.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.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.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.
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}") })
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.@types/node at latest.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.@types/node and your build still worksThe 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.
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'
@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.@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.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.
// before -rw-r--r-- glyph // after -rwxr-xr-x glyph
MIT OR Apache-2.0 and contained neither file.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.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.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.
// 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";
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./// <reference> to the ambient prelude declarations, so Issue and Schema exist in whatever compilation includes your generated files, not just the compiler's own.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.
Breaking: serve is gone from std/net and std/http, replaced by listen. If you call it, this is the change to read.
// 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), }, }
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.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.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.
Four modules for the calls a program used to make raw, and the first application in the tree built on a real npm package.
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
}
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.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.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.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.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.
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.
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"),
}
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.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._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.
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.
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
}
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 await — mut 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.
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.
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`?).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.
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.
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.
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.
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.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.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.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.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.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.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.
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.
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.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.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.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.
The same misspelling used to get two different answers depending on which way you had written the import.
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.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.
One shape built with 0 error(s) and threw at run time, and it had been there since the standard library was first modeled.
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.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.
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.
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.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.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.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.
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.
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.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.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.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.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.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..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.
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.
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.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.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.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.
({}), 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.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.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>.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".
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.== 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.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.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.sqlite.Row does not yet get E0224. That needs stdlib named types modelled as more than a field set.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.
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.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.net, timers, events, child_process, dns/promises, zlib..d.ts or an extern_ts, so the answer to a missing capability is to extend the stdlib.declare var in .types/ is invisible to the resolver. And an Option field still cannot be read from ordinary JSON.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.
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.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..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.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.
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..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.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.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.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.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.
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.\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.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.look while you type, and minilang --repl evaluates a line and prints the result before reading the next.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.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.
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.packages/foo already means to anyone who has used a workspace. Reaching another project is what npm is for, and the error says so.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.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.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.
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.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.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.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.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.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.
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.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.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.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.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.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.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.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.
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.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.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.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.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.
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.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.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.
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.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.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.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.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.
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.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.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.Array<T>. Whether that case should be an error instead is an open call.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.
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.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.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.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.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.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.
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.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.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."..." 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.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.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.ResultGlyph'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.
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.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].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..-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.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.
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.tsc errors. It now prints after every stage that can turn the build red.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.--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.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.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.glyph fmt stops being the thing that breaks your buildThree 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.
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.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.@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.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.|| 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.tsc caught and Glyph did notFour 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.
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.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.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.? 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.? 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.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.{} 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.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.
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.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.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 "".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.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.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.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.
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.std/array gains fold, index_of, and flat_map. fold takes the collection, then the seed, then a callback that gets (acc, x).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.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.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.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.std/http can serve a web page, not just JSONA 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.
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.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.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.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.url_encode. On the client side there is still no request timeout, no redirect policy, no head, and no final URL after a redirect.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.match keeps its typeGlyph 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.
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.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.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.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.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.
.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.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.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.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.@examples run on every buildGlyph 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.
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.--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.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.--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.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.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.match you assign is a switch, not a closureGlyph 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.
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.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).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.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).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.await in a plain fn compiles here and is caught by tsc on the emitted TypeScript, one stage later than it should be.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.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" }).
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.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..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.match v { is types.Inner => … } and json.parse<types.Inner>(s) still take the unresolved path. Same lookup, not done yet.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.glyph run reports what glyph build reportsglyph 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.
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.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.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.time.parse_iso parses ISO-8601, and nothing elseIt 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.
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."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.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.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.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.
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.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.w: int, // width in cells) moves to the line above the next item. It no longer crosses a declaration boundary, but it does move.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.
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.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.
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.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.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.
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.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.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.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.
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.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.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.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.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.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.@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.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..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.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.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".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.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.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.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.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.examples/apps/tasks/main.glyph, a persisted, validated task API with data that survives a restart and no hand-written validators anywhere in it.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.<< >> >>>, completing the operator family.case instead of a binding catch-all.interface is usable as an ordinary parameter or return type, with member access and structural assignability checked, not only as a generic bound.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.& | ^ ~, surfaced writing a reproducible PRNG in pure Glyph. They emit verbatim to TypeScript, number-typed, at JS precedence.class, switch, new, typeof) used as an identifier is now E0109, caught in the resolver instead of emitting broken TypeScript.-1 => ...).glyph bench (ns/op per bench_*), and LSP warning-tier lints, a remove-unused-import quick-fix, and inlay type hints on untyped let.std/regex, std/set, std/path, and std/crypto (sha256/512, HMAC, UUID); std/time gained format_iso/parse_iso and UTC calendar accessors.glyph fix (safe autofixes), glyph init --template <cli|web|lib>, diagnostics that link to the error-codes reference, and a large guide expansion.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.std/task: all (concurrent join, fail-fast), race, and all_settled over the promise model.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)."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.@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.fs, path, http, and the rest) type-check out of the box, with @types/node loaded automatically when installed.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.@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.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.<input {...register("email")} />, the react-hook-form idiom, now lowers to an object spread in the props with no adapter.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.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.E0221). A typo like @puer no longer compiles as if it meant something; the recognized set is @example, @doc, @redact, @open, @pure, @public.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.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.import binding across the whole project, complete and safe, with the new name validated first.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.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).Paginated.parse<User>(body) checks every element as a User, not just for presence, and match v { is Paginated<User> => ... } narrows the same way.tsc. Function-typed fields are now validated by typeof, not presence.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.npm audit signatures, gh attestation verify, sha256sum -c).@open type opts out).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.main gives a friendly E0310 instead of a raw Node stack trace, and concurrent glyph runs no longer race on the temp directory.CONTRIBUTING.md with issue and PR templates, a stated pre-1.0 stability policy, and a site link-check in CI.npx @glyphlang/glyph failed with EACCES. The launcher now also restores it defensively.<>...</>) and member-expression tags (<Ctx.Provider>), so React Context and multi-child returns work.glyph build --json) and standard v3 source maps (.ts.map) on every emitted file.<T: Bound>), a shared-state std/store, and glyph regen to refresh generated code from its spec.number/string matches (E0218) and real @redact PII masking (E0219).discriminator, and gen dts on the TypeScript 7 native compiler."${x}"), and a payload-carrying first union variant (Wrap({...}) | Empty) parses..glyph source (same caret and stable code) instead of pointing at the generated .ts.E0217, which warns when a Result is discarded and its error silently ignored.\${ in a string stays literal instead of silently interpolating, and non-ASCII text in a template string ("café ${x}") is no longer mangled.match (Err(BadQty(b))) no longer emits code tsc rejects.glyph gen openapi --client generates a typed std/http client: one async fn per operation, with typed path parameters and request bodies.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).glyph gen zod materializes a module of zod schemas into committed, descriptor-bearing Glyph types.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.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.glyph gen dtsglyph 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.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.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.type, get a validated request boundary with no separate zod schema.oneOf) narrow with a printed note rather than emit a validator that would reject real payloads.match over a Result whose error union has a no-payload variant no longer silently dispatches to the wrong arm.bool match (missing false) and a JSX <match> missing a case are now compile errors, not runtime throws.E0213); unreachable match arms are flagged; the ? error-type rule extends across the stdlib boundary.react-hook-form, @hookform/resolvers/zod), aria-*/data-* JSX attributes, and significant JSX whitespace.if/else point at match (E0006), range patterns say so directly (E0007); json.parse deep-validates Record fields.std/http gains a server: serve, Handler, Request, Response, query, and path, a thin, errors-as-values wrapper over node:http.glyph init scaffolds a runnable starter project, and glyph llms prints the agent bootstrap offline.AGENTS.md / llms.txt), a standard-library reference, and an editor-setup guide.glyph build, glyph run, and glyph fmt.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.