Skip to content

refactor(irverify): read declarations once per Verify run - #290

Merged
OmarAlJarrah merged 4 commits into
mainfrom
fix/ir-declaration-walk-reuse
Aug 6, 2026
Merged

refactor(irverify): read declarations once per Verify run#290
OmarAlJarrah merged 4 commits into
mainfrom
fix/ir-declaration-walk-reuse

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #271, which added reference resolution and uniqueness checking for the two ID classes
Document keys no map by. Two things it left: irverify.Verify walked the whole document twice to
build the same list of declarations, and four comments describe a state that change moved past.

One walk instead of two. checkReferentialIntegrity and checkDuplicateIDs each called
ir.DeclaredIDs, which is a full bounded walk. runWalkChecks now reads the declarations once and
hands them down. On a 500-operation synthetic document, Verify drops from ~27.3ms to ~23.4ms —
about 14%, and the ~4.1ms a single DeclaredIDs costs on that shape. pass.Validate is unchanged
at ~12.4ms: its second walk is not the same redundancy, because the reference walk needs the
registries the declarations build, so it genuinely cannot run first.

The four checks that do not read the declarations take the parameter anyway. One signature is what
lets walkChecks be a list, and the drift guard in walkchecks_test.go keys on the result types,
which are unchanged — every check still returns ([]Violation, bool) and still has to be listed.

checkDuplicateIDs no longer walks for itself, so "each returns whether its own walk was cut short"
stopped being true of it. It passes on the flag of the walk its input rests on, and both the
walkChecks contract and its own doc comment now say that.

The seed in runWalkChecks is deliberately redundant. truncated starts at decls.truncated
rather than false. Both checks that read the declarations return that flag too, so seeding from
false reports the same thing today — planting that mutation leaves the suite green, and the
comment says so rather than leaving the line looking load-bearing. It is written this way because
the declaration walk now runs in runWalkChecks, and a function that walks owning its own flag is
the whole point of #55: relying on a callee to hand back the flag for a walk this function performed
is the same dependence on a coincidence.

Comment corrections

ir.DeclaredIDs overstated its own coverage. It said a node carrying an empty ID is "reported
where the node's registry key is". That holds for the classes Document keys a map by —
checkRegistryKeys reads an empty or disagreeing key — and not for OpID and ServiceID, the two
#271 added, which have no key. Probed:

document reported
two operations, both ID: "" nothing (only the unrelated ir/naming-absent)
two services, both ID: "" nothing (only ir/naming-absent)
a channel with ID: "" under key c/x ir/channel-id-mismatch

Skipping empty IDs is right and unchanged — nothing can reference one, and calling several of them
duplicates of each other would name the wrong defect. Only the justification was wrong, in the one
place a reader goes to find out whether the case is handled. Filed as #289; the comment now
states the gap.

Three comments in validate_idrefs_test.go describe a smaller set than the file now holds.
idRefSite said the set is "every field whose type mentions ChannelID, MessageID or AuthID" and
listed the own-ID exclusions as "(Channel.ID, Message.ID, AuthScheme.ID)";
TestValidate_DanglingTypedIDRef said it plants "one dangling channel, message or auth reference";
and the comment directly above sortedIDRefPointers said "across all three reference classes" while
the literal beneath it gained six entries in the same change. Each enumeration is replaced by the
rule that derives it, so the next class to arrive cannot leave them wrong — which is the property
the set itself already has and its description did not.

identityClasses omits a check it was written beside. The map says, per identity, what resolves
references to it and what holds it unique; the TypeID entry named only checkRegistryKeys.
checkDuplicateIDs holds it too, which is exactly what
TestCheckDuplicateIDs_TwoRegistryEntriesOnOneNodeID, in the same file, exists to show. The three
entries reading "resolved and held as TypeID is" inherit the correction.

Test plan

Gate run in CI order, each exit code read directly rather than through a pipe: gofmt clean,
go vet 0, golangci-lint 0, go build 0, ./scripts/check-coverage.sh 0 at 100%
(4793/4793 statements).

Output equivalence. A refactor that preserves behaviour has to be shown to, not asserted. Both
revisions were run over the same 14 hand-built defective documents — duplicate op, triple op,
dangling op, empty op ID, empty op reference, duplicate service, dangling service, repeated PropID,
duplicate type ID, a document that truncates the walk, one that truncates and dangles, a nil
registry entry, a resource naming a ghost operation, and an empty document — plus every spec in
testdata, dumping every violation's code, path and message. The two dumps are byte-for-byte
identical, over 52 violations spanning 11 codes including all of ir/duplicate-op-id,
ir/duplicate-service-id, ir/duplicate-type-id, ir/dangling-op-ref, ir/dangling-service-ref
and ir/walk-truncated.

