fix: report a command with no parameterless constructor as a load failure [patch] - #73
Conversation
…lure [patch] JsonUndoRedoSerializer.ConvertFromSerializableCommand reconstructs commands with Activator.CreateInstance(Type), which needs a public parameterless constructor. Real ICommand implementations take their target and values as constructor arguments, and nothing on ISerializableCommand documented the requirement. The resulting MissingMethodException was not in LoadStateAsync's exception filter (JsonException, InvalidOperationException, NotSupportedException), so it escaped a method whose Task<bool> contract is to report deserialization failure as false. Saving worked, loading threw. Translate MissingMethodException into an InvalidOperationException naming the offending type, which the existing filter already covers, and preserve the original as InnerException. Also document the parameterless-constructor requirement on ISerializableCommand so implementers learn it from the interface rather than at load time. Adds two tests, both failing before the change: - UndoRedoService_LoadStateCommandHasNoParameterlessConstructor_ReturnsFalse - JsonSerializer_DeserializeCommandHasNoParameterlessConstructor_ThrowsInvalidOperationException Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1ntzPwg732TrH7D5vWpSV
|
|
SonarQube's quality gate passed here (100% coverage on new code, 0 duplication, 0 hotspots). Recording what I did with the 4 new issues it counted, since the summary links to them rather than listing them. All four are the same rule,
These stay as they are. Not because they're INFO — because adopting them here would make this PR inconsistent with the file it's in:
If the repo wants For contrast, I did act on the lint findings in the sibling PRs where the repo had already established the newer convention: #72 ( Generated by Claude Code |



Fixes #71.
JsonUndoRedoSerializer.ConvertFromSerializableCommandreconstructs commands withActivator.CreateInstance(commandType), which requires a public parameterless constructor. RealICommandimplementations take their target and the values being changed as constructor arguments, and nothing onISerializableCommanddocumented the requirement.The resulting
MissingMethodExceptionis not inLoadStateAsync's exception filter:So it escaped a method whose whole
Task<bool>contract is to report deserialization failure asfalse. Saving such a command worked fine (SerializeAsyncconstructs nothing); loading it back threw.The change
Two parts, both small:
Translate at the source. The
Activator.CreateInstancecall is wrapped, andMissingMethodExceptionbecomes anInvalidOperationExceptionnaming the offending type, with the original kept asInnerException. Translating here rather than widening the filter inLoadStateAsyncmeans directDeserializeAsynccallers also get a diagnosable error instead of a bare reflection failure, andLoadStateAsyncreturnsfalsethrough the filter it already has.Document the requirement on
ISerializableCommanditself, so implementers learn it from the interface rather than at load time. The issue raised this as the longer-term half; enforcement would need an analyzer or a generic constraint, and neither belongs in a patch, so this is documentation only.MissingMethodExceptionis also whatActivator.CreateInstanceraises for an abstract type, so that case is covered by the same translation.Tests
Both verified to fail before the change and pass after it (by stashing the
JsonUndoRedoSerializer.cschange and re-running):UndoRedoService_LoadStateCommandHasNoParameterlessConstructor_ReturnsFalseMissingMethodExceptionescapesLoadStateAsyncinstead of it returningfalseJsonSerializer_DeserializeCommandHasNoParameterlessConstructor_ThrowsInvalidOperationExceptionMissingMethodExceptionwhereInvalidOperationExceptionis the contractThe test command
ConstructorOnlySerializableCommanddeliberately has only a value-taking constructor — the shape the issue describes as the realistic case. The existingTestSerializableCommandhas a parameterless constructor, which is why the existing round-trip tests never saw this.Full suite: 48 passed, 0 failed, 0 skipped.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T1ntzPwg732TrH7D5vWpSV
Generated by Claude Code