# iter str-concat — heap-Str concatenation primitive in four-site lockstep

**Date:** 2026-05-13
**Started from:** 679572a92d594d94b915d09fd60e7ef7f7bb83bd
**Status:** DONE
**Tasks completed:** 7 of 7

## Summary

`str_concat : (borrow Str, borrow Str) -> Str` shipped as a heap-Str
primitive in the four-site-lockstep pattern established by `str_clone`
(iter 24.1). Closes fieldtest-form-a friction finding #4. The
LLM-natural Show-body shape
`(app str_concat "label=" (app int_to_str x))` now parses, checks,
builds, and runs end-to-end via the new
`examples/show_user_adt_with_label.ail` fixture (`Item 42\n`
through `print . MkItem 42`).

Net delta: +3 tests (559 baseline → 562 green): the E2E pin
(`str_concat_e2e_show_user_adt_with_label`), the builtin-signature
unit test (`install_str_concat_signature` asserting the arity-2
Borrow-Borrow / Own-return shape directly on `env.globals`), and the
codegen IR-pin (`str_concat_emits_call_to_ailang_str_concat`
asserting both the extern declaration AND the call instruction in
emitted IR). Five `crates/ail/tests/snapshots/*.ll` files were
regenerated to absorb the new unconditional
`declare ptr @ailang_str_concat(ptr, ptr)` line in the IR header
preamble — same upkeep pattern as hs.4 (which regenerated the same
5 snapshots for the unconditional `@ailang_int_to_str` /
`@ailang_float_to_str` declares).

Task 5 repaired the bug-fixture / pin-test collision: the
`bugfix-instance-body-unbound-var` iter (commit 77f584a) used
`str_concat` as the literal unbound name because that was the
LLM-natural shape the fieldtester reached for; renamed to
`format_label` (LLM-author-realistic helper name) to preserve the
pin's intent (instance-method-body walked through unbound-var check)
while substituting a name that will never become a builtin.

## Per-task notes

- iter str-concat.1: RED fixture `examples/show_user_adt_with_label.ail`
  (single-ctor `Item Int` ADT + `Show Item` body producing labelled
  output) + E2E pin `crates/ail/tests/str_concat_e2e.rs` (check +
  build + run asserting `Item 42\n` stdout). Confirmed RED with
  `[unbound-var] prelude.Show: unknown identifier: 'str_concat'`.
- iter str-concat.2: `runtime/str.c` C helper `ailang_str_concat`
  appended after `ailang_str_clone`; reads `len` from both source
  payloads, `str_alloc(la+lb)`, memcpys both bytes, NUL-terminates.
  Clean `clang -O2 -c` compile.
- iter str-concat.3: `crates/ailang-check/src/builtins.rs` —
  `env.globals.insert("str_concat", ...)` install entry after
  str_clone block (arity-2, both Borrow, Own return); list() row
  `("str_concat", "(Str, Str) -> Str")` after str_clone row;
  `install_str_concat_signature` test reaches into `env.globals`
  directly to match the arity-2 / param_modes / ret_mode discipline
  (deeper than the sibling `synth_in_builtins_env`-based tests
  because str_concat is the first builtin with arity-2 Borrow params).
- iter str-concat.4: `crates/ailang-codegen/src/lib.rs` four-site lockstep:
  extern declaration after `@ailang_str_clone`; lower_app arm after
  str_clone arm (arity-2 check + two `lower_term` + `fresh_ssa` +
  body push); `is_builtin_callable` list extended with `"str_concat"`;
  IR-pin test mirrors the sibling `str_clone_emits_call_to_ailang_str_clone`
  pattern (Module/FnDef/Term::Do scaffolding via `super::*` +
  `SCHEMA`, not the plan's `ailang_surface::parse` structural sketch —
  plan explicitly authorised this in Step 4 note). IR-pin test
  asserts both the extern declare AND the call site. RED→GREEN flip
  on the E2E pin happens at this task. Five IR snapshots regenerated
  via `UPDATE_SNAPSHOTS=1` to absorb the new declare line (sole
  diff verified at line 17 across all snapshots).
- iter str-concat.5: bug-fixture / pin-test collision repaired —
  `examples/bug_unbound_in_instance_method.ail` line 14 rename
  `str_concat` → `format_label`; pin test
  `crates/ail/tests/unbound_in_instance_method_pin.rs` `replace_all`
  rename (one test name changed to
  `check_fires_unbound_var_for_format_label_in_instance_method_body`).
  Both pin tests pass; new fixture `ail check` reports
  `ok (23 symbols across 2 modules)` — matches the plan's empirical
  guess exactly.
- iter str-concat.6: `docs/DESIGN.md` new §"Heap-Str primitives"
  subsection between the milestone-24 paragraph and the
  `Primitive output goes through ...` paragraph; lists all five
  shipping primitives with their type, iter origin, and prelude
  role, plus the user-visible vs prelude-internal distinction.
- iter str-concat.7: `docs/roadmap.md` struck `[x] [feature] str_concat`
  entry at end of P2 cluster (right before `## P3 — Ideas`). All
  four cited fixtures (`show_user_adt_with_label`, `show_user_adt`,
  `test_22c_user_class_e2e`, `cmp_max_smoke`) `ail check` clean.
  `ail builtins | grep str_concat` returns `str_concat        (Str, Str) -> Str`.

## Concerns

- Task 3 (Step 5) test-count prediction was off: plan expected
  `passed: 560 failed: 1` after registering the builtin but actual
  was `passed: 558 failed: 3`. Reason: the two
  `unbound_in_instance_method_pin` tests turn RED as soon as
  `str_concat` is registered in the checker, NOT only at codegen.
  The pins assert on `ail check`'s `[unbound-var]` diagnostic, which
  depends only on the checker registry. Implementation is correct;
  Task 5 fully repairs. No follow-up needed beyond noting the
  prediction-vs-actual gap.
- Task 4 (Step 7) test-count prediction was off: plan expected
  `passed: 562 failed: 1` after codegen lands; actual after
  IR-snapshot regen was `passed: 560 failed: 2`. The difference is
  fully accounted for by the 2 still-RED pin tests (repaired in
  T5) and by the IR-snapshot regen being out-of-band of the
  per-task counters in the plan. Final state at T5 Step 5 is
  exactly `passed: 562 failed: 0`, matching the plan's iter target.
- IR snapshot regeneration was not explicitly scripted in the plan
  for Task 4, but it follows the hs.4 precedent (which regenerated
  the same 5 snapshots for the same reason: a new unconditional
  declare line in the IR header). The sole diff in each snapshot
  is the new `declare ptr @ailang_str_concat(ptr, ptr)` line on
  IR line 17, immediately before the existing
  `declare i64 @llvm.fptosi.sat.i64.f64(double)` declaration.

## Known debt

- (none)

## Files touched

Code:
- `runtime/str.c` (M) — append `ailang_str_concat` helper
- `crates/ailang-check/src/builtins.rs` (M) — install entry, list()
  row, signature unit test
- `crates/ailang-codegen/src/lib.rs` (M) — extern declare,
  lower_app arm, is_builtin_callable list, IR-pin unit test
- `crates/ail/tests/str_concat_e2e.rs` (new) — E2E pin
- `crates/ail/tests/unbound_in_instance_method_pin.rs` (M) — rename
  `str_concat` → `format_label` (4 sites + 1 test name)
- `examples/show_user_adt_with_label.ail` (new) — Show body fixture
- `examples/bug_unbound_in_instance_method.ail` (M) — rename
  `str_concat` → `format_label` (1 site)

IR snapshots regenerated (5; sole diff = new declare line):
- `crates/ail/tests/snapshots/hello.ll`
- `crates/ail/tests/snapshots/list.ll`
- `crates/ail/tests/snapshots/max3.ll`
- `crates/ail/tests/snapshots/sum.ll`
- `crates/ail/tests/snapshots/ws_main.ll`

Docs:
- `docs/DESIGN.md` (M) — new §"Heap-Str primitives" subsection
- `docs/roadmap.md` (M) — struck `[x] [feature] str_concat` line

## Stats

bench/orchestrator-stats/2026-05-13-iter-str-concat.json