Mutation coverage. Every guard that held the behaviour before the signatures moved, plus every
line this change adds, was planted back:

Mutation Went red
checkReferentialIntegrity drops .WithDeclarations TestCheckReferentialIntegrity_DanglingOpRef, …_DanglingServiceRef, …_DanglingOpRefWithNoOperationDeclared
checkReferentialIntegrity trusts a truncated declaration walk TestCheckReferentialIntegrity_TruncatedWalkClaimsNoDeclarations
checkDuplicateIDs drops the PropID skip TestCheckDuplicateIDs_RepeatedPropIDIsClean, TestVerify_Corpus/component-reuse.yaml, TestVerify_EngineOutput
checkDuplicateIDs removed from walkChecks TestVerify_ReportsDuplicateIDs, TestWalkChecks_NoWalkDropsItsTruncationFlag
one entry deleted from identityClasses TestIdentityClasses_AreAllClassified
readDeclarations drops the truncation flag TestWalkChecks_EachReportsTruncation, TestCheckReferentialIntegrity_TruncatedWalkClaimsNoDeclarations
checkDuplicateIDs hardcodes truncated = true TestVerify_CleanDocHasNoViolations and every other clean-document case
checkDuplicateIDs hardcodes truncated = false TestWalkChecks_EachReportsTruncation
runWalkChecks hands the checks a zero declarations TestVerify_ReportsDuplicateIDs, TestVerify_ValidRawConfigIsClean and others
runWalkChecks seeds truncated from false nothing — deliberate, documented at the line
checkReferentialIntegrity drops decls.truncated from its fold nothing — deliberate, documented at the line; the same mutation also survives on main, so this is inherited rather than introduced here

The two surviving mutations are the two defence-in-depth lines. Both are kept for the reason #55
records — a walk's own flag must not depend on a sibling walk happening to trip the cap first — and
both now say at the line that they are redundant today, so neither reads as load-bearing.

Benchmarks are a scratch harness rather than a committed one; #78 is where committed benchmarks for
compile, marshal and validate belong, and adding one here would be a different change.

Related: #271, #289 (filed, not closed here).

checkReferentialIntegrity and checkDuplicateIDs each called ir.DeclaredIDs,
so Verify walked the whole document twice to build the same slice. On a
500-operation document that was ~4ms of ~27ms, a sixth of the run.

runWalkChecks reads the declarations once and hands them to every walking
check. The four that do not need them take the parameter anyway: one
signature is what lets walkChecks be a list, and the drift guard in
walkchecks_test.go keys on the result types, which are unchanged.

checkDuplicateIDs no longer walks for itself, so it passes on the flag of
the walk its input rests on. Both the walkChecks contract and its own doc
comment now say that rather than "its own walk".
DeclaredIDs said a node carrying an empty ID is "reported where the node's
registry key is". That holds for the classes Document keys a map by and not
for the two the same change added, which have no key: an operation or a
service with an empty ID is reported by nothing. Filed as #289; the comment
now states the gap instead of asserting it away.

Three comments in validate_idrefs_test.go describe a set that has since
grown. idRefSite named the classes it covers, TestValidate_DanglingTypedIDRef
said it plants "channel, message or auth" references, and the location order
above sortedIDRefPointers said "all three reference classes" while the
literal below it gained six entries. The enumerations are replaced with the
rule that derives them, so the next class to arrive cannot leave them wrong.

identityClasses says per identity what holds it unique, and the TypeID entry
named only checkRegistryKeys. checkDuplicateIDs holds it too, which is what
TestCheckDuplicateIDs_TwoRegistryEntriesOnOneNodeID exists to show.
Two things a reader can only find by planting a mutation, so both are now
written down beside the code.

checkReferentialIntegrity folds decls.truncated into the flag it returns
beside collectRefs' own. Neither of those walks prunes, so they truncate
together and dropping either half is invisible to the suite. The fold stays
for the reason the seed in runWalkChecks does, and now says so.

The declarations zero value is not "nothing worth mentioning": it says the
document declares no identity, which makes every OpID and ServiceID
reference in it report as dangling. The suite does catch a check being
handed one, but the type cannot, so the doc comment names it.
The declarations comment gave the saving as a fraction of Verify's cost,
which is a figure that goes stale as the other checks change; the reason
that cannot drift is that it was the same walk twice, and the measurement
belongs in the pull request rather than the source.

readDeclarations said it reads the identities "once for the run", which
describes what runWalkChecks does with it and reads as though the function
memoizes. It does not, and the tests call it per case.
@OmarAlJarrah
OmarAlJarrah merged commit b97aabb into main Aug 6, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/ir-declaration-walk-reuse branch August 6, 2026 11:13
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