Files
AILang/docs/plans/0062-iter-str-concat.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

27 KiB
Raw Permalink Blame History

str-concat — Implementation Plan

Parent spec: docs/specs/0026-fieldtest-form-a.md §"[friction] No primitive str_concat / str_append in builtins" (lines 186-212).

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Add str_concat : (borrow Str, borrow Str) -> Str as a runtime + checker + codegen primitive in the four-site-lockstep pattern established by str_clone / int_to_str / bool_to_str (iter 24.1), so the LLM-natural Show-MyType body (app str_concat "label=" (app int_to_str x)) parses, checks, builds, and runs.

Architecture: Four-site lockstep registration mirroring str_clone (iter 24.1). The new primitive ships at: (1) runtime/str.c as a C helper ailang_str_concat(const char *a, const char *b) -> char * that slab-allocates a fresh heap-Str of size a.len + b.len, memcpys both payloads, terminates; (2) crates/ailang-check/src/builtins.rs::install as a Type::Fn { params: 2× Str borrow, ret: Str own } entry plus a list() row plus a install_str_concat_signature unit test; (3) crates/ailang-codegen/src/lib.rs as an extern @ailang_str_concat(ptr, ptr) declaration plus an arm in lower_app emitting the call plus an entry in is_builtin_callable plus an IR-pin unit test; (4) examples/show_user_adt_with_label.ail corpus fixture authored fresh (the LLM-natural shape the fieldtester first reached for) plus a crates/ail/tests/str_concat_e2e.rs E2E test asserting check + build + run produce the expected stdout. The plan also repairs one lockstep collision: examples/bug_unbound_in_instance_method.ail currently uses str_concat as its UNBOUND name (because that was the literal name the fieldtester tried); after str_concat ships as a builtin the fixture and its pin test crates/ail/tests/unbound_in_instance_method_pin.rs would silently turn into a false-OK. Task 5 renames the unbound name to format_label (LLM-author-realistic fn name) and updates the pin test accordingly, preserving the regression guard's intent (instance-method-body walked through unbound-var check) while substituting a name that will never become a builtin.

Tech Stack: Rust (crates/ailang-check, crates/ailang-codegen, crates/ail/tests), C (runtime/str.c), Markdown (docs/DESIGN.md, docs/roadmap.md), .ail Form-A (examples/).


Files this plan creates or modifies:

  • Create: examples/show_user_adt_with_label.ail — Show user-ADT with str_concat-using body (Task 1)
  • Create: crates/ail/tests/str_concat_e2e.rs — E2E pin asserting check + build + run on the new fixture (Task 1)
  • Modify: runtime/str.c:186+ — append ailang_str_concat C helper (Task 2)
  • Modify: crates/ailang-check/src/builtins.rs:232 — insert str_concat registration after str_clone (Task 3)
  • Modify: crates/ailang-check/src/builtins.rs:330 — insert list() row after str_clone (Task 3)
  • Modify: crates/ailang-check/src/builtins.rs:505-514 — insert signature unit test after install_str_clone_signature (Task 3)
  • Modify: crates/ailang-codegen/src/lib.rs:548 — append @ailang_str_concat extern declaration after str_clone declare (Task 4)
  • Modify: crates/ailang-codegen/src/lib.rs:1977 — insert str_concat arm in lower_app after str_clone arm (Task 4)
  • Modify: crates/ailang-codegen/src/lib.rs:2192 — extend is_builtin_callable name-list (Task 4)
  • Modify: crates/ailang-codegen/src/lib.rs:4255-4292 — append IR-pin test after str_clone IR-pin test (Task 4)
  • Modify: examples/bug_unbound_in_instance_method.ail:14 — rename str_concat to format_label (Task 5)
  • Modify: crates/ail/tests/unbound_in_instance_method_pin.rs — rename str_concat references to format_label (Task 5)
  • Modify: docs/DESIGN.md:1992 — insert new §"Heap-Str primitives" subsection (Task 6)
  • Modify: docs/roadmap.md — add struck-out [x] [feature] str_concat primitive line (Task 7)

Task 1: RED — fixture + E2E test for str_concat

Files:

  • Create: examples/show_user_adt_with_label.ail

  • Create: crates/ail/tests/str_concat_e2e.rs

  • Step 1: Author the fresh .ail fixture exercising str_concat in a Show body

Create examples/show_user_adt_with_label.ail with content:

(module show_user_adt_with_label
  (data Item
    (ctor MkItem (con Int)))
  (instance
    (class prelude.Show)
    (type (con Item))
    (method show
      (body (lam (params (typed it (con Item))) (ret (con Str)) (body (match it
        (case (pat-ctor MkItem n) (app str_concat "Item " (app int_to_str n)))))))))
  (fn main
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body (app print (term-ctor Item MkItem 42)))))

This is the LLM-natural shape: a single-ctor ADT with an integer payload, a Show instance whose body produces a labelled string ("Item 42") by concatenating a static-Str prefix with the int_to_str rendering. The output via print (polymorphic from iter 24.3) goes through the prelude's print : forall a. Show a => (a borrow) -> Unit !IO.

  • Step 2: Author the E2E test file

Create crates/ail/tests/str_concat_e2e.rs with content:

//! E2E pin for the `str_concat` heap-Str primitive shipped in iter
//! `str-concat`. Asserts that `ail check` + `ail build` + run on
//! `examples/show_user_adt_with_label.ail` produce stdout
//! `Item 42\n` — i.e. that the Show body's
//! `(app str_concat "Item " (app int_to_str n))` evaluates correctly
//! and `print` emits the concatenated heap-Str.
//!
//! Without `str_concat` registered as a builtin (pre-iter state), the
//! fixture fails `ail check` with `[unbound-var]: str_concat`. After
//! the iter ships, both check and build succeed and the binary
//! produces the expected stdout.

use std::path::Path;
use std::process::Command;

fn ail_bin() -> &'static str {
    env!("CARGO_BIN_EXE_ail")
}

#[test]
fn str_concat_e2e_show_user_adt_with_label() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace
        .join("examples")
        .join("show_user_adt_with_label.ail");
    assert!(src.exists(), "fixture missing: {}", src.display());

    let check = Command::new(ail_bin())
        .args(["check", src.to_str().unwrap()])
        .output()
        .expect("ail check failed to spawn");
    assert_eq!(
        check.status.code(),
        Some(0),
        "ail check must succeed; got stdout={} stderr={}",
        String::from_utf8_lossy(&check.stdout),
        String::from_utf8_lossy(&check.stderr)
    );

    let tmp = tempfile::tempdir().expect("tempdir");
    let bin_path = tmp.path().join("a.out");
    let build = Command::new(ail_bin())
        .args([
            "build",
            src.to_str().unwrap(),
            "-o",
            bin_path.to_str().unwrap(),
        ])
        .output()
        .expect("ail build failed to spawn");
    assert_eq!(
        build.status.code(),
        Some(0),
        "ail build must succeed; got stdout={} stderr={}",
        String::from_utf8_lossy(&build.stdout),
        String::from_utf8_lossy(&build.stderr)
    );

    let run = Command::new(&bin_path)
        .output()
        .expect("produced binary failed to spawn");
    assert_eq!(run.status.code(), Some(0), "binary must exit 0");
    let stdout = String::from_utf8_lossy(&run.stdout);
    assert_eq!(
        stdout, "Item 42\n",
        "expected `Item 42\\n` from str_concat + int_to_str + print; got {stdout:?}"
    );
}
  • Step 3: Run the RED test to confirm it fails today

Run: cargo test --manifest-path=Cargo.toml -p ail --test str_concat_e2e 2>&1 | tail -15 Expected: FAIL with ail check must succeed panic, because str_concat is unbound. Specifically the diagnostic surface should mention [unbound-var] and str_concat. The test must fail because of this RED, not because the fixture parse-fails or the binary spawn fails — verify by reading the panic output.

  • Step 4: Full workspace test as a no-regression smoke

Run: cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}' Expected: passed: 559 failed: 1 (or 560 passed 1 failed depending on whether the new pin counts; the only failing test must be str_concat_e2e_show_user_adt_with_label).

Task 2: Runtime C helper

Files:

  • Modify: runtime/str.c — append after ailang_str_clone (currently ends at line 186)

  • Step 1: Append the C helper implementation

After the closing brace of ailang_str_clone at line 186 of runtime/str.c, append:


/*
 * `ailang_str_concat(a, b)` — heap-Str concatenation primitive
 * shipped in iter str-concat (2026-05-13). Reads `len` headers from
 * both source Str payloads (offset 0 of each), allocates a fresh
 * heap-Str slab sized for the combined bytes via `str_alloc`,
 * memcpys both payloads in order, NUL-terminates, returns the new
 * payload pointer. Like `str_clone`, this works uniformly on static-
 * Str and heap-Str inputs because the consumer ABI is identical
 * between realisations (see DESIGN.md §"Heap-Str primitives").
 *
 * Used by Show bodies that want labelled output (e.g.
 * `"Item " ++ int_to_str n`) and by any caller that needs to
 * combine two existing Str values into a new owned Str.
 *
 * Returns the payload pointer of a fresh heap-Str slab. The rc
 * header is initialised to 1 by `str_alloc`.
 */
char *ailang_str_concat(const char *a, const char *b) {
    uint64_t la = *(const uint64_t *)a;
    uint64_t lb = *(const uint64_t *)b;
    char *payload = str_alloc(la + lb);
    memcpy(payload + 8, a + 8, la);
    memcpy(payload + 8 + la, b + 8, lb);
    payload[8 + la + lb] = '\0';
    return payload;
}
  • Step 2: Verify the C file compiles

Run: clang -O2 -c runtime/str.c -o /tmp/str.o 2>&1 | head -20 Expected: no output (clean compile).

  • Step 3: Full workspace test as a no-regression smoke

Run: cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}' Expected: passed: 559 failed: 1 (str_concat_e2e still RED — check still rejects, can't reach build).

Task 3: ailang-check builtin registration

Files:

  • Modify: crates/ailang-check/src/builtins.rs:232 — insert install entry after the str_clone block (currently ends at line 232)

  • Modify: crates/ailang-check/src/builtins.rs:330 — append list() row after ("str_clone", ...)

  • Modify: crates/ailang-check/src/builtins.rs:514 — append signature test after install_str_clone_signature

  • Step 1: Insert str_concat install entry

Immediately after the closing ); of the str_clone env.globals.insert(...) block at crates/ailang-check/src/builtins.rs:232, insert:

    env.globals.insert(
        "str_concat".into(),
        Type::Fn {
            params: vec![Type::str_(), Type::str_()],
            ret: Box::new(Type::str_()),
            effects: vec![],
            param_modes: vec![
                ailang_core::ast::ParamMode::Borrow,
                ailang_core::ast::ParamMode::Borrow,
            ],
            ret_mode: ailang_core::ast::ParamMode::Own,
        },
    );
  • Step 2: Append list() row

Find the row ("str_clone", "(Str) -> Str"), near crates/ailang-check/src/builtins.rs:330. Insert the new row immediately after it:

        ("str_concat", "(Str, Str) -> Str"),

Maintain the comma + indentation pattern of the surrounding rows.

  • Step 3: Append signature unit test

Find install_str_clone_signature around crates/ailang-check/src/builtins.rs:505-514. Immediately after its closing }, append:

    #[test]
    fn install_str_concat_signature() {
        // Iter str-concat: `str_concat : (Str borrow, Str borrow) -> Str
        // own`. Codegen lowers via the runtime C glue `ailang_str_concat`
        // from `runtime/str.c`.
        let mut env = Env::default();
        install(&mut env);
        let ty = env
            .globals
            .get("str_concat")
            .expect("str_concat must be installed");
        match ty {
            Type::Fn {
                params,
                ret,
                effects,
                param_modes,
                ret_mode,
            } => {
                assert_eq!(params.len(), 2);
                assert!(matches!(params[0], Type::Con { ref name, .. } if name == "Str"));
                assert!(matches!(params[1], Type::Con { ref name, .. } if name == "Str"));
                assert!(matches!(**ret, Type::Con { ref name, .. } if name == "Str"));
                assert!(effects.is_empty(), "str_concat must be effect-free");
                assert_eq!(
                    param_modes,
                    &vec![
                        ailang_core::ast::ParamMode::Borrow,
                        ailang_core::ast::ParamMode::Borrow,
                    ]
                );
                assert_eq!(*ret_mode, ailang_core::ast::ParamMode::Own);
            }
            other => panic!("expected Type::Fn; got {other:?}"),
        }
    }
  • Step 4: Run the new unit test

Run: cargo test --manifest-path=Cargo.toml -p ailang-check builtins::tests::install_str_concat_signature 2>&1 | tail -5 Expected: test result: ok. 1 passed.

  • Step 5: Full workspace test

Run: cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}' Expected: passed: 560 failed: 1 (str_concat_e2e moves further but still RED — check passes now, build fails because codegen doesn't yet emit the call).

Task 4: ailang-codegen — extern declare + lower_app arm + is_builtin_callable + IR pin

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:548 — append extern declaration

  • Modify: crates/ailang-codegen/src/lib.rs:1977 — insert lower_app arm after str_clone arm

  • Modify: crates/ailang-codegen/src/lib.rs:2192 — extend is_builtin_callable list

  • Modify: crates/ailang-codegen/src/lib.rs:4292 — append IR-pin test after str_clone IR-pin

  • Step 1: Append extern declaration

Find the line out.push_str("declare ptr @ailang_str_clone(ptr)\n"); at crates/ailang-codegen/src/lib.rs:548. Immediately after that push_str line, insert:

    out.push_str("declare ptr @ailang_str_concat(ptr, ptr)\n");
  • Step 2: Insert str_concat arm in lower_app after the str_clone arm

Find the str_clone arm at crates/ailang-codegen/src/lib.rs:1960-1977. Immediately after its closing brace (the } at line 1977), insert:

        if name == "str_concat" {
            // Iter str-concat: lowers to the runtime C glue
            // `ailang_str_concat(ptr, ptr) -> ptr` defined in
            // `runtime/str.c`. Reads `len` from offset 0 of each
            // source Str payload and allocates a fresh heap-Str
            // slab sized for the combined bytes; works uniformly
            // on static-Str and heap-Str inputs because the
            // consumer ABI is identical. Common shape in Show
            // bodies: `(app str_concat "label=" (app int_to_str x))`.
            if args.len() != 2 {
                return Err(CodegenError::Internal("str_concat arity".into()));
            }
            let (a, _) = self.lower_term(&args[0])?;
            let (b, _) = self.lower_term(&args[1])?;
            let dst = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {dst} = call ptr @ailang_str_concat(ptr {a}, ptr {b})\n"
            ));
            return Ok((dst, "ptr".to_string()));
        }
  • Step 3: Extend is_builtin_callable name list

Find the matches! block at crates/ailang-codegen/src/lib.rs:2183-2193:

        if matches!(
            name,
            "neg"
                | "int_to_float"
                | "float_to_int_truncate"
                | "is_nan"
                | "float_to_str"
                | "int_to_str"
                | "bool_to_str"
                | "str_clone"
        ) {

Change to:

        if matches!(
            name,
            "neg"
                | "int_to_float"
                | "float_to_int_truncate"
                | "is_nan"
                | "float_to_str"
                | "int_to_str"
                | "bool_to_str"
                | "str_clone"
                | "str_concat"
        ) {
  • Step 4: Append IR-pin unit test

Find the str_clone_emits_call_to_ailang_str_clone test at crates/ailang-codegen/src/lib.rs:4255-4292. Immediately after its closing }, append:

    #[test]
    fn str_concat_emits_call_to_ailang_str_concat() {
        // Iter str-concat: `(app str_concat "x" "y")` must lower to
        // `call ptr @ailang_str_concat(ptr, ptr)` in the emitted IR.
        // Pins the extern declaration + the lower_app arm together.
        let src = r#"
(module ir_pin_str_concat
  (fn produce_label
    (type (fn-type (params) (ret (con Str))))
    (params)
    (body (app str_concat "x" "y"))))
"#;
        let module: Module = serde_json::from_str(
            &ailang_surface::parse(src).expect("parse").to_string(),
        )
        .expect("module from parse");
        let mut ws = Workspace::default();
        ws.modules
            .insert(module.name.clone(), module.clone());
        let ir = lower_workspace_to_ir(&ws, &CodegenOptions::default())
            .expect("codegen");
        assert!(
            ir.contains("declare ptr @ailang_str_concat(ptr, ptr)"),
            "IR must declare the extern; got {ir}"
        );
        assert!(
            ir.contains("call ptr @ailang_str_concat(ptr"),
            "IR must contain the call to @ailang_str_concat; got {ir}"
        );
    }

(If the imports in the surrounding test module differ from what's used by the str_clone IR pin, lift them — read 4255-4292 first and mirror the pattern exactly. The above is a structural sketch; the exact Module / Workspace / lower_workspace_to_ir imports must match the sibling test.)

  • Step 5: Run the new IR-pin test

Run: cargo test --manifest-path=Cargo.toml -p ailang-codegen str_concat_emits_call_to_ailang_str_concat 2>&1 | tail -5 Expected: test result: ok. 1 passed.

  • Step 6: Run the str_concat E2E test

Run: cargo test --manifest-path=Cargo.toml -p ail --test str_concat_e2e 2>&1 | tail -5 Expected: test result: ok. 1 passed. The RED from Task 1 is now GREEN: check passes, build passes, run produces Item 42\n.

  • Step 7: Full workspace test

Run: cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}' Expected: passed: 562 failed: 1 (the new pin test + new IR-pin test pass; but the unbound_in_instance_method_pin.rs tests now FAIL because str_concat is no longer unbound — Task 5 repairs this lockstep collision).

Task 5: Repair the bug-fixture / pin-test collision

Files:

  • Modify: examples/bug_unbound_in_instance_method.ail:14 — rename str_concat to format_label
  • Modify: crates/ail/tests/unbound_in_instance_method_pin.rs — rename all four str_concat references to format_label

Why this task exists. 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 actually reached for. Once str_concat ships as a builtin (Tasks 1-4), the fixture would silently turn into a false-OK and the pin test starts failing. The pin's intent (instance-method-body walked through unbound-var check) is name-independent; substituting a different LLM-natural unbound name (format_label — the kind of helper an LLM-author would forget to define) preserves the intent.

  • Step 1: Update the fixture

Open examples/bug_unbound_in_instance_method.ail. The current line 14 is:

        (body (app str_concat "n=" (app int_to_str n)))))))

Replace str_concat with format_label:

        (body (app format_label "n=" (app int_to_str n)))))))
  • Step 2: Update the pin test references

Open crates/ail/tests/unbound_in_instance_method_pin.rs. The current file has multiple references to str_concat:

  • Line 17: in the module docstring's "monomorphise_workspace: unknown identifier: str_concat" verbatim panic-message quote;
  • Line 22: in the module docstring's "the unbound str_concat in the method body";
  • Line 36: in the module docstring's fixture description "The fixture uses str_concat (NOT a builtin)";
  • Line 52 onward: the test name check_fires_unbound_var_for_str_concat_in_instance_method_body;
  • Line 84: assertion combined.contains("str_concat");
  • Line 100 onward: the test name check_json_unbound_var_in_instance_method_body;
  • Line 132: assertion d.to_string().contains("str_concat").

Strategy: replace str_concatformat_label everywhere in the file (Edit with replace_all: true). The test names will become check_fires_unbound_var_for_format_label_in_instance_method_body and check_json_unbound_var_in_instance_method_body (the second name doesn't contain str_concat; leave it). Update the docstring text accordingly.

The simpler exact strategy: read the file, then issue ONE Edit with old_string = "str_concat", new_string = "format_label", replace_all = true.

  • Step 3: Verify the pin test now correctly RED-protects with the new name

Run: cargo test --manifest-path=Cargo.toml -p ail --test unbound_in_instance_method_pin 2>&1 | tail -10 Expected: test result: ok. 2 passed. Both pin tests pass: ail check correctly fires [unbound-var]: format_label on the fixture (the bugfix from 77f584a stays GREEN under the new unbound name).

  • Step 4: Verify the new fixture also ail checks as expected (its str_concat usage is now valid)

Run: ./target/debug/ail check examples/show_user_adt_with_label.ail 2>&1 Expected output: ok (<N> symbols across 2 modules) (likely 23 symbols across 2 modules; verify empirically and record in the journal if it differs from expected).

  • Step 5: Full workspace test

Run: cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}' Expected: passed: 562 failed: 0. Net: +3 from baseline (559 → 562): the new E2E pin + the new builtin-signature test + the new IR-pin test, all GREEN.

Task 6: DESIGN.md — new §"Heap-Str primitives" subsection

Files:

  • Modify: docs/DESIGN.md:1992 — insert new subsection between the milestone-24 amend block and the §"Primitive output goes through io/print_int" paragraph

  • Step 1: Insert new subsection

Find the empty line after Routing through print replaces the ad-hoc io/print_int|bool|float idiom for new code; retiring the per-type effect-ops is queued as a P2 follow-up. (DESIGN.md ending around line 1992). Before the Primitive output goes through io/print_int / io/print_bool / io/print_str directly. paragraph at line 1994, insert:

### Heap-Str primitives

The runtime ships a small family of operations that produce or
transform heap-allocated `Str` values uniformly across static-Str
and heap-Str inputs (the consumer ABI is identical between
realisations — see §"Str ABI"). All take their input(s) by `borrow`
and return an owned `Str`. Each is registered as a builtin in
`crates/ailang-check/src/builtins.rs`, lowered inline in
`crates/ailang-codegen/src/lib.rs::lower_app` to a `call ptr @ailang_<name>`,
and backed by a `runtime/str.c` C helper.

- `int_to_str : (borrow Int) -> Str` (iter 24.1) — decimal rendering
  of an `Int`. Backs `Show Int` in the prelude.
- `bool_to_str : (borrow Bool) -> Str` (iter 24.1) — `"true"`/`"false"`.
  Backs `Show Bool` in the prelude.
- `float_to_str : (borrow Float) -> Str` (iter 24.1, type-installed;
  codegen ships in a future iter per roadmap). Will back `Show Float`.
- `str_clone : (borrow Str) -> Str` (iter 24.1) — allocates a fresh
  heap-Str copy of the input's bytes. Backs `Show Str` in the prelude.
- `str_concat : (borrow Str, borrow Str) -> Str` (iter str-concat,
  2026-05-13) — combines two `Str` values into a single owned `Str`.
  General-purpose; commonly used in Show bodies for labelled output
  (`(app str_concat "label=" (app int_to_str x))`).

The four Show-backers above are not directly observable to the
LLM-author writing a `Show <T>` instance — the prelude's instance
bodies dispatch into them. `str_concat` IS directly observable
because the LLM-author calls it explicitly when authoring an
instance body that wants to combine fragments.

  • Step 2: Verify the DESIGN.md edit is syntactically sound

Run: grep -n '### Heap-Str primitives' docs/DESIGN.md Expected output: one hit on a line near 1993 or wherever the insertion landed.

Run: grep -n 'Primitive output goes through' docs/DESIGN.md Expected output: one hit (the still-existing paragraph that followed the new subsection).

  • Step 3: Full workspace test as a no-regression smoke (pure docs change)

Run: cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}' Expected: passed: 562 failed: 0.

Task 7: Verification + roadmap update

Files:

  • Modify: docs/roadmap.md — insert a struck [x] line under a P2 block

  • Step 1: Add roadmap entry

Open docs/roadmap.md. Find an appropriate place under P2 — alongside the other shipped features. The cleanest insertion point is at the END of the P2 §[x] cluster (right after the existing struck-out entries for milestones already shipped: Heap-Str ABI, Post-22 Prelude, Form-A authoring surface, etc.). Insert:

- [x] **\[feature\]** `str_concat : (borrow Str, borrow Str) -> Str` —
  heap-Str concatenation primitive. Shipped 2026-05-13 as iter
  str-concat (closes fieldtest-form-a friction #4). Symmetric to the
  iter 24.1 `str_clone` / `int_to_str` / `bool_to_str` plumbing:
  runtime C helper (`ailang_str_concat`), `ailang-check` builtin
  registration, `ailang-codegen` extern + `lower_app` arm, plus a
  fresh `examples/show_user_adt_with_label.ail` corpus fixture
  exercising the LLM-natural Show-body shape.
  - context: per-iter journal 2026-05-13-iter-str-concat.md.
  • Step 2: Verify the four fixtures cited in this plan all still ail-check cleanly

Run each in sequence:

./target/debug/ail check examples/show_user_adt_with_label.ail
./target/debug/ail check examples/show_user_adt.ail
./target/debug/ail check examples/test_22c_user_class_e2e.ail
./target/debug/ail check examples/cmp_max_smoke.ail

Expected: each prints ok (<N> symbols across <M> modules) and exits 0. None should regress.

  • Step 3: Verify the ail builtins command now lists str_concat

Run: ./target/debug/ail builtins 2>&1 | grep str_concat Expected output: one line of the form str_concat : (Str, Str) -> Str (or whatever the existing ail builtins format is — int_to_str's and str_clone's rows will dictate the exact shape).

  • Step 4: Final full-workspace test

Run: cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6, ignored+=$8} END {print "passed:",passed,"failed:",failed,"ignored:",ignored}' Expected: passed: 562 failed: 0 ignored: 3. Net iter delta: +3 tests from the 559 baseline (the E2E pin + builtin-signature test + IR-pin test).