Repository navigation
Description
Summary
Renaming t.buffer in the fixture below fails with SPX-T001, although the source passes check and fmt --check. The same rename succeeds without the callback parameter, and with the callback after the resource parameter. From reading the code, the return-type lookup takes the first -> after the function name, which is the arrow inside fn(i64) -> i64, and then records the parameter's Buffer a second time instead of the return type. The patch is refused and the source stays unchanged.
Environment
- Repository: github.com/wavect/semaprax at commit 2d215d3
- Binary:
target/debug/semaprax, built withcargo build --locked -p semaprax --bin semaprax, sha25616542ba925d65ead8e1b3c7e070f7e2abac39fd3479dbf78c0786435d287ae72;--versionprintssemaprax 0.9.0 (commit unknown) - Host: macOS 27.2 (Darwin 27.2.0), arm64
- Toolchain: rustc 1.98.1 (48a229cea 2026-09-01); cargo 1.98.1 (797e8a9bc 2026-08-05)
Fixture
callback.spx (sha256 029a89f837f77aa6925f6611dc810b9d7eaac947041c5e8a3ec963bc526c7a06):
module t.res;
@id("t.buffer")
resource Buffer {
@id("t.buffer.drop")
drop trivial;
}
@id("t.wrap")
fn wrap(callback: fn(i64) -> i64, buffer: own Buffer) -> Buffer
{
buffer
}
@id("t.main")
fn main() -> i64
{
42
}
callback.spatch:
base sha256:4dfdead72e3deb2368b9b0c89c4b229662816b8ff4077dbc017bd42b0bf9815f
rename t.buffer to ByteBuffer
callback-v2.spatch:
schema semaprax.semantic-patch.v2
base sha256:4dfdead72e3deb2368b9b0c89c4b229662816b8ff4077dbc017bd42b0bf9815f
rename t.buffer to ByteBuffer
Two controls differ from callback.spx only in the wrap signature:
plain.spx(sha2563a331f1abef861eaba4240280b902f65afe6bd569dc71882b83f5fcad0ff6243), without the callback:fn wrap(buffer: own Buffer) -> Buffer. Basesha256:512fdcf0efcc662202800b4cec4307dc1bef5fc0af1d4176d268184ea50fe11c.after.spx(sha256216700fcf028dfcc60e588b927918dcfb19ae313f57df9f4c89bbe0364706edb), with the callback after the resource parameter:fn wrap(buffer: own Buffer, callback: fn(i64) -> i64) -> Buffer. Basesha256:b8ad151d7e8fd3141766eb8430221a662542ce7d82f444535a1fc5a6d93cb093.
plain.spatch and after.spatch are callback.spatch with these base hashes. Each base hash is the graph hash that semaprax check prints for the matching source.
Steps and observed output
semaprax below is the debug binary listed above, run in the directory that holds the fixture files.
All three sources are well formed:
$ semaprax fmt --check callback.spx; echo rc=$?
rc=0
$ semaprax check callback.spx; echo rc=$?
verified callback.spx (sha256:4dfdead72e3deb2368b9b0c89c4b229662816b8ff4077dbc017bd42b0bf9815f)
rc=0
$ semaprax check plain.spx; echo rc=$?
verified plain.spx (sha256:512fdcf0efcc662202800b4cec4307dc1bef5fc0af1d4176d268184ea50fe11c)
rc=0
$ semaprax check after.spx; echo rc=$?
verified after.spx (sha256:b8ad151d7e8fd3141766eb8430221a662542ce7d82f444535a1fc5a6d93cb093)
rc=0
fmt --check also exits 0 for plain.spx and after.spx.
The rename fails. Stdout is empty; the line below is stderr:
$ semaprax patch callback.spx callback.spatch; echo rc=$?
error[SPX-T001]: unknown type `Buffer`; declare it with `resource Buffer;` at callback.spx:10:1
rc=1
callback.spx is unchanged afterwards (same sha256 and 231 bytes), and no staging file is left in the directory.
The v2 schema and semaprax impact give the same error, and neither prints a candidate:
$ cp callback.spx callbackv2.spx
$ semaprax patch callbackv2.spx callback-v2.spatch; echo rc=$?
error[SPX-T001]: unknown type `Buffer`; declare it with `resource Buffer;` at callbackv2.spx:10:1
rc=1
$ semaprax impact callback.spx callback.spatch; echo rc=$?
error[SPX-T001]: unknown type `Buffer`; declare it with `resource Buffer;` at callback.spx:10:1
rc=1
Both controls apply:
$ semaprax patch plain.spx plain.spatch; echo rc=$?
applied semantic patch; graph is now sha256:7860bcb25505915af10fc1b08a6a35a35d9d8d43d9a6d4634a9476ca26110f11
rc=0
$ semaprax patch after.spx after.spatch; echo rc=$?
applied semantic patch; graph is now sha256:3673380996a4640351950f36d55ead8c609db9bd40672a40331755f7f8cc59ec
rc=0
In both files the declaration becomes resource ByteBuffer {. The signature in plain.spx becomes fn wrap(buffer: own ByteBuffer) -> ByteBuffer, and in after.spx it becomes fn wrap(buffer: own ByteBuffer, callback: fn(i64) -> i64) -> ByteBuffer. In after.spx the first Buffer after the callback's arrow happens to be the return type.
Expected behaviour
callback.spx is renamed like plain.spx: the patch exits 0, the declaration becomes resource ByteBuffer {, and the signature becomes fn wrap(callback: fn(i64) -> i64, buffer: own ByteBuffer) -> ByteBuffer.
Where in the code
src/patch.rs:3022-3035, in resource_type_positions (src/patch.rs:2948). The arrow search is bounded only by the end of the function name and the start of the body, so it returns the first arrow in the signature. In callback.spx that is the arrow inside fn(i64) -> i64:
if function.return_type == resource_type {
if let Some(arrow) = tokens.iter().find(|token| {
token.span.start >= function.name_span.end
&& token.span.end <= function.body.span.start
&& matches!(token.kind, lexer::TokenKind::Arrow)
}) {
insert_named_type_token(
&mut positions,
tokens,
arrow.span.end,
function.body.span.start,
&resource.name,
);
}src/patch.rs:3049-3054, in insert_named_type_token (src/patch.rs:3042). Only the first matching identifier in the range is recorded. After the callback's arrow, that is Buffer in own Buffer, which the parameter loop at src/patch.rs:3005-3019 already recorded. The return-type Buffer is never added:
if let Some(token) = tokens.iter().find(|token| {
token.span.start >= start
&& token.span.end <= end
&& matches!(&token.kind, lexer::TokenKind::Ident(candidate) if candidate == name)
}) {
positions.insert((token.span.start, token.span.end));Further case
A parameter named like the resource after a callback, fn wrap(callback: fn(i64) -> i64, Buffer: i64, buffer: own Buffer) -> Buffer, passes check and fails the same way: patch exits 1 with SPX-T001 at 10:1 and leaves the file unchanged. By the code above, the return-type lookup would record the parameter name Buffer here, so the candidate would rename a parameter and still miss the return type. This could not be confirmed because the candidate is not printed.
Suggested fix
One possible fix is to locate the return arrow as the first -> after the ) that closes the outer parameter list, tracking parenthesis depth from the ( after the function name, and then search for the resource name between that arrow and the body start. Starting the search at the last parameter's span end would not be enough: a parameter's span covers only its name (src/parser.rs:720, src/parser.rs:736-741), and a function-typed last parameter (as in after.spx) has its own arrow after it. A regression test could cover the reported case, the after.spx control and the Buffer: i64 case, and check that identifiers that are not types and the stable IDs stay unchanged.
Not verified
- The text of the refused candidate. Neither
patchnorimpactprints it before the SPX-T001 refusal, so the cause described above follows from the code, not from inspected output. src/workspace_graph/operation_sidecar.rs:654-662finds the return arrow the same way (first->after the function name). Whether that path misbehaves for this fixture was not tested.- Only the debug binary on macOS was run.
Written by an AI agent loop, lightly reviewed by a human. All commands were re-run at the commit above.
Activity