Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e7885dc
Refactor HIR item tree members to submodules
2kai2kai2 Jul 13, 2026
e1282e1
Span-less HIR `TypeRef`
2kai2kai2 Jul 13, 2026
21c34d8
HIR type alias query firewall
2kai2kai2 Jul 13, 2026
cb59f58
HIR class + function query firewalls
2kai2kai2 Jul 13, 2026
31d31d0
HIR remaining query firewalls
2kai2kai2 Jul 13, 2026
d0fef03
HIR item tree builder
2kai2kai2 Jul 13, 2026
698e32a
Make PPIR item tree canonical
2kai2kai2 Jul 14, 2026
576e9bf
Use firewall queries in TIR
2kai2kai2 Jul 14, 2026
7b177a4
Unified class generic params lookup
2kai2kai2 Jul 14, 2026
854e4f0
TIR lower type refs
2kai2kai2 Jul 14, 2026
2b366fb
Span-free elaborated function data
2kai2kai2 Jul 14, 2026
c9c2fb3
Use elaborated function param data
2kai2kai2 Jul 14, 2026
3c78bbd
Use type refs for more tir stuff
2kai2kai2 Jul 14, 2026
03a9124
Move span for fn+class+iface+let to source map
2kai2kai2 Jul 15, 2026
6a1ecd6
Delete old `Self`-substitution logic
2kai2kai2 Jul 15, 2026
c4c221e
Add `function_llm_meta` query
2kai2kai2 Jul 15, 2026
72ed7f7
Tests to validate firewall queries
2kai2kai2 Jul 15, 2026
bc22c08
Cleanup & bugfixes
2kai2kai2 Jul 16, 2026
9bd43d6
Merge remote-tracking branch 'origin/canary' into kai/hir-salsa
2kai2kai2 Jul 16, 2026
e03d422
Item enumeration deterministic tie breaker
2kai2kai2 Jul 16, 2026
bf68e0d
Merge remote-tracking branch 'origin/gh-readonly-queue/canary/pr-4065…
2kai2kai2 Jul 16, 2026
b01ee9c
Merge remote-tracking branch 'origin/gh-readonly-queue/canary/pr-4063…
2kai2kai2 Jul 17, 2026
a81dec3
Bump size gate
2kai2kai2 Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/cargo-tests.reusable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ jobs:
runs-on: blacksmith-8vcpu-windows-2025
env:
CARGO_BUILD_JOBS: "16"
# Windows debug builds emit very large PDBs; full debuginfo across the
# whole `--all-features` test build overruns the runner's ~130 GB disk
# ("no space on device" during codegen, before any test runs).
# `line-tables-only` keeps file:line backtraces for test failures while
# dropping the bulk of the debug-info size (variable/type DIEs). Pinned on
# both `dev` (dependencies) and `test` (the `--test` binaries, which
# inherit `dev` but are pinned explicitly so the reduction is unambiguous).
# Drop to "0" if this is still too tight.
CARGO_PROFILE_DEV_DEBUG: "line-tables-only"
CARGO_PROFILE_TEST_DEBUG: "line-tables-only"
# TODO: Reduce this once our changes to aws-sdk-rust are upstreamed
# Right now it needs to download our repo every time which is very slow
timeout-minutes: 50
Expand Down
24 changes: 12 additions & 12 deletions baml_language/.cargo/size-gate.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,29 +42,29 @@ policy.max_delta_pct = 3.0
# Baselines re-recorded after merging canary (~+4%). Reclaiming the `syn`
# cost would mean dropping prettyplease (raw, unformatted generated SDK
# output).
# macOS arm64: baseline 18.30 MB file (18_300_864), ceiling = baseline x1.03.
# macOS arm64: baseline 18.90 MB file (18_900_160), ceiling = baseline x1.03.
[artifacts.baml-cli.platform.aarch64-apple-darwin]
max_file_bytes = 18_849_890
# Linux x86_64: baseline 23.75 MB file (23_753_040), ceiling = baseline x1.03.
max_file_bytes = 19_467_165
# Linux x86_64: baseline 24.51 MB file (24_507_664), ceiling = baseline x1.03.
[artifacts.baml-cli.platform.x86_64-unknown-linux-gnu]
max_file_bytes = 24_465_631
# Windows x86_64: baseline 19.80 MB file (19_798_528), ceiling = baseline x1.03.
max_file_bytes = 25_242_894
# Windows x86_64: baseline 20.43 MB file (20_426_752), ceiling = baseline x1.03.
[artifacts.baml-cli.platform.x86_64-pc-windows-msvc]
max_file_bytes = 20_392_484
max_file_bytes = 21_039_555

[artifacts.packed-program]
kind = "pack"
policy.max_delta_pct = 3.0

# macOS arm64: baseline 12.64 MB file.
# macOS arm64: baseline 13.21 MB file.
[artifacts.packed-program.platform.aarch64-apple-darwin]
max_file_bytes = 13_019_384
# Linux x86_64: baseline 16.26 MB file.
max_file_bytes = 13_602_990
# Linux x86_64: baseline 17.03 MB file.
[artifacts.packed-program.platform.x86_64-unknown-linux-gnu]
max_file_bytes = 16_771_886
# Windows x86_64: baseline 13.53 MB file.
max_file_bytes = 17_542_251
# Windows x86_64: baseline 14.14 MB file.
[artifacts.packed-program.platform.x86_64-pc-windows-msvc]
max_file_bytes = 13_944_063
max_file_bytes = 14_567_300

[artifacts.packed-program.pack]
cli_package = "baml_cli"
Expand Down
14 changes: 7 additions & 7 deletions baml_language/.ci/size-gate/aarch64-apple-darwin.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
version = 1
recorded_at = "2026-07-16T10:00:08Z"
git_sha = "79aa0f44e323e356a172e16e6b0f4a42e569959d"
recorded_at = "2026-07-17T00:35:21Z"
git_sha = "b01ee9c671a00b44a7a9a3832e149e3c2ec93c13"

[artifacts.baml-cli]
file_bytes = 18300864
stripped_bytes = 18300912
gzip_bytes = 8798867
file_bytes = 18900160
stripped_bytes = 18900208
gzip_bytes = 8955790

[artifacts.packed-program]
file_bytes = 12640178
gzip_bytes = 5993393
file_bytes = 13206786
gzip_bytes = 6154161
14 changes: 7 additions & 7 deletions baml_language/.ci/size-gate/x86_64-pc-windows-msvc.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
version = 1
recorded_at = "2026-07-16T10:00:08Z"
git_sha = "79aa0f44e323e356a172e16e6b0f4a42e569959d"
recorded_at = "2026-07-17T00:35:21Z"
git_sha = "b01ee9c671a00b44a7a9a3832e149e3c2ec93c13"

[artifacts.baml-cli]
file_bytes = 19798528
stripped_bytes = 19798528
gzip_bytes = 8992285
file_bytes = 20426752
stripped_bytes = 20426752
gzip_bytes = 9169338

[artifacts.packed-program]
file_bytes = 13537925
gzip_bytes = 6082881
file_bytes = 14143010
gzip_bytes = 6248500
14 changes: 7 additions & 7 deletions baml_language/.ci/size-gate/x86_64-unknown-linux-gnu.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
version = 1
recorded_at = "2026-07-16T10:00:08Z"
git_sha = "79aa0f44e323e356a172e16e6b0f4a42e569959d"
recorded_at = "2026-07-17T00:35:21Z"
git_sha = "b01ee9c671a00b44a7a9a3832e149e3c2ec93c13"

[artifacts.baml-cli]
file_bytes = 23753040
stripped_bytes = 23753032
gzip_bytes = 10113500
file_bytes = 24507664
stripped_bytes = 24507656
gzip_bytes = 10325653

[artifacts.packed-program]
file_bytes = 16283384
gzip_bytes = 6846279
file_bytes = 17031312
gzip_bytes = 7039119
43 changes: 30 additions & 13 deletions baml_language/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ The parser produces a **CST** (Concrete Syntax Tree), which is a lossless, error
- **Shadowing rules** — The HIR decides where shadowing is allowed (e.g., a match arm variable may shadow a function parameter).
- **Lambda capture analysis** — Which variables a lambda captures is determined here. You don't need to know the type to know *what* is captured, only what names are in scope.
- **Package and namespace aggregation** — Cross-file symbol merging happens here.
- **Item tree + type-reference arena** — A span-free `ItemTree` (items keyed by position-independent `LocalItemId`) with a parallel `ItemTreeSourceMap`, plus a flat span-free `TypeRef` arena that replaces inline `ast::TypeExpr`. `ItemTreeBuilder` constructs both together and records the item↔scope and method→owner indices. This is the substrate the PPIR firewall queries front (see [Salsa Early Cutoff](#salsa-early-cutoff-how-edits-stay-local)).

**What does NOT live here:**
- Node transformations. The HIR should NOT construct new AST nodes. If you find yourself doing that, the work belongs in the AST layer.
Expand Down Expand Up @@ -199,11 +200,13 @@ The parser produces a **CST** (Concrete Syntax Tree), which is a lossless, error
- Synthesis of `*$stream` variants for classes and type aliases
- Stream expansion logic (`stream_expand`, `expand_partial`)
- SAP (streaming attribute propagation) attributes
- Canonical queries that merge original items with synthetic stream items
- **The canonical item layer** — `ppir::file_item_tree` merges original items with the synthetic stream items, and the per-item **firewall queries** (`item_data`: enumeration + `*_data` + `*_source_map`) that front the item tree live here.

**How it works:** The PPIR generates synthetic AST items (the stream variants) and feeds them **back into the HIR**. This means the flow is actually: HIR → PPIR → back to HIR (with expanded symbols) → TIR. The TIR then consumes the enriched HIR index that includes both original and stream-expanded items.
**How it works:** The PPIR generates synthetic AST items (the stream variants) and re-runs the HIR builder over the *merged* list — originals first, then synthetics appended. The flow is HIR → PPIR → (re-run the HIR builder on merged items) → TIR. PPIR's tree is **canonical**, but it is an internal substrate: downstream layers read it **only through the firewall queries** in `item_data` (enumeration + `*_data` + `*_source_map`) — never `file_item_tree` directly, and never the HIR pre-expansion tree. A `ClassLoc`/`FunctionLoc` therefore unambiguously means a canonical item. (Originals keep identical `LocalItemId`s in the pre-expansion and canonical trees because they are allocated first, in the same order — which is what makes a HIR-derived `*Loc` safe to pass to a firewall query. Migrating the remaining direct `file_item_tree` readers onto the firewall queries is in progress.)

**Key Salsa query:** `ppir_expansion_items(db, file)` — Synthetic stream items per file
**Key Salsa queries:**
- `ppir_expansion_items(db, file)` — Synthetic stream items per file
- Firewall queries (`item_data`) — the consumer API over the canonical item tree: enumeration (`file_classes` / `file_functions` / …), lookup (`class_data` / `function_data` / …), and spans (`class_source_map` / …). `file_item_tree` itself is the internal substrate these are built on, not for direct downstream use.

---

Expand Down Expand Up @@ -569,9 +572,18 @@ baml_test! {

## Rules for Adding Spans to Data Structures

**Do not add `TextRange` or span fields to your data structures.** There is a dedicated mechanism for associating spans with nodes. If you add `TextRange` directly to a data structure, you break Salsa incrementality for everything downstream — a change to whitespace (which changes spans but not semantics) will unnecessarily invalidate all dependent queries.
**The load-bearing invariant: anything a memoized, `PartialEq`-compared query *returns* must be span-free.** Salsa only overwrites a memoized value when the new one is *not* `Eq` to the old, so a span inside a query result is a trap either way:

- If the span is inside `PartialEq`, a whitespace edit makes the value compare unequal and **destroys early cutoff** for everything downstream.
- If the span is *ignored* by `PartialEq` (as `ast::TypeExpr` does with its own `span`), the value compares equal, Salsa keeps the old memo, and the query **serves a stale span forever**.

Concretely:

Use expression IDs and the span lookup infrastructure instead. If you're unsure how to associate span information with a new construct, ask before implementing.
- **Do not add `TextRange`/span fields to data that flows out of a tracked query.** Type references use the span-free `TypeRef` arena (ids into a per-item store), not `ast::TypeExpr`. Attributes use the span-free `item_tree::Attribute`, not `ast::RawAttribute` (whose `PartialEq` *includes* its span — a subtle transitive leak through any `TypeExpr`).
- **Spans live in a parallel `*_source_map` query**, keyed by the same id (`LocalItemId` / `TypeRefId`), never inline with the data.
- The one place spans may remain inline is the coarse `no_eq` index (`file_semantic_index` and its `ItemTree`): because that query is `no_eq` it is always recomputed, so its spans are always fresh — never stale. Everything *fronting* it (the firewall `*_data` queries) must be span-free.

If you're unsure how to associate span information with a new construct, ask before implementing.

---

Expand Down Expand Up @@ -713,22 +725,27 @@ The Salsa query model has one critical optimization beyond basic memoization: **

### How it works in practice

Every item is physically split into **two** tracked queries: one for semantic data (span-free), one for source maps (spans only). For example, `function_signature` returns names and `TypeExpr`s with no `TextRange`, while `function_signature_source_map` returns only spans. The type checker reads the semantic query but never the source map query.
The item tree is produced by a single coarse query, `file_semantic_index`, marked `no_eq` — it always reports "changed", so its spans are always fresh but it provides no cutoff itself. In front of it sit fine-grained **firewall queries** (in `baml_compiler2_ppir::item_data`), one family per item kind:

- *Enumeration* — `file_classes(file)` / `file_functions(file)` / … return a `Vec` of interned `*Loc` handles (a `ClassLoc` carries its own file plus a position-independent `LocalItemId`).
- *Lookup* — `class_data(ClassLoc)` / `function_data(FunctionLoc)` / … return **span-free** semantic data. Type references inside them are ids into a per-item `TypeRef` arena — a flat, span-free replacement for `ast::TypeExpr` — never `TextRange`s.
- *Spans* — `class_source_map(ClassLoc)` / `function_source_map(FunctionLoc)` / … are separate queries holding the spans, keyed by the same `*Loc`. The type checker reads the data query; only diagnostics read the source-map query.

Because the `*_data` queries are tracked and `PartialEq`-compared, a whitespace or comment edit that shifts spans but not semantics leaves them **byte-identical** → `PartialEq` returns `true` → anything depending on a `*_data` query cuts off. **This is why the data must be span-free** (see [Rules for Adding Spans](#rules-for-adding-spans-to-data-structures)): a span inside a `PartialEq`-compared result would either destroy the cutoff or, if `PartialEq` ignored it, serve a *stale* span forever.

Items are keyed by **position-independent IDs** — a hash of the item's name, not its position in the file. Adding a blank line before `function Greet(...)` doesn't change the hash of `"Greet"`, so the Salsa query key stays the same and cached results survive.
Items are keyed by **position-independent IDs** — `LocalItemId` is a hash of the item's name plus a collision index, not its position in the file. Adding a blank line before `function Greet(...)` doesn't change the hash of `"Greet"`, so the `FunctionLoc` key stays the same and cached results survive.

### Concrete trace: adding a comment to a file

User adds `// comment` to `file_a.baml`. File B is untouched.

1. `file_a.text` is marked changed.
2. `file_semantic_index(file_a)` re-runs (it's marked `no_eq`, so always reports "changed").
3. `namespace_items(user_root)` re-runs — re-collects contributions from all files. But the result is identical: same names, same definition handles. Its `PartialEq` returns `true`. **Early cutoff fires.**
4. `package_items` — NOT re-run (its dependency didn't change).
5. `infer_scope_types` for any scope — NOT re-run.
6. `file_semantic_index(file_b)` — NOT re-run (its input `file_b.text` is unchanged).
2. `file_semantic_index(file_a)` re-runs (`no_eq` — always "changed"); its spans are refreshed.
3. Each per-item firewall query for `file_a` (`class_data`, `function_data`, …) re-runs but **early-cuts**: the semantic data is identical, so its `PartialEq` returns `true`. Its `*_source_map` twin re-runs and *does* change (new spans) — correctly, since positions moved.
4. `namespace_items(user_root)` re-runs and early-cuts when the name set is unchanged. (Exception: a file already holding a *duplicate-name* conflict currently carries a `name_span` in the conflict record, so a cosmetic edit there loses cutoff — a known gap being closed.)
5. `file_semantic_index(file_b)` — NOT re-run (its input `file_b.text` is unchanged), so nothing about file B recomputes.

Result: a comment addition re-runs the lexer and HIR for that one file, then stops. A whitespace edit shifts spans but leaves `TypeExpr` trees identical → `function_signature` early-cuts → type inference stays cached.
**Status / caveat.** The firewall queries above exist and cut off, but many downstream consumers are **not yet migrated onto them** — notably `infer_scope_types`, which still reads the coarse `no_eq` `file_semantic_index` directly and therefore re-runs on *any* edit to its file today. Realizing end-to-end cutoff (a comment edit not re-running type inference) requires migrating those consumers to the firewall queries; that work is in progress. Treat this section as the mechanism, not a fully-realized guarantee — the incremental tests in `baml_tests` pin what actually holds today, and one of them (`comment_edit_does_not_reexecute_type_inference`) is deliberately `#[ignore]`d precisely because inference is not yet migrated.

---

Expand Down
22 changes: 18 additions & 4 deletions baml_language/crates/baml_cli/src/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,21 @@ fn render_test_list(
);
// Indented list under the cargo-style status line. These are content (the
// actual list), not status updates, so they go to stdout as plain prints.
for t in legacy_selected {
//
// Canonicalize the order at this single render point — both the fresh-compile
// and bytecode-cache paths flow through here — so `--list` output is
// byte-identical between them regardless of upstream map/enumeration order.
// Sort by (function, test) so each function's tests group together (a legacy
// `test` is function-attached), with the path as a final deterministic tiebreak.
let mut legacy_sorted: Vec<&crate::bytecode_cache::CachedLegacyTest> =
legacy_selected.iter().collect();
legacy_sorted.sort_by(|a, b| {
a.function_name
.cmp(&b.function_name)
.then_with(|| a.test_name.cmp(&b.test_name))
.then_with(|| a.file_path.cmp(&b.file_path))
});
for t in legacy_sorted {
println!(" {}::{} ({})", t.function_name, t.test_name, t.file_path);
}
for name in testset_names {
Expand Down Expand Up @@ -499,20 +513,20 @@ fn discover_legacy_tests(
db: &ProjectDatabase,
project: baml_workspace::Project,
) -> Vec<LegacyTest> {
use baml_db::baml_compiler2_hir;
use baml_db::baml_compiler2_ppir::item_data::{file_tests, test_data};

let mut tests = Vec::new();
let root = project.root(db);

for source_file in db.get_source_files() {
let item_tree = baml_compiler2_hir::file_item_tree(db, source_file);
// Root-relative for display, matching how emit records source paths —
// keeps `--list` output identical between compiled and
// bytecode-cache-served runs.
let file_path = source_file.path(db);
let file_path = file_path.strip_prefix(&root).unwrap_or(&file_path);

for test in item_tree.tests.values() {
for test_loc in file_tests(db, source_file) {
let test = test_data(db, *test_loc);
for func_ref in &test.function_refs {
tests.push(LegacyTest {
function_name: func_ref.to_string(),
Expand Down
Loading
Loading