Skip to content

Compare semantic strings by value and expose path values on interfaces - #140

Merged
matt-edmondson merged 3 commits into
mainfrom
fix/semantic-string-comparison
Jul 30, 2026
Merged

Compare semantic strings by value and expose path values on interfaces#140
matt-edmondson merged 3 commits into
mainfrom
fix/semantic-string-comparison

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

The bug

SemanticString<T>.CompareTo(object) forwarded straight to string.CompareTo(object), which accepts only a System.String:

public int CompareTo(object? value) => WeakString.CompareTo(value: value);

Sorting a collection typed as an interface that carries no IComparable<T> — such as IPath — makes LINQ and Comparer<T>.Default fall back to that non-generic overload and hand it another semantic string, not a string. Result:

System.InvalidOperationException: Failed to compare two elements in the array.
 ---> System.ArgumentException: Object must be of type String.
   at System.String.CompareTo(Object value)
   at ktsu.Semantics.Strings.SemanticString`1.CompareTo(Object value)

Reported downstream as ktsu-dev/ImGuiApp#273, where it crashed every file dialog as soon as the listed directory held two entries of the same kind. Not platform-specific despite the report title — it reproduces anywhere.

CompareTo(object) now unwraps ISemanticString and compares underlying values. null still sorts first and unsupported types still throw ArgumentException.

Why the sort reached the boxing path at all

IPath, IFileName, IFileExtension and IDirectoryName were empty marker interfaces. Since SemanticDirectoryPath.GetContents() returns IEnumerable<IPath>, the library handed consumers a type from which the path value could not be recovered without a downcast. That is what the downstream code had to write:

AbsoluteDirectoryPath? directory = path as AbsoluteDirectoryPath;
AbsoluteFilePath? file = path as AbsoluteFilePath;
string displayPath = directory?.WeakString ?? file?.WeakString ?? string.Empty;
  • All four interfaces now expose WeakString. Every implementation already had it via SemanticString<TDerived>, so no implementation changed. ToString() was the only prior option and returns string?, costing every call site a !.
  • IPath declares IComparable<IPath>, so Comparer<IPath>.Default resolves to GenericComparer instead of ObjectComparer and never boxes into the non-generic path. SemanticPath<TDerived> implements it explicitly — a public overload taking IPath would make pathA.CompareTo(pathB) ambiguous against the inherited overload taking ISemanticString, because a concrete path satisfies both parameter types and neither is more specific. There's a test whose failure to compile is the regression signal for that.

Build fix

.gitattributes gains *.cs text eol=crlf to match .editorconfig, mirroring ktsu-dev/ImGuiApp@dd44580. Without it a Linux checkout produces LF working files that violate end_of_line = crlf and the repo cannot build at all — 1711 IDE0055 errors before a single line of real work.

Tests

16 new tests: CompareTo(object) with semantic strings and unsupported types, sorting via OrderBy/ThenBy/Comparer<T>.Default/List<IPath>.Sort(), WeakString through each interface, GetContents() read without downcasting, and the comparer-selection and no-ambiguity guards. Reverting the CompareTo fix fails 5 of them.

977 passed, 93 failed — the 93 are pre-existing and unrelated: path tests hard-coding Windows separators and C:\ roots, which cannot pass on Linux. Verified by stashing this branch and re-running on a clean tree: identical 93. Baseline was 961 passing.

Packs clean (Semantics.Paths sets EnablePackageValidation=false, so there's no baseline API check to trip).

Note on the version tag

Tagged [minor]. Adding members to a public interface is a source break for anyone implementing these interfaces outside the library — though no public API accepts an IPath (or any descendant) as a parameter, so an external implementation has nothing to plug into today. If you'd rather treat interface-member additions as major, retag before merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WFRwYRHhEmVMGvrH2BbtbW

matt-edmondson and others added 3 commits July 30, 2026 14:15
…nterfaces

SemanticString<T>.CompareTo(object) forwarded straight to string.CompareTo(object),
which only accepts a System.String. Sorting a collection typed as an interface that
carries no IComparable<T> — such as IPath — makes LINQ and Comparer<T>.Default fall
back to that non-generic overload and hand it another semantic string, so the sort
threw ArgumentException: "Object must be of type String." Reported downstream as
ktsu-dev/ImGuiApp#273, where it crashed every file dialog on a directory listing.

CompareTo(object) now unwraps ISemanticString and compares underlying values. Null
still sorts first and unsupported types still throw ArgumentException.

Also addresses why that sort reached the boxing path at all:

- IPath, IFileName, IFileExtension and IDirectoryName were empty marker interfaces,
  so code holding the IEnumerable<IPath> returned by GetContents() could not read a
  path's value without downcasting to a concrete type. All four now expose WeakString,
  which every implementation already had via SemanticString<TDerived>.
- IPath declares IComparable<IPath>, so Comparer<IPath>.Default now sorts through the
  generic comparison path. SemanticPath<TDerived> implements it explicitly: a public
  overload taking IPath would make pathA.CompareTo(pathB) ambiguous against the
  inherited overload taking ISemanticString, since a concrete path satisfies both
  parameter types and neither is more specific.

Adds *.cs text eol=crlf to .gitattributes to match .editorconfig, mirroring
ktsu-dev/ImGuiApp@dd44580. Without it a Linux checkout gets LF working files that
violate end_of_line = crlf, and the repo cannot build at all — 1711 IDE0055 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFRwYRHhEmVMGvrH2BbtbW
The generators wrote through ktsu.CodeBlocker, which uses IndentedTextWriter and
therefore Environment.NewLine, so identical metadata produced CRLF output on Windows
and LF output on Linux. Generator output under Semantics.Quantities/Generated/ is
committed and CI-verified, so it has to be host-independent. All 12 AddSource call
sites now go through GeneratedSource.Add, which normalizes to CRLF — matching
end_of_line = crlf and the *.cs eol=crlf attribute. Regenerating from scratch
produces all 226 files as CRLF with no content change.

Semantics.Paths, Semantics.Strings and Semantics.Quantities each disabled package
validation because ktsu.Sdk 2.11.0 turned on strict ApiCompat, whose
compatible-frameworks check cannot accept the by-design per-TFM surface differences
that Polyfill shims produce. ktsu.Sdk 2.15.0 — what this repo pins — already leaves
EnableStrictModeForCompatibleFrameworksInPackage and EnableStrictModeForCompatibleTfms
off for exactly that reason, and directs the residual diffs to a regenerable
CompatibilitySuppressions.xml. So validation is back on in all three, with
suppressions regenerated via -p:ApiCompatGenerateSuppressionFile=true.

The regenerated suppression files are 16978 lines smaller: the old ones were dominated
by entries for net5.0/net6.0/net7.0 assets this package no longer ships. What remains
is real and per-TFM by design — CP0005 on record {Clone}$ members, CP0014/CP0015 on
Polyfill shim attributes, CP0006 on the netstandard2.1-only FromReadOnlySpan.

Note that PackageValidationBaselineVersion remains unset, so this gate covers
cross-TFM consistency within a package but does not yet compare against the published
version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFRwYRHhEmVMGvrH2BbtbW
The tests I added hard-coded Unix paths, so on Windows CI every one of them threw
ArgumentException: Cannot convert "\tmp\afile" to AbsoluteFilePath — /tmp/afile
canonicalizes to \tmp\afile, which is not absolute without a drive or UNC prefix.
That is the same defect as the suite's pre-existing Linux failures, just mirrored.

Paths are now built from Path.GetPathRoot(Path.GetTempPath()), giving C:\ on Windows
and / elsewhere, and the expected values are derived from the same helper rather than
written as literals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFRwYRHhEmVMGvrH2BbtbW
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
30.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@matt-edmondson
matt-edmondson merged commit 40133b8 into main Jul 30, 2026
5 of 6 checks passed
@matt-edmondson
matt-edmondson deleted the fix/semantic-string-comparison branch July 30, 2026 07:17
matt-edmondson added a commit to ktsu-dev/ImGuiApp that referenced this pull request Jul 30, 2026
2.8.0 fixes SemanticString<T>.CompareTo(object) so it unwraps ISemanticString rather
than handing another semantic string to string.CompareTo(object) (ktsu-dev/Semantics#140),
and exposes WeakString on IPath, IFileName, IFileExtension and IDirectoryName.

Nothing here was required by the upgrade — ImGuiApp implements none of those interfaces,
so the added members break nothing, and the build passes untouched against 2.8.0. These
are the simplifications the new surface allows:

- DrawContentRow read the display path by testing for two concrete types and falling
  back to string.Empty, a branch that could never legitimately be reached. IAbsolutePath
  now carries WeakString directly.
- SortContents and its tests used ToString(), which returns string? and cost a
  null-forgiving operator at every call site.

SortContents still sorts on the string value rather than the entry. The IComparable<IPath>
that 2.8.0 declares only helps collections typed as IPath exactly; CurrentContents is a
Collection<IAbsolutePath>, which implements IComparable<T> of neither itself nor IPath, so
sorting by the entry would still box through the non-generic CompareTo(object). Sorting on
the string value also keeps case-insensitive display order.

All four Semantics packages move together since that repo versions in lockstep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFRwYRHhEmVMGvrH2BbtbW
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant