Repository navigation
Description
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 withcargo build --locked -p semaprax --bin semaprax, sha2563eb5ebbf7f5cefa5a3be9b5c66032ffb0dbbda162f48cb015975721d636b8b2f;--versionprintssemaprax 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:16c41f384cc91ffa4d55badc0d1d2d18311fd0a91c54b0c1b2556aec19990a45with rc=0, the output haspoint.heightandheight: 1, andrunprints 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
- [REF-12][P2] Represent semantic patch operations once and derive planner views #524: refactor of the semantic patch operation representation. It mentions member renames as a planner view and does not touch the source span index, so it does not overlap with this fix.
Not verified
rename-memberis 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