Skip to content

Take the UID registry out of callers' hands, and stop building it twice. - #84

Merged
xbmlz merged 1 commit into
mainfrom
feat/uid-encapsulation
Aug 26, 2026
Merged

xbmlz merged 1 commit into
mainfrom
feat/uid-encapsulation

Conversation

@xbmlz

@xbmlz xbmlz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The defect

uid exported three package-level maps: Dictionary (496 registered UIDs with their PS3.6 metadata), KeywordToUID (494 keyword → UID), and Known (one Info per registered UID, filled by a package init). Read-only by convention, mutable by type, no synchronisation of any kind.

uid.Known[uid.ImplicitVRLittleEndian] = someOtherInfo

That is one assignment, from anywhere in any dependency, and every implicit-VR file in the process decodes as explicit from then on. Same defect #81 fixed for the data dictionary — this time in the table that decides how bytes are read.

The change

Approved API shape: uid.Lookup + uid.LookupKeyword.

// UID -> what the registry records, plus the encoding facts that follow from it
info, ok := uid.Lookup(uid.CTImageStorage)

// keyword -> UID (the direction the old Lookup went)
u, ok := uid.LookupKeyword("CTImageStorage")
  • Lookup(u UID) (Info, bool) is what replaces the maps for readers. Info is a strict superset of a dictionary entry plus the four derived transfer-syntax flags, and it is exactly what the three internal call sites already wanted — DecodeDatasetContext, determineWriteEncoding and EncodeDataset all read IsTransferSyntax / IsImplicitVR / IsLittleEndian off it. It returns a copy, so the table is not reachable through the result.
  • LookupKeyword(keyword string) (UID, bool) is new, and is the old Lookup's job under a name that says which direction it goes.
  • Dictionary, KeywordToUID and DictEntry are unexported — in generate_uid_dict.py as well as in the generated file, so a regeneration cannot quietly export them again.

Two deviations from a literal "unexport everything"

  1. Known is deleted, not unexported. Every field of an Info is either read straight from the dictionary entry or derived from the UID in three comparisons, so Lookup derives them on demand. That is one table where there were two, nothing to keep in step, and no init work — 496 Info values built at program start — for a program that never looks a UID up.
  2. The root aliases godicom.UIDDictionary and godicom.KnownUIDs are deleted, not unexported. An alias to another package's identifier cannot be unexported, and there is nothing left for them to name. godicom.UIDInfo and godicom.LookupUID are unchanged.

Both are stated in the CHANGELOG.

Evidence

Three mutations, each restored and re-verified:

mutation fails
info.IsImplicitVR = false TestLookup + 8 root-package tests
unregistered UID answered true TestLookup, TestDetermineEncodingPrivateTransferSyntaxRequiresArgs
Keyword: "" TestLookup, TestLookupCoversEveryEntry, TestLookupKeyword

The first found a real gap — nothing had pinned the implicit-VR true case, only the false ones — which TestLookup now covers.

TestLookupCoversEveryEntry walks the whole table and checks each entry resolves with every field intact, which is the invariant len(Known) == len(Dictionary) used to state. TestLookupReturnsCopy mutates the returned Info and checks the registry and UID.Name() are untouched, so a future memoising *Info cache cannot regress the point of the change.

Local gate green: build, go vet, gofmt, staticcheck -checks=all, go test ./... on amd64 and under GOARCH=386 CGO_ENABLED=0, all eight cross-vet targets. Lookup, LookupKeyword and entry at 100%; ExtraInfo/IsRetired lifted 66.7% → 100%.

Downstream

Breaking, which 0.x allows. Within the organisation the only affected code is two lines in one gonetdicom test — ae/storage_sop_classes_test.go:35 and :121 — recorded in the CHANGELOG for whoever bumps the module there. No non-test code anywhere in the org touched the maps.

🤖 Generated with Claude Code

The uid package exported three maps: Dictionary (496 registered UIDs and
their PS3.6 metadata), KeywordToUID (494 keyword -> UID), and Known (an
Info per registered UID, built by a package init). All three were
read-only by convention and mutable by type, with no synchronisation of
any kind. `uid.Known[uid.ImplicitVRLittleEndian] = someOtherInfo` was one
assignment away from making every implicit-VR file in the process decode
as explicit, from anywhere in any dependency, and nothing in the package
could have noticed. This is the same defect the data dictionary had
before #81, in the table that decides how bytes are read.

Read access is now two accessors. Lookup takes a UID and returns Info,
which is what the three internal call sites already wanted --
DecodeDatasetContext, determineWriteEncoding, EncodeDataset all read
IsTransferSyntax, IsImplicitVR and IsLittleEndian off it -- and it
returns a copy, so the table cannot be reached through the result.
LookupKeyword takes a keyword and returns a UID, which is the direction
the old Lookup went, under a name that says which direction that is.
Dictionary, KeywordToUID and DictEntry become unexported, in the
generator as well as the generated file, so a regeneration cannot
quietly export them again.

Known is deleted rather than unexported. It was a second copy of the
dictionary: every field in an Info is either read straight from the
dictionary entry or derived from the UID in three comparisons, so Lookup
derives them on demand instead. That leaves one table where there were
two, nothing to keep in step, and no init work for a program that never
looks a UID up. The root aliases UIDDictionary and KnownUIDs are deleted
too -- an alias to another package's identifier cannot be unexported, and
there is nothing left for them to name. UIDInfo and LookupUID stay.

Verified by mutation, each restored and re-checked: forcing IsImplicitVR
false fails TestLookup and eight root tests; answering true for an
unregistered UID fails TestLookup and
TestDetermineEncodingPrivateTransferSyntaxRequiresArgs; dropping Keyword
fails TestLookup, TestLookupCoversEveryEntry and TestLookupKeyword. The
first of those found a real gap -- nothing had pinned the implicit-VR
true case -- which TestLookup now covers. Full local gate green: build,
vet, gofmt, staticcheck -checks=all, tests on amd64 and GOARCH=386, all
eight cross-vet targets. Lookup, LookupKeyword and entry are at 100%
coverage, and ExtraInfo/IsRetired go from 66.7% to 100%.

Within the organisation the only affected code is two lines in one
gonetdicom test, ae/storage_sop_classes_test.go:35 and :121, recorded in
the CHANGELOG for whoever bumps the module there.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@xbmlz
xbmlz merged commit bcdbe04 into main Aug 26, 2026
6 checks passed
@xbmlz
xbmlz deleted the feat/uid-encapsulation branch August 26, 2026 03:07
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