fix(ir): resolve every class of ID reference a document carries - #271
Merged
Conversation
Both reference checkers build their registry map from ir.DocumentRegistries, which reads the ID-keyed maps hanging off Document. Two ID classes have no such map: an operation is declared inside the Service->OperationGroup tree and a service sits in a slice. Neither class had a registry, so every OpID and ServiceID reference in the IR resolved against nothing and was silently accepted - Operation.OverloadOf, LongRunning.PollingOperation/FinalOperation, Callback.Operations, ResourceInfo.Lifecycle/InstanceOps/CollectionOps and Service.Extends alike. ir.DeclaredIDs reads the identities a document's own nodes declare off the shared bounded walk, and ir.Registries.WithDeclarations turns the classes with no map into registries from them. Both checkers pick those up, so the defect reports under the same ir/dangling-<noun>-ref code whichever one runs. Uniqueness came only from the registry map keys, which cannot express it for a class they do not hold: two operations on one OpID made every reference to it resolve to whichever the reader reached first. checkDuplicateIDs holds every declared identity to being declared once. It is a Violation rather than a Diagnostic because an ID derives from the source pointer of its defining occurrence, so two nodes sharing one is a compiler bug. PropID stays out of both. A response declared once in components and referenced by several operations materializes into each of them, so its header property appears at several paths under one ID - the copies are the same property, and a lookup for it is unambiguous. identityClasses (irverify) classifies every named string type ir declares as an identity or not, and fails when the package grows one nobody has accounted for. A class nothing resolves against goes unchecked in silence rather than failing, which is what let these two go unnoticed.
WithDeclarations read the classes it answered for out of the declarations it was handed, so a class nothing declared got no registry at all. A site whose class has no registry is not collected, and an uncollected site is not an unresolved reference but no reference — the same silence #50 is about, reached by a different route. A Smithy resource names its lifecycle operations, so a document holding no ir.Operation still carries OpID references, and all of them went unchecked: three planted dangling OpIDs and one dangling ServiceID produced no diagnostic and no violation in either checker. The classes now come from idClasses, a bounded walk of the IR's type graph seeded with Document and each concrete TypeDef, applying the rule declaredID applies to a value: a field named ID the struct declares itself, of a named string type. A class with no map gets a registry whether or not this document fills it, so the reference is resolved rather than skipped. Truncation is the other direction. A registry derived from a walk that saw part of the document answers "not declared" for a node it never reached, so an operation buried past the cap made a valid reference to it report as dangling — a false error, where a map-keyed registry can only under-report. Both checkers now drop the declaration-derived classes when the walk truncates and leave ir/walk-truncated to say why, as checkArgsOutsideGraphQL already does with reachability it cannot trust. WithDeclarations also replaces a declaration-derived registry it is handed instead of writing into its set, so extending one call's result cannot mutate it. The PropID skip in checkDuplicateIDs stands for the case it names — a component response materializes into each operation referencing it, carrying the one ID #107 derives from the declaration — but it is wider than that reason: two different properties minted at one PropID go unreported with the copies. That is #280, and the comment now reads as the debt it is rather than a settled carve-out.
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Both of Morphic's reference checkers —
pass.Validateandirverify.Verify— decide what counts asa reference from the ID-keyed maps hanging off
ir.Document. Two ID classes have no such map: anoperation is declared inside the
Service→OperationGrouptree, and a service sits inDocument.Services, a slice. Neither class had a registry, so everyOpIDandServiceIDreference in the IR resolved against nothing and was accepted in silence —
Operation.OverloadOf,LongRunning.PollingOperation/FinalOperation,Callback.Operations,ResourceInfo.Lifecycle/InstanceOps/CollectionOps, andService.Extends.Separately, ID uniqueness came only from the registry map keys, which cannot express it for a class
they do not hold. Two operations sharing an
OpIDmade every reference to it resolve to whicheverthe reader reached first, with nothing in the document saying which that is.
#51is a direct consequence of the same walker gap — GraphQL reachability was built on the sameincomplete traversal — so the two are one change over one walk rather than two PRs editing it. The
reachability half turned out to be already fixed by the traversal consolidation in #249; this PR
adds the regression cover it never got.
What the state actually is
#50lists four classes of missed reference. Each was re-checked by building anir.Documentin Gothat plants the specific defect and running both checkers over it. Two of the four had already
been closed by #249, which replaced the four hand-written typed walkers with the single
reflection-driven
ir.WalkValues:TypeReffield classesTypeRefs, one per site the issue names → 30ir/dangling-type-refdiagnostics and 30 violationsOpIDreferences checked nowhereOpIDs → 0 diagnostics, 0 violationsOpID+ duplicatePropID→ 0 diagnostics, 0 violationspass/args-outside-graphql; removing its GraphQL binding does yield one, so the check reachesClass 3 is the one place the PR does less than the issue asks. See "The
PropIDcarve-out" below.Changes
ir.DeclaredIDsreads the identities a document's own nodes declare — a node'sIDfield — offthe same bounded walk both checkers already share. Only a struct that declares the field itself
counts: every type node embeds
TypeCommonand promotes itsID, so counting promoted fields wouldread a sound document as one where every type ID is declared twice.
ir.idClassesanswers which classes there are, from the IR's type graph rather than from onedocument's contents — a bounded walk over
reflect.Typeseeded withDocumentand each concreteTypeDef, applying to a type the same ruledeclaredIDapplies to a value. Reading the class setoff the declarations instead would leave a class no node happens to declare with no registry at
all, and a site whose class has no registry is not an unresolved reference but no reference: the
same silence, reached by a different route. A Smithy resource names its lifecycle operations, so a
document holding no
ir.Operationstill carriesOpIDreferences.ir.Registries.WithDeclarationsturns those into aRegistryfor each classDocumentholds nomap for. A class that does have a map keeps it —
irverifyalready holds every entry to being keyedby its own node's ID, so a second answer derived here could only disagree with the first. A
declaration-derived registry the receiver already carries is replaced rather than added to, so
extending one call's result cannot mutate it.
Both checkers build their registry map from
DocumentRegistries(doc).WithDeclarations(decls), soone defect reports under one
ir/dangling-<noun>-refcode whichever checker a caller runs — theconsistency
ir.RefNounexists for.Truncation is the direction a derived registry can be wrong in. A registry built from a walk
that saw part of the document answers "not declared" for a node it never reached, so an operation
buried past
ir.MaxWalkDepthwould make a valid reference to it report as dangling. That is a falseerror, where a map-keyed registry can only ever under-report. Both checkers drop the
declaration-derived classes when the declaration walk truncates and leave
ir/walk-truncatedto saywhy nothing is claimed for them, as
checkArgsOutsideGraphQLalready does with reachability itcannot trust.
irverify.checkDuplicateIDsholds every declared identity to being declared once, reportingir/duplicate-<noun>-idfor each declaration after the first in walk order. It is aViolationrather than an
ir.Diagnosticbecause an ID derives from the source pointer of its definingoccurrence: two nodes sharing one means a compiler minted the same pointer twice, which is our bug,
not something a spec author wrote or can fix.
irverify.identityClassesis the drift guard. It classifies every named string type theirpackage declares as an identity or not, and says for each identity what resolves references to it and
what holds it unique.
TestIdentityClasses_AreAllClassifiedfails whenirgrows a named stringtype nobody has accounted for; this is the string-typed counterpart to the existing
integerFieldsguard, which does the same job for index references.
The
PropIDcarve-out#50asks for two properties sharing aPropIDto be reported. Running that check over the existingcorpus showed the premise does not hold for the common case:
testdata/conformance/openapi/component-reuse.yamldeclarescomponents/responses/Listedonce and$refs it from three operations. Responses are embedded by value rather than interned, so the headerproperty that response declares materializes at three paths under the one
PropIDits definingoccurrence derives. That the ID stays the declaration's rather than the use site's is what #107
fixed, so the repeat is invariant 3 holding rather than breaking: the three are the same property,
a lookup for that ID is unambiguous, and reporting it would fail every document that reuses a
component.
The skip is nonetheless wider than that reason, and is deliberately provisional. Two genuinely
different properties minted at one
PropIDgo unreported with the copies, which is the same defectthis check exists for. Telling them apart needs a fingerprint of the node rather than its ID alone;
that is filed as #280, and
checkDuplicateIDsand theidentityClassesentry both name it sothe exemption reads as the debt it is.
TestCheckDuplicateIDs_RepeatedPropIDIsCleanpins the casethat is settled.
PropIDalso stays out ofWithDeclarations:pass.Validatealready resolves those referencesagainst the properties a document declares (
checkPropIDRefs) and against the parts of the model acontent names (
checkEncodingKeys), and adding a document-wide answer would report one defect twiceunder two codes.
#51
graphqlReachableTypesnow runs on the reflection walk, so it reachesResponseStream.Events,Property.Argsparameter types and template instantiation arguments — the three sites #51 names.This PR adds
TestValidate_GraphQLReachabilityFollowsEveryReference, which drives all three and, foreach, asserts both halves: no diagnostic with the GraphQL binding present, and a diagnostic once it is
removed. Without the second half the case would pass on a reachability walk that reaches nothing at
all. Two test comments naming the deleted
walkModelRefs/walkOperationRefs/walkPayloadRefswalkers are corrected while in the file.
Test plan
Gate run in the CI order:
gofmtclean,go vetclean,golangci-lint0 issues,go buildclean,./scripts/check-coverage.shat 100%.Each new behavioural assertion was proved able to fail by planting the defect back and watching it go
red. Mutations planted and killed, each named at the site it was planted:
checkDanglingRefs(pass) drops.WithDeclarationsTestValidate_DanglingTypedIDRef— all 8 new subtests,…_DanglingOpRefWithNoOperationDeclared, andTestValidate_TypedIDDiagnosticOrderIsDeterministiccheckReferentialIntegrity(irverify) drops.WithDeclarationsTestCheckReferentialIntegrity_DanglingOpRef,…_DanglingServiceRef,…_DanglingOpRefWithNoOperationDeclaredWithDeclarationstakes its classes from the declarations againTestWithDeclarations_ResolvesAClassNothingDeclares, and the…WithNoOperationDeclaredcase in both checkersTestCheckDanglingRefs_TruncatedWalkClaimsNoDeclarations,TestCheckReferentialIntegrity_TruncatedWalkClaimsNoDeclarationscheckDuplicateIDsremoved fromwalkChecksTestVerify_ReportsDuplicateIDs,TestWalkChecks_NoWalkDropsItsTruncationFlagcheckDuplicateIDsdrops thePropIDskipTestCheckDuplicateIDs_RepeatedPropIDIsClean,TestVerify_Corpus/component-reuse.yaml,TestVerify_EngineOutput,TestHarness_InRepoCorpusdeclaredIDdrops thelen(f.Index) != 1promoted-field guardTestDeclaredIDs_ReachesEveryIDBearingNodeOnce, plus the whole irverify corpusWithDeclarationsmutates its receiver instead of copyingTestWithDeclarations_LeavesTheReceiverAlone,…_LeavesAnEarlierResultAloneWithDeclarationswrites into a set an earlier call handed outTestWithDeclarations_LeavesAnEarlierResultAloneidentityClassesTestIdentityClasses_AreAllClassifiedgraphqlReachableTypesseeds fromop.Params/op.Responsesinstead ofop…FollowsEveryReference/subscription_response_streamappendTypeIDsdrops sites under.Argsand.Instantiation…FollowsEveryReference/nested_field-argument_type,…/template_instantiation_argumentTestIdentityClasses_AreAllClassifiedwas also driven the other way, by adding atype FooID stringto
irand confirming it fails on a class nobody has accounted for.The new
OpIDcheck was confirmed to reach live compiler output rather than only syntheticdocuments: compiling
testdata/conformance/openapi/component-reuse.yamlemits aCallback.Operationsentry(
op/openapi/paths/~1orders/post/callbacks/onShipped/{$request.body#~1callbackUrl}/post), which thenew registry resolves, and the corpus stays clean.
No IR shape changed, so
ir.IRVersionis unmoved.Closes #50
Closes #51