Repro
import "/usr/include/string.h"
def via_param(b):
return strlen(b)
s = "abcdefgh"
v = s.encode("ascii")
print(strlen(v)) # 8 -- already correct
print(strlen(s.encode("ascii"))) # 3 -- WRONG
print(via_param(s.encode("ascii"))) # 8 -- correct: a parameter is a name
print(strlen(b"abcdefgh")) # 3 -- WRONG
print(strlen("".encode("ascii"))) # 3 -- WRONG (want 0)
The control this needs, and why the obvious probe certifies the bug
strlen(b"abc") answers 3, which is CORRECT, and three characters is the
natural length to reach for. The wrong answer is a CONSTANT that collides with
a plausible right one -- the same family as CLAUDE.md's "choose a probe whose
right answer differs from the default", by a door that list does not yet name:
not a width, not an empty aggregate, but a fixed wrong value that happens to be
right at one input. Every row in the fixture is eight characters for that
reason, and it says so in its own comment.
The fix
compiler/pyparser.inc, PyCoerceCallableArgsIn: when an external callee's
tyPointer parameter receives a statically TPyBytes argument that is not
already an AN_IDENT, hoist it into a hidden named temp and pass the temp.
The argument is then exactly what IRLowerCallArg's existing static arm turns
into pybytes_cbuf(tmp) -- one answer to "where is this object's data", not
two. It is the same hoist, for the same ownership reason, that the neighbouring
TPyList arm already performs: a temp in the enclosing routine outlives the
call, where a nested call result has no owner.
One divergence it takes deliberately: the hoisted argument is now evaluated before the arguments to its left, where Python is strictly left to right. The list arm above it already takes the same one, and there is no spelling here that assigns a temp in place.
Gate
make test-nilpy; self-host converged; new fixture
test/test_nilpy_a_bytes_expression_reaches_a_c_pointer_parameter_as_its_buffer.npy,
wired into the Makefile, GREEN at HEAD and RED against the pinned compiler
(call result 3, literal 3, empty expr 3).
Inert until pinned
These are compiler/** and compiler/builtin/pylib.pas changes, so a NilPy
program built with ./compiler/pascal26 has them now and a program built with
$(PXX_STABLE) does NOT. Anyone rebuilding lekkerzeilen against the pin will
still get the blank scene until the next make pin, which is the owner's to
run. Saying so here rather than waiting: CLAUDE.md's own rule is that a fix is
inert until pinned and that nobody holds a seat waiting for one.
Log
- 2026-09-14 -- found while tracking lekkerzeilen's missing geometry, filed,
and fixed the same evening, commit 925346238. Filed first as "an inline bytes literal ..."
which was too narrow: the literal is one case of an expression and
"".encode()is another.