…tch]
CI caught a real regression: FileBrowserTests.SavingRecordsTheChosenPathAsRecentlyUsed
failed with an expected and actual that render character-for-character
identical.
Reading AbsoluteFilePath.AbsoluteDirectoryPath mutates the instance it is read
from. In ktsu.Semantics.Paths 5.4.2 an AbsoluteFilePath stops comparing equal
to an identical one, and its hash code changes, once that property has been
touched, while its text stays the same. SchemaEditor.SaveToCurrentPath hands
the same instance to SetSourceFile and then to RecordRecentFile, so anchoring
the schema silently broke equality for a path this code does not own.
So SetSourceFile goes back to Path.GetDirectoryName, and with it the null guard
CA1062 only wanted because the parameter was being dereferenced. The two
TryResolvePath overloads keep AsAbsolute: measured, it mutates neither its
receiver nor its argument, and it is where the issue's actual win was.
Adds a test that fails on the property version and passes on this one, and
records the hazard where the next reader would otherwise 'tidy' it back.
Verified: Schema.Test 502/502, Schema.Editor.UITests 190/190 (the suite that
failed), Schema.Cpp.Test 104/104, Schema.Editor.Test 17/17.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01By7NnPN7STCZqeAftJ1BmH
Fixes #200
What changed
Schema/Models/Schema.Paths.cscombined two already-typedSemantics.Pathsvalues through aSystem.IO.Pathstring round-trip — re-parsing and re-validating both sides of a combine the library already owns.TryResolvePath(RelativeFilePath, …)Path.GetFullPath(Path.Combine(SourceDirectory, relativePath)).As<AbsoluteFilePath>()relativePath.AsAbsolute(SourceDirectory)TryResolvePath(RelativeDirectoryPath, …)relativePath.AsAbsolute(SourceDirectory)SetSourceFileis deliberately left alone — see below. The net diff is two lines of implementation plus the reasoning that keeps them that way.AsAbsolute, not the/operatorThe issue sketched the
/combine operator, and flagged as unverified "whetherPath.GetFullPathnormalization was doing anything the/operator's own construction doesn't already guarantee". It was. Measured againstktsu.Semantics.Paths5.4.2:Path.GetFullPath(Path.Combine(…))/operatorAsAbsolute(base)/src/schemasdata.json/src/schemas/data.json/src/schemassub/data.json/src/schemas/sub/data.json/src/schemas../data.json/src/data.json/src/schemas/../data.json/src/schemas./data.json/src/schemas/data.json/src/schemas/./data.json/src/schemasa/../b/data.json/src/schemas/b/data.json/src/schemas/a/../b/data.json/src/schemasa//data.json/src/schemas/a/data.json/src/schemas/a//data.json/joins; it does not normalise. A schema whose data source sits in a sibling directory ("file": "../shared/items.json"— an ordinary thing to write) would have resolved to a path containing..rather than the path it names. The resolved value is compared, displayed and used as a dictionary key, so that is a behaviour change, not a cosmetic one.AsAbsolute(basePath)reproducesPath.GetFullPath(Path.Combine(…))exactly on every case above, in both overloads. The edge cases the existing guards already reject match too: an empty anchor is refused byCanResolvePathsbefore either implementation is reached, and an empty relative path by thestring.IsNullOrEmptyguard.Why
SetSourceFilekeepsPath.GetDirectoryNameThe issue also proposed
SourceDirectory = schemaFilePath.AbsoluteDirectoryPath. That is not safe, and CI caught it — the first push failedFileBrowserTests.SavingRecordsTheChosenPathAsRecentlyUsedwith an expected and actual that render character-for-character identical.Reading
AbsoluteFilePath.AbsoluteDirectoryPathmutates the instance it is read from. Inktsu.Semantics.Paths5.4.2:SchemaEditor.SaveToCurrentPathcallsCurrentSchema.SetSourceFile(CurrentSchemaPath)and thenOptions.RecordRecentFile(CurrentSchemaPath)— the same instance. So anchoring the schema silently broke equality for a path this code does not own, and the recorded recent file stopped comparing equal to itself.FileNameWithoutExtensionhas the same defect;FileName,FileExtensionandFullFileExtensiondo not.AsAbsolutecarries no such hazard — measured, it mutates neither its receiver nor its argument — which is why the resolution moved and the anchor did not. Reverting the anchor also removes theEnsure.NotNullguard, which CA1062 only asked for because the parameter was being dereferenced.Root cause is upstream and now filed as ktsu-dev/Semantics#265:
AbsoluteFilePathis arecord classcarrying lazily-populated_cachedDirectoryPath/_cachedFileNameWithoutExtensionfields, and record-generatedEquals/GetHashCodeinclude them, so populating a cache on read changes both. It is a hazard well beyond this repo — anAbsoluteFilePathused as a dictionary key becomes unfindable once anything reads its directory.Tests
Added to
SchemaDataSourceTests:TestFilePathsResolveThroughTraversalSegments—../shared/./items.jsonTestDirectoryPathsResolveThroughTraversalSegments—../build/./generatedTestTheAnchorIsTheSchemaFilesDirectory—SetSourceFilesplits anchor from filenameTestSettingTheSourceFileLeavesTheCallersPathEqualToItself— the regression aboveThe resolution change is behaviour-preserving, so no test can separate it from the old code — that is the point. What the two traversal tests pin is the invariant a naive swap would break, verified by temporarily applying the issue's literal
/sketch (both fail:/tmp/…/../shared/./items.jsonvs the expected/tmp/shared/items.json). The mutation test was likewise verified to fail on theAbsoluteDirectoryPathversion and pass on this one.Not included
SchemaGenerator.cs:173-175, which the issue flags as "the weakest of the four" — adopting the operator there means first typingfile.Keyas aRelativeFilePath, which is a change to the generator's key handling rather than to path resolution. Left for its own change.Verification
Locally, on the final head:
Schema.Test— 502/502tests/Schema.Editor.UITests— 190/190 (the suite that failed; run on Linux, as CI does)Schema.Cpp.Test— 104/104Schema.Editor.Test— 17/17CI is green on all three platforms, plus CodeQL, GHAS, and SonarCloud (100% coverage on new code).
🤖 Generated with Claude Code
https://claude.ai/code/session_01By7NnPN7STCZqeAftJ1BmH