From c8ecfa39b9aa62252a04c39c0c46bfea962370ed Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 21 May 2026 11:59:38 +0200 Subject: [PATCH] =?UTF-8?q?RED:=20io/print=5Fstr=20must=20print=20exactly?= =?UTF-8?q?=20the=20bytes=20of=20its=20argument=20=E2=80=94=20refs=20#29?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two RED E2E tests that pin the byte-faithful-print property of io/print_str. Both fail today because the codegen at crates/ailang-codegen/src/lib.rs:2754 lowers the op to a C `@puts` call, which writes the string plus a trailing newline. - io_print_str_does_not_append_auto_newline: (seq (do io/print_str "a") (do io/print_str "b")) → stdout "ab" current: "a\nb\n" - io_print_str_does_not_double_embedded_newline: (do io/print_str "x\n") → stdout "x\n" current: "x\n\n" Assertions compare RAW stdout (no .trim()) — the byte-level property IS the assertion target. Empirically caught in the cross-model-authoring naming-A/B run on 2026-05-21 (experiments/2026-05-21-naming-ab/runs/r1/): Qwen3- Coder repeatedly wrote `(do io/print_str "text\n")` with explicit \n, expecting byte-faithful output, and got doubled newlines — failing every t3_main_prints stdout match across all three cohorts. GREEN side (codegen swap @puts → fputs(s, @stdout) + sweep of existing fixtures that implicitly relied on the auto-newline) is the next commit, via implement mini-mode. refs #29 --- .../tests/print_str_no_auto_newline_e2e.rs | 87 +++++++++++++++++++ ...print_str_embedded_newline_no_doubling.ail | 6 ++ examples/print_str_no_auto_newline.ail | 6 ++ 3 files changed, 99 insertions(+) create mode 100644 crates/ail/tests/print_str_no_auto_newline_e2e.rs create mode 100644 examples/print_str_embedded_newline_no_doubling.ail create mode 100644 examples/print_str_no_auto_newline.ail diff --git a/crates/ail/tests/print_str_no_auto_newline_e2e.rs b/crates/ail/tests/print_str_no_auto_newline_e2e.rs new file mode 100644 index 0000000..c80677a --- /dev/null +++ b/crates/ail/tests/print_str_no_auto_newline_e2e.rs @@ -0,0 +1,87 @@ +//! RED tests for Gitea #29: `io/print_str` must print exactly the +//! bytes of its argument with no implicit trailing newline. +//! +//! Today the codegen at `crates/ailang-codegen/src/lib.rs:2734-2754` +//! lowers `(do io/print_str s)` to a C `@puts(ptr)` call. `puts` +//! writes the C string PLUS a trailing newline, so `io/print_str` is +//! de-facto `println_str`. That is undocumented in the design ledger +//! and was empirically caught in the cross-model-authoring naming-A/B +//! run on 2026-05-21: Qwen3-Coder repeatedly wrote +//! `(do io/print_str "text\n")` with explicit `\n`, expecting the +//! bytes to be printed literally — and got doubled newlines, failing +//! every t3_main_prints stdout match across all three cohorts. +//! +//! These tests will turn GREEN once the codegen swaps the `@puts` +//! call for `fputs(s, stdout)` (Option 1 from Gitea #29). Until +//! then they pin the symptom: two single-character prints emit +//! `"ab"`, not `"a\nb\n"`; one print of `"x\n"` emits `"x\n"`, not +//! `"x\n\n"`. +//! +//! NOTE: Unlike `show_print_e2e.rs` these assertions compare RAW +//! stdout — no `.trim()`. The whole point of #29 is the byte-level +//! property; trimming would mask both bugs. + +use std::path::PathBuf; +use std::process::Command; + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples") + .join(name) +} + +fn build_and_run_raw(fixture: &str) -> String { + let src = fixture_path(fixture); + let out = std::env::temp_dir().join(format!("ail_{}.bin", fixture.replace('.', "_"))); + + let build = Command::new(env!("CARGO_BIN_EXE_ail")) + .args(["build", src.to_str().unwrap(), "-o", out.to_str().unwrap()]) + .output() + .expect("ail build"); + assert!( + build.status.success(), + "ail build failed for {fixture}:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr), + ); + + let run = Command::new(&out).output().expect("run binary"); + assert!( + run.status.success(), + "binary exited non-zero:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr), + ); + // Raw stdout — no trimming. The bug is at the byte level. + String::from_utf8(run.stdout).expect("stdout is valid UTF-8") +} + +/// Property: two consecutive `(do io/print_str ...)` calls with +/// single-character no-newline arguments produce exactly the +/// concatenation of those characters on stdout — no separator, no +/// trailing newline. +/// +/// Fixture body: `(seq (do io/print_str "a") (do io/print_str "b"))`. +/// +/// Expected (post-fix): `"ab"`. +/// Today (pre-fix): `"a\nb\n"` — `@puts` appends a newline per call. +#[test] +fn io_print_str_does_not_append_auto_newline() { + let stdout = build_and_run_raw("print_str_no_auto_newline.ail"); + assert_eq!(stdout, "ab", "got: {stdout:?}"); +} + +/// Property: a single `(do io/print_str "x\n")` emits exactly one +/// newline — the one the author embedded. The codegen must not +/// double it. +/// +/// Fixture body: `(do io/print_str "x\n")`. +/// +/// Expected (post-fix): `"x\n"`. +/// Today (pre-fix): `"x\n\n"` — embedded `\n` plus auto-newline +/// from `@puts`. +#[test] +fn io_print_str_does_not_double_embedded_newline() { + let stdout = build_and_run_raw("print_str_embedded_newline_no_doubling.ail"); + assert_eq!(stdout, "x\n", "got: {stdout:?}"); +} diff --git a/examples/print_str_embedded_newline_no_doubling.ail b/examples/print_str_embedded_newline_no_doubling.ail new file mode 100644 index 0000000..66a29de --- /dev/null +++ b/examples/print_str_embedded_newline_no_doubling.ail @@ -0,0 +1,6 @@ +(module print_str_embedded_newline_no_doubling + (fn main + (doc "RED fixture for Gitea #29 (companion to print_str_no_auto_newline.ail): when the author embeds a single `\\n` in the argument, exactly one newline must reach stdout — not two. Today the codegen lowers io/print_str to `@puts(ptr)` which appends its own newline, so this program emits \"x\\n\\n\" (two newlines) instead of the author-intended \"x\\n\". Pins the no-doubling property as a separate axis from the no-auto-newline property: even authors who want a newline must not get a doubled one.") + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body (do io/print_str "x\n")))) diff --git a/examples/print_str_no_auto_newline.ail b/examples/print_str_no_auto_newline.ail new file mode 100644 index 0000000..95c8ced --- /dev/null +++ b/examples/print_str_no_auto_newline.ail @@ -0,0 +1,6 @@ +(module print_str_no_auto_newline + (fn main + (doc "RED fixture for Gitea #29: `io/print_str` must print exactly the bytes of its argument with NO auto-newline. Today the codegen lowers this op to a C `@puts(ptr)` call, which writes the string PLUS a trailing newline — so two consecutive prints of single-character strings emit \"a\\nb\\n\" instead of the expected \"ab\". This is the de-facto-println behaviour LLM authors (Qwen3-Coder, naming-A/B run 2026-05-21) trip over when they write explicit `\\n` and get doubled newlines. Property protected once the codegen swaps `@puts` for `fputs(s, stdout)`: io/print_str is a byte-faithful print, the author chooses whether to embed a newline.") + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body (seq (do io/print_str "a") (do io/print_str "b")))))