Repository navigation
feat(reflect): list and look up a package's globals - #5361
Conversation
`reflect.Package` gains: - `globals()`: the package's global variables and `client` declarations, keyed by package-local name, with their current values. - `get_global<T>(name)` / `find_global<T>(name)`: one global's value, checked against `T` as `reflect.Class.get<T>` checks a field (`NotFound` / `TypeMismatch`). A lookup reads a global as `LoadGlobal` does: during `$init`, one whose initializer has not run is `GlobalReadBeforeInit`, and `globals()` leaves it out. The pending check is one helper, `BexVm::global_is_pending`, which `LoadGlobal` now uses too. Tests: corpus `ns_global_variables/ns_reflection` (listing, client discovery by value, get/find, missing name, type mismatch, shared value, a compiled package) and two engine tests for reads during `$init`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
⏭️ Performance benchmarks were skippedPerf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to To run them on this PR, do any of the following, then push a commit (or re-run CI):
|
Binary size checks failed❌ 2 violations · ✅ 1 passed
Details & how to fixViolations:
Add/update baselines:
[artifacts.bridge_wasm]
file_bytes = 26478743
gzip_bytes = 7756766
[artifacts.packed-program]
file_bytes = 40452232
gzip_bytes = 16465098Generated by |
## Summary
BAML now has **global variables**: a file-scope `const` declares one.
```baml
/// How many times to retry.
const MAX_RETRIES = 3;
const NAMES: string[] = ["ada", "grace"];
const GREETING = `hello, ${NAMES[0]}`; // reads other globals
const DOUBLED = double(MAX_RETRIES); // calls functions; forward references are fine
function retries_left(used: int) -> int {
MAX_RETRIES - used // visible from every body in the namespace
}
```
A global is evaluated **once, at startup**, by the package's `$init`, in
dependency order. It is reachable bare from its own namespace and, like
any item, as `root.ns.NAME` or `dep.ns.NAME` from anywhere else,
including from a `Session` mounting the package (`app.NAME`).
A top-level `let` is an **error** (E0010, pointing at the keyword):
`let` declares a reassignable binding, which only a body has, while a
global cannot be reassigned. The binding still lowers as the global it
names, so its readers resolve and the error is reported once, with no
cascade. Inside a body, `const` is unchanged: an alias for `let` with
its existing warning.
The `$init` pipeline already existed for `client X = …`, but every layer
assumed only clients and sessions produced top-level bindings, and
several of those assumptions break once users write their own.
## Semantics
| | |
|---|---|
| **Shape** | One name, optionally annotated: `const NAME = value;` /
`const NAME: Type = value;`. Destructuring, `_`, alias chains and `const
… else` are refused at file scope. A top-level `let` is an error. |
| **Type** | The annotation, which the initializer is checked against;
otherwise the initializer's type, widened as at any binding site (`const
N = 5` is `int`). One definition, `globals::global_value_type`, serves
both in-package reads and the package interface. |
| **Scope / shadowing** | Value namespace of the file's namespace (E0011
on duplicates). Locals, parameters and lambda parameters shadow globals.
A type's statics and enum variants win over a global root, so
`float.nan` stays the static even beside a global named `float`. A
global named like a package is an item of its namespace, so it shadows
that package there, as any item does (BoundaryML#4543). `root` is reserved
(E0176), as for every name. |
| **Mutability** | The binding is immutable (`X = v`, `X += v` →
**E0179**). What it holds is an ordinary heap value shared by every call
(`X.field = v`, `X.push(…)`), like a JS `const`. Concurrent mutation is
memory-safe through the existing container locks and atomic field slots.
|
| **Initialization order** | Go's rule. A global depends on another when
its initializer refers to it lexically (lambda bodies included), or
refers to a same-package function, method or parameter default whose
body does, transitively. References come from inference's resolution
tables, not from spellings. Independent globals run in source order
(file path, then position), independent of file load order. Cycles are
**E0181**, reported once with the chain (`A → helper → B → A`). |
| **Initializer rules** | No io (E0163, existing), no `spawn`/`await`
(**E0182**), no top-level `return` (**E0180**). A throw nothing catches
is a **warning** (E0183), because it would stop the program from
starting. |
## Pre-existing bugs that globals would have exposed
Each of these was reproduced on `canary` before being fixed:
- **Assigning to a top-level binding crashed the compiler.** `C = …` on
a client passed the checker, then emit failed with an internal error
("TIR should have rejected this program"). `C.field = v` silently stored
into a `null` temp. Both are fixed: E0179 for the binding, and MIR now
loads the global before storing a field.
- **The `$init` order matched names.** `topological_sort_lets` treated
any single-segment path ending in `.name` as an edge. Shadowing locals
created false cycles (reported as an internal compiler error), and
dependencies through function calls were missed, so an initializer could
read another global's `null` placeholder. It is replaced by the semantic
analysis in `baml_compiler2_hir_ty::globals`.
- **Qualified references to a top-level binding didn't resolve**
(`other.MyClient.model` gave E0003). TIR now records the binding in
`InferenceResult::global_roots`, and MIR lowers the path as the bare
global.
- **`$init` aborted on an early yield.** Allocation pressure (32 MiB),
or wasm's periodic poll, made engine creation fail, so any moderately
heavy initializer broke the playground. `$init` now resumes; there is
nothing to park for, since no collector exists yet. A test confirms the
failure without the fix.
- **`$init` failures were opaque**: `uncaught throw:
Object(HeapPtr(0x…))`, or a full VM-state `Debug` dump for `spawn`. They
now name the global, render the thrown value, and include the BAML
traceback.
- **Method calls on a global** typed as bound-method *values*, so
generic methods with turbofish failed (`PKG.get_function<F>(…)`). They
now type like local receivers.
## Reflection and sessions
- Package interfaces export globals with their types
(`PackageInterface::values`, `ExternLetLoc`). A served or mounted
package's global is read as an import of the slot its own `$init`
filled.
- Tested in the corpus: a `Session` reads `app.global_variables.X`;
`Package.compile` of source with globals initializes them in order; a
throwing initializer in a compiled package reaches the caller as a
catchable error; `Package.current()` and `reflect.Type` values held in
globals work.
- Reflection over globals (`reflect.Package.globals()`, `get_global<T>`,
`find_global<T>`, clients included) is in the stacked PR BoundaryML#5361, to keep
this PR's public API surface unchanged.
## Runtime safety net
The static order cannot see a global read through a virtual interface
call or a function value. During `$init` only, the VM tracks which
global slots are still pending (`BexVm::pending_globals`), and a
`LoadGlobal` of one fails with "global `user.X` was read before its
initializer ran", naming the global being initialized. Post-`$init` VMs
pay one `None` check.
## Tooling
- **IDE:** hover shows `const NAME: Type` with `///` docs (locals still
show `let`). Goto-definition and find-references follow `root.ns.X` and
`dep.X` and skip shadowing locals (references are now confirmed through
`symbol_at`, which also fixes a local `let` named like an item being
counted as a reference to it). Hover, goto, references and member
completion work *inside* initializers, because the IDE paths are keyed
on body owners rather than functions. Unannotated globals get inlay type
hints, globals are `readonly` semantic tokens, and `baml describe` finds
globals and clients and renders a global's shape as `const NAME: Type`.
Top-level completion offers a `const ${1:name} = $0;` snippet in place
of `let`.
- **Formatter:** top-level bindings are formatted (they were printed
verbatim). Adjacent globals stay grouped; author blank lines are kept,
collapsed to one. The keyword is kept as written: the formatter does not
rewrite a top-level `let` to `const`.
- **Docs:** `ARCHITECTURE.md`, keyword docs (`let` covers locals;
`const` declares a global at file scope and behaves like `let` in a
body), the `baml-core` agent skill, the `bex_heap` README safety
invariant, and a website lesson whose example made an LLM call in a
global initializer (now E0163).
## Testing
- **Runtime corpus**, `baml_src/ns_global_variables`: 22 tests covering
annotations, dependency order (including through helpers and parameter
defaults), shadowing (including a global named like a package), shared
mutation, qualified access across namespaces (including a qualified
global inside a tagged template), sessions, compiled packages,
reflection values, spawn and generic methods. The full corpus passes:
5679 passed, 2 tolerated failures that are pre-existing.
- **Compile-error projects:** `global_variable_declarations`,
`global_variable_initializers` and `global_variable_assignment` replace
`top_level_let`. `global_variable_declarations` includes a rejected
top-level `let` that a function still reads, showing the single error
without a cascade.
- **Engine-level tests** (`tests/global_variables.rs`): startup failure
messages, the uninitialized-read check, allocation pressure during
`$init` (verified to fail without the fix), source-order init
independent of file load order, dependency-package globals, and a
namespaced global's failure named in full.
- **IDE** (13 tests, plus the top-level completion test, which now
checks the `const` snippet and the absence of `let`) and **formatter**
(6 tests, including one that a top-level `let` keeps its keyword).
- **Suites run locally** on the branch merged with `canary`
(`5e9826d29`):
- `baml_tests`: 1142/1142, with `--unreferenced=reject` clean.
- Runtime corpus through `baml_cli` (`baml_corpus`): 2/2. Run directly
with `baml-cli test`, that is 5679 passed and 2 tolerated failures. The
2 are intentional `test_runners` fixtures.
- `baml_compiler2_ast` + `baml_fmt`: 250/250.
- `baml_ide`: 330/330.
- `baml_compiler2_hir_ty` + `baml_lsp` + `baml_lsp_server`: 323/323.
- `baml_compiler2_hir` + `baml_compiler2_mir` + `baml_compiler2_emit`:
83/83.
- `baml_db`: 63/63. `baml_builtins2`: 6/6 (parses the keyword docs).
- `bex_engine` + `bex_vm` + `bex_heap` + `bex_vm_types`: 934/934.
- `baml_cli` `run_command` tests: 41/41. The rest of `baml_cli` wasn't
re-run. `feedback_auth_e2e` needs Apple Keychain access, which the
sandbox doesn't have.
- Workspace clippy, run as in CI, is clean except `sdk_test_codegen`,
which has a pre-existing `println!` lint unrelated to this change.
`cargo fmt --check` is clean.
## Review notes
- **Diagnostics changed in existing projects:** `init_io_reachable` is
unchanged (io takes priority over suspension when one call reaches
both). `top_level_let` is removed; its case, a rejected top-level `let`,
is now in `global_variable_declarations`.
- **The keyword is read from the CST.** `lower_top_level_let` finds the
statement's first `let`/`const` token. The parser nests it inside the
pattern node for most shapes, so the body-level `const` warning is told
to skip that one token. Nested introducers, such as an alias chain's,
still get the warning. `Session.eval` bindings and `client` declarations
are unchanged.
- **Merged with `canary`** (merge commit `c5c611edc`), adapting the
branch to canary's changes:
- `FunctionIdentity::InitLet` carries the global's `ItemPath` instead of
an ordinal. It renders as `$init_let.<pkg>.<ns>.<name>`, so `$init`
failures still name the global. This changes the shape of a serialized
function identity.
- Global paths are read through canary's `PathHead`, the same way
functions are. A child namespace's global is `root.ns.X` from its
parent; the relative `ns.X` form no longer resolves.
- The test-registry shadowing fix is canary's own (`" __registry"`), so
this PR no longer renames that parameter.
- A top-level `let type` keeps its own error, renamed `TopLevelLetType`,
with a message about `let type` rather than top-level `let`.
- Canary made `void` an alias for `null` and stopped reserving `baml`,
so `global_variable_declarations` now tests a global named `root` and
has no `void` case.
- **Review of the merge** (commit `234cbf889`): an independent review of
the merge found no dropped intent but some cleanup, all addressed here:
- A qualified global inside a tagged template (`root.ns.name`) was
lowered as the template's same-named body parameter; MIR now skips name
lookups for paths the checker resolved to a global. The new corpus test
failed before the fix.
- `ResolvedValue::ExternalLet`: both `resolve_value` implementations
answer a served package's exported global, so `global_root` is a prefix
walk over `resolve_value` rather than a second lookup.
- One record of how many path segments name a global
(`GlobalRoot.segments`), read through
`InferenceResult::path_segment_at_written`; `ResolvedPath.root_len` is
gone.
- The `$init_let` naming scheme is defined once in `bex_vm_types`
(`init_let_helper_name` / `init_let_helper_global`), used by the
function identity, emit's operand metadata and the engine.
- MIR's name-based global fallback was never reached across the corpus
and every affected suite (checked with a temporary panic), so it is
removed.
- A telemetry file the merge committed by accident is removed, and docs
and comments follow canary's naming rules.
- **Unrelated test fix** (commit `b15eed4a2`): `bex_engine`'s telemetry
`failure_tests` passed an empty folder to its fake recording writer, so
telemetry's blob store wrote into `crates/bex_engine/cas/`, inside the
source tree, on every run since shutdown GC pauses started being
recorded (BoundaryML#5167). That is how a blob got into this branch's merge. The
tests now use a temp folder; what they check is unchanged.
- **Kind labels are unchanged.** `DefinitionKind::Let` still prints as
`let` in E0011 ("defined 2 times as: let, function") and in `baml
describe` listings and JSON `kind` fields. Only the surface syntax
changes here.
- **E0183 is a warning on purpose.** Stdlib client constructors validate
their arguments (`ai.RoundRobin` throws `InvalidRequest`, Vertex throws
`ParseError`), so an error would have rejected valid clients. Clients
and session bindings are exempt.
- **Unannotated globals in a type cycle** no longer get the
order-dependent "type inference failed; write the type explicitly"
error. Every such cycle is also an initialization cycle, which is
reported deterministically as E0181. Clients and session bindings keep
the old behavior.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **New Features**
* Added file-scope `const` globals with optional type annotations,
namespace support, shared mutable contents, and cross-package access.
* Globals initialize once at startup in dependency order, with support
for forward references and clear cycle or read-before-initialization
errors.
* Added editor support for global hover information, navigation,
references, completions, readonly highlighting, and inferred type hints.
* **Bug Fixes**
* Improved diagnostics for invalid declarations, reassignment,
unsupported initializer effects, uncaught errors, and returns.
* **Documentation**
* Updated language, skills, and website documentation with
global-variable behavior and restrictions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
…ndaryML#5364) ## Summary Follow-up to BoundaryML#5325, addressing CodeRabbit's review of it (an "outside diff range" note on `typescript2/app-website/lessons/everything.md`). The lesson's Variables section now teaches `const` for a global at file scope and `let` for a local inside a function. The "Annotating Variable Types" example right after it still showed two bare top-level `let` lines, which contradicts that and, copied as written, gives the new "global variables are declared with `const`" error. It now shows the annotation on a `const` global and on a `let` local inside a function: ```baml // A global variable with a type annotation const name: string = "Bammy"; function Example() { // A local variable with a type annotation let r: int | string = someComplicatedFunction(); } ``` Only this example changes. The lesson's other bare `let` snippets are fragments illustrating arrays, maps and loops rather than declarations, so they're left as they are. ## Notes - Docs only; nothing compiles the lesson's code blocks. - BoundaryML#5325 and BoundaryML#5361 have merged; this PR is based on `canary`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <[email protected]>
Summary
Stacked on #5325 (global variables). Adds reflection over a package's globals to
reflect.Package:globals()map<string, unknown>: the package's global variables andclientdeclarations, keyed by package-local name (root.ns.NAME, asfunctions()andclasses()key theirs), with their current values.get_global<T>(name)T. Throwsreflect.errors.NotFoundfor a missing name,reflect.errors.TypeMismatchwhen the value is not aT(checked asreflect.Class.get<T>checks a field). The name may be written with or withoutroot..find_global<T>(name)get_global, butnullfor a missing name.Scope is the package the method is called on (and, for a
with_typesview, the packages it re-exports, asfunctions()does), never the whole program. Each value is the global itself, shared with every reader, as reading the global directly is.Semantics worth reviewing
client X = …declaration is the same top-level binding as aconstglobal, soglobals()lists both, and finding a package's clients isglobals()plus anis ai.Clientcheck.$init. A lookup reads a global asLoadGlobaldoes. While$initruns, a global whose initializer has not run yet is the existingGlobalReadBeforeIniterror fromget_global/find_global, andglobals()leaves it out instead of listing the placeholder. The check is one helper,BexVm::global_is_pending, whichLoadGlobalnow uses too. A lookup by name is no edge the static init order can follow, so this runtime check is what catches it.globals()returns values rather than declared types: a package's runtime tables carry no per-global type, and values are what discovery needs.Testing
baml_src/ns_global_variables/ns_reflection, 8 tests): listing globals and clients, client discovery by value,get_global/find_global(with and withoutroot., nullableT, interfaceT), a missing name, a type mismatch, a value read through reflection being the global itself, and aPackage.compilepackage's own globals.tests/global_variables.rs, 2 tests):get_globalof a global whose initializer has not run is reported by name;globals()during$initleaves it out.baml_tests1144/1144 with--unreferenced=rejectclean;bex_vm+bex_vm_types+bex_heap431/431;bex_engine503/503; IDE + LSP 486/486;baml_builtins2+baml_db69/69; type checker + MIR + emit 240/240;baml_clilib 421/421 and corpus runner 2/2. Workspace clippy as CI runs it is clean except the pre-existingsdk_test_codegenlint;cargo fmt --checkis clean.baml describe reflect.Package's snapshot only shifts three line numbers (the stdlib file grew).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation