Skip to content

Semantic patch v2 rename-member misses field uses in assignment targets and match guards #964

Open

Description

@devdotbo

Summary

A v2 rename-member patch renames the field declaration, the record literal selector and plain field reads, but not the field in an assignment target such as b.value = 2;. patch and impact then exit 1 with SPX-U108 record Box has no field value at that assignment, and the file stays unchanged. The same program without the assignment renames cleanly. Two further fixtures with an assigned field failed the same way. A field read inside a match guard has the same gap (see Further cases). Both gaps are in the same source index walk, so they are reported together.

Environment

  • Repository: github.com/wavect/semaprax at commit 2d215d3.
  • Binary: target/debug/semaprax, built with cargo build --locked -p semaprax --bin semaprax, sha256 3eb5ebbf7f5cefa5a3be9b5c66032ffb0dbbda162f48cb015975721d636b8b2f; --version prints semaprax 0.9.0 (commit unknown).
  • Host: macOS arm64, Darwin 27.2.0.
  • Toolchain: rustc 1.98.1 (48a229cea 2026-09-01); cargo 1.98.1 (797e8a9bc 2026-08-05).

Fixture

module.spx:

module patch.member;

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

@id("app.main")
fn main() -> i64
{
    let mut b = Box { value: 1 };
    b.value = 2;
    b.value
}

change.spatch (the base is the hash that check module.spx prints):

schema semaprax.semantic-patch.v2
base sha256:568a9b92a5c63f0c70f446b1336c5ced0bff237cc240a0d0443b579d19be0cfa
rename-member owner patch.box member patch.box.value to payload

Control: control.spx is the same program without the assignment (let b = Box { value: 1 }; followed by b.value). control.spatch is change.spatch with base sha256:28f5d65d2503b9ea441c3c960397f94e0781ccc701614333b866cd18c4ec2064.

Steps and observed output

The source passes formatting, checking and running:

$ semaprax fmt module.spx --check; echo rc=$?
rc=0

$ semaprax check module.spx; echo rc=$?
verified module.spx (sha256:568a9b92a5c63f0c70f446b1336c5ced0bff237cc240a0d0443b579d19be0cfa)
rc=0

$ semaprax run module.spx; echo rc=$?
2
rc=0

The patch fails. Stdout is empty; the error line is on stderr:

$ semaprax patch module.spx change.spatch; echo rc=$?
error[SPX-U108]: record `Box` has no field `value` at module.spx:13:7
rc=1

semaprax impact module.spx change.spatch prints the same line with rc=1.

Line 13, column 7 is value in b.value = 2;. module.spx has the same sha256 before and after (113894d604051e40dca2fb1b07899511feb2e7c3b5459da9056bf0697a423b55). The missing-field diagnostic is consistent with the declaration being renamed while the assignment target keeps value.

The control applies:

$ semaprax patch control.spx control.spatch; echo rc=$?
applied semantic patch; graph is now sha256:973e7027f8526100c1c74b9e761ce4f6d6569c61e88b970865abe6d1904ff774
rc=0

$ semaprax run control.spx; echo rc=$?
1
rc=0

After the control patch, control.spx reads payload: i64, let b = Box { payload: 1 }; and b.payload.

The resolved graph already knows the field identity of the assignment target. The assign statement in semaprax graph module.spx (trimmed):

{"kind":"assign","target":{"id":"declaration:8:app.main:value:local:7:body.s0","name":"b",...},"value":{...,"kind":"int","value":"2"},"field":"patch.box.value"}

Expected behaviour

Before the patch is applied, semaprax impact module.spx change.spatch exits 0 and leaves the source unchanged. semaprax patch module.spx change.spatch exits 0 and rewrites module.spx so the declaration reads payload: i64 and main reads:

    let mut b = Box { payload: 1 };
    b.payload = 2;
    b.payload

semaprax run module.spx still prints 2.

Where in the code

src/patch/source_index.rs:356-362. The Assign arm walks only the assigned value. The field member of the statement is ignored, so its span is never recorded as a use of patch.box.value:

                        | (
                            Statement::Assign { value, .. },
                            ResolvedStatement::Assign {
                                value: resolved_value,
                                ..
                            },
                        ) => self.expr(value, resolved_value, tokens)?,

Both sides carry the field: the source statement has field: Option<FieldTarget> (src/ast/statement.rs:23-29, with FieldTarget { name, span } at src/ast.rs:1186-1189), and the resolved statement has field: Option<DeclarationId> (src/hir/expr_nodes.rs:492-497).

For comparison, the folded field-read path records a member use from a resolved field identity at src/patch/source_index.rs:499-503:

                    if self.ambiguous_member_fields.contains(field.as_str()) {
                        return None;
                    }
                    let owner = self.member_owners.get(field.as_str())?.clone();
                    self.member(&owner, field.as_str(), span, None);

Further cases

A field read inside a match guard is also not renamed. guard.spx passes fmt --check and check, and run prints 42:

module t.g;

@id("t.point")
record Point {
    @id("t.point.x")
    x: i64,
    @id("t.point.y")
    y: i64,
}

@id("t.result")
variant Outcome {
    @id("t.result.ok")
    Ok {
        @id("t.result.ok.value")
        value: i64,
    },
    @id("t.result.err")
    Err,
}

@id("t.pick")
fn pick(input: Outcome, point: Point) -> i64
{
    match input { Outcome::Ok { value } if value > point.y => value, Outcome::Ok { value } => 0, Outcome::Err {} => 0, }
}

@id("t.main")
fn main() -> i64
{
    pick(Outcome::Ok { value: 42 }, Point { x: 0, y: 1 })
}

guard.spatch:

schema semaprax.semantic-patch.v2
base sha256:41e4a4f24b79ee2a9c5f60ed03e2d967f3798904569112b3820c49d58f244891
rename-member owner t.point member t.point.y to height
$ semaprax check guard.spx; echo rc=$?
verified guard.spx (sha256:41e4a4f24b79ee2a9c5f60ed03e2d967f3798904569112b3820c49d58f244891)
rc=0

$ semaprax patch guard.spx guard.spatch; echo rc=$?
error[SPX-T214]: record `Point` has no field `y` at guard.spx:25:52
rc=1

impact prints the same line with rc=1. Line 25, column 52 is the start of point.y in the guard. guard.spx is unchanged.

Control:

  • Change: the use moves from the guard into the arm value (if value > 0 => value + point.y), and the base becomes that file's hash, sha256:f366c0a829dba7fcc2676e26753736a1ff01916589c3d382ca9ce82062bf7d39.
  • Result: the same rename prints applied semantic patch; graph is now sha256:16c41f384cc91ffa4d55badc0d1d2d18311fd0a91c54b0c1b2556aec19990a45 with rc=0, the output has point.height and height: 1, and run prints 43.

The arm loop at src/patch/source_index.rs:449-452 walks the pattern and the value, but not the guard:

                for (arm, resolved_arm) in arms.iter().zip(resolved_arms) {
                    self.pattern(&arm.pattern, &resolved_arm.pattern)?;
                    self.expr(&arm.value, &resolved_arm.value, tokens)?;
                }

The guard exists on both sides, at src/ast.rs:1037 (pub guard: Option<Box<Expr>>,) and src/hir/expr_nodes.rs:315 (pub guard: Option<Box<ResolvedExpr>>,). src/patch/source_index.rs contains no occurrence of guard.

Suggested fix

One possible fix: in the Assign arm, when the source and resolved statements both have a field, apply the checks of the folded path at src/patch/source_index.rs:499-503 and record FieldTarget.span with self.member, then index the assigned value as now. In the arm loop, walk arm.guard against resolved_arm.guard with self.expr. If only one side has a field or a guard, return None, as the arm already does when statement kinds or arm counts differ (src/patch/source_index.rs:344-345, :370, :446-447). Regression tests could rename an assigned field and a field read in a guard and check that every use is renamed.

Related

Not verified

  • rename-member is accepted only in schema v2 (src/patch.rs:2398-2399), so v1 patches are not affected.
  • Other statement and expression kinds that may hold a field use were not checked.
  • 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