Skip to content

Resource rename fails when a function-typed parameter precedes a resource parameter #967

Open

Description

@devdotbo

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 with cargo build --locked -p semaprax --bin semaprax, sha256 16542ba925d65ead8e1b3c7e070f7e2abac39fd3479dbf78c0786435d287ae72; --version prints semaprax 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 (sha256 3a331f1abef861eaba4240280b902f65afe6bd569dc71882b83f5fcad0ff6243), without the callback: fn wrap(buffer: own Buffer) -> Buffer. Base sha256:512fdcf0efcc662202800b4cec4307dc1bef5fc0af1d4176d268184ea50fe11c.
  • after.spx (sha256 216700fcf028dfcc60e588b927918dcfb19ae313f57df9f4c89bbe0364706edb), with the callback after the resource parameter: fn wrap(buffer: own Buffer, callback: fn(i64) -> i64) -> Buffer. Base sha256: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 patch nor impact prints 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-662 finds 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions