Skip to content

Semantic patch v2: SPX-G108 on while loops, method calls, i32 literals and match patterns #963

Open

Description

@devdotbo

Summary

The Semantic Patch v2 preflight builds a source/HIR identity index over declarations and over the contracts and bodies of top-level functions. Its walker has no arms for while statements, method calls, i32 literals, or literal, or-pattern and binding match patterns. In the positions reproduced below, these valid constructs make a rename or rename-member patch fail with SPX-G108 semantic patch source/HIR identity index is inconsistent, even when the construct is unrelated to the renamed declaration. The committed examples/refutable_match.spx and examples/integers_i32.spx fail too. All cases end in the _ => return None fallthroughs of one walker, so they are reported together.

Environment

  • Repository: github.com/wavect/semaprax at commit 2d215d3.
  • Binary: target/debug/semaprax, sha256 16542ba925d65ead8e1b3c7e070f7e2abac39fd3479dbf78c0786435d287ae72, tested with a clean checkout at the commit above; --version prints semaprax 0.9.0 (commit unknown). Every command below used this binary.
  • Host: macOS 27.2 arm64 (Darwin 27.2.0).
  • Toolchain: rustc 1.98.1 (48a229cea 2026-09-01); cargo 1.98.1 (797e8a9bc 2026-08-05).

Fixtures

loop.spx has an unrelated while loop in main:

module patch.member;

@id("patch.box")
record Box {
    @id("patch.box.value")
    value: i64,
}

@id("app.main")
fn main() -> i64
{
    let b = Box { value: 1 };
    let mut n = 0;
    while n < 1 {
        n = n + 1;
        n < 1
    }
    b.value + n
}

control.spx is loop.spx without the let mut n line and the loop, with b.value as the last line of main.

method.spx has a method call in main:

module demo;

@id("demo.counter")
class Counter {
    @id("demo.counter.start")
    start: i64,

    @id("demo.counter.get")
    fn get(self: Counter) -> i64
{
        41
    }
}

@id("demo.helper")
fn helper() -> i64
{
    42
}

@id("demo.main")
fn main() -> i64
{
    let c = Counter { start: 1 };
    c.get() + helper()
}

nocall.spx is method.spx with c.get() + helper() replaced by c.start + helper().

litpat.spx has a literal match pattern in helper:

module t.m;

@id("t.helper")
fn helper(v: i64) -> i64
{
    match v { 0 => 0, _ => 1, }
}

@id("t.main")
fn main() -> i64
{
    helper(1)
}

Its variants replace only the body of helper. All sources pass fmt --check and check with exit code 0.

Steps and observed output

Each .spatch file has three lines: schema semaprax.semantic-patch.v2, base sha256:<hash> with the hash that semaprax check prints for the source, and the operation. Each patch ran on a fresh copy of the source:

$ cat loop.spatch
schema semaprax.semantic-patch.v2
base sha256:21c8f85d1d6e833955d83a98f04a003a8680a40a3e167c7f7fb2248d2008a6b6
rename-member owner patch.box member patch.box.value to payload
$ semaprax patch loop.spx loop.spatch; echo rc=$?
error[SPX-G108]: semantic patch source/HIR identity index is inconsistent
rc=1
$ semaprax patch control.spx control.spatch; echo rc=$?
applied semantic patch; graph is now sha256:973e7027f8526100c1c74b9e761ce4f6d6569c61e88b970865abe6d1904ff774
rc=0
Source Base Operation Result
control.spx 28f5d65d2503b9ea441c3c960397f94e0781ccc701614333b866cd18c4ec2064 rename-member owner patch.box member patch.box.value to payload applied, rc=0
loop.spx 21c8f85d1d6e833955d83a98f04a003a8680a40a3e167c7f7fb2248d2008a6b6 same SPX-G108, rc=1
nocall.spx 2c3bbbaef847fde3b6678a3d45277f9cfe4adbe850724f91f5f3c4e5867dc1b6 rename demo.helper to computed applied, rc=0
method.spx b8931ddb57fd255ad3935137facef98ad23399f55893a8d11df616a0c726a12a same SPX-G108, rc=1
litpat.spx 0ba3b8772b94a6f43d2a8cb3120dbde565c123419cdd19998aa624be01c2c40f rename t.helper to total SPX-G108, rc=1
helper body let a = 2i32; then v b85e50567ee1660b888adcdb0685427e2f58a9aee57346df906f57a51f1edfe5 same SPX-G108, rc=1
helper body match v { 1 | 2 => 0, _ => 1, } 5912ff3f29be0184e68bdeedf7c9fdba9f66dbb623f3f7b9a79e634d41bdcd44 same SPX-G108, rc=1
helper body match v { n => n, } a5a31ba32b7ad2b67c2261c399639a0f3c7d545b63f8c5f3227e979f803170a4 same SPX-G108, rc=1
helper body v + 1 c3ad43d0867c1e8d37c3623cbefb3142b36e99de1034823df44f5256535b47d4 same applied, rc=0
examples/refutable_match.spx dc987de603a56afdbe1527107b272fea520901dff79ba0b3910cab372b885287 rename refutable.sign_class to sign_kind SPX-G108, rc=1
examples/integers_i32.spx 643584f9a136582e4613e78411f595f95a8184cbdcde0343528d3cf507e1cbb9 rename sum.pair to pair_sum SPX-G108, rc=1

Every failing patch prints nothing on stdout, writes the error line to stderr, leaves the source byte-identical and creates no files. Every applied patch yields a source that passes check; nocall.spx still prints 43 under run. The examples trigger it at line 6 (literal, or and binding patterns; 2i32).

Each failing program differs from its control only in code the rename does not touch. SPX-G108 also occurs when the while loop or c.get() moves into a separate function that main calls; the c.start twin of the latter applies. A renamed reference inside the construct fails too: while n < b.value { n = n + 1; 0 } in main with the rename-member above gives SPX-G108, rc=1.

Expected behaviour

Each failing case applies like its control, with exit code 0. For loop.spx, the declaration, record-literal label and field read become payload; for method.spx, helper becomes computed at its declaration and call site.

Where in the code

  • src/patch.rs:1477-1484 builds SemanticSourceIndex and maps a None result to SPX-G108.
  • src/patch/source_index.rs:111-135 starts traversal from every top-level function's contracts and body, not only from declarations the patch selects. It does not traverse class method bodies, and the match walker skips arm guards (src/patch/source_index.rs:449-451).
  • src/patch/source_index.rs:347-370: block statements pair only Let, Assign and Unsafe; any other statement returns None. While (src/ast/statement.rs:42) and For (line 48) have no arm.
  • src/patch/source_index.rs:506-513: the expression match pairs Int, String, BorrowedUtf8Literal, Bool and Var, then returns None. ExprKind::Int32 (src/ast.rs:929) and ExprKind::MethodCall (src/ast.rs:958) are not named in the file (rg -nw 'MethodCall|Int32|While|For' src/patch/source_index.rs finds nothing).
  • src/patch/source_index.rs:533-571: pattern() pairs only Variant, Record and Wildcard and returns None at line 569. MatchPattern::Literal, Or and Binding are declared at src/ast.rs:1063, 1070 and 1075.
  • docs/SEMANTIC-PATCH-V2.md:64 lists SPX-G108 for a "source/HIR index mismatch" without naming these constructs.

Suggested fix

One possible fix is to extend SemanticSourceIndex for the demonstrated While, MethodCall, Int32 and literal, or-pattern and binding pattern cases. Method calls lower to Call with the receiver as an implicit first argument (src/hir/resolve_expr.rs:3050-3063), so the pairing must include that argument, and match guards need pairing too. The fail-closed None would stay wherever correspondence cannot be established. Regressions could cover unrelated renames and renamed references inside these constructs. For, ForBorrow and ForOwn probably need a separate audit: their lowering introduces synthesized HIR structure (src/hir/resolve_for.rs:588-665).

Related

Not verified

  • for loops are not isolated. A for own fixture over a Vec fails with SPX-G108, but so does its control without the loop.
  • Other scalar literal kinds and other v2 operations were not tested.
  • Only the debug binary on macOS was used.

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