Attribute Go template output against shipped compiler export data - #8545
Merged
knutwannheden merged 4 commits intoAug 21, 2026
Merged
Conversation
knutwannheden
force-pushed
the
explore-shipped-export-data-for-go-templates
branch
2 times, most recently
from
August 19, 2026 09:53
4426cb9 to
7046bc5
Compare
A template is type-checked as a file belonging to no module, against whatever importer.Default() can load, so anything outside the stdlib resolves to nothing and the emitted tree carries no types. A recipe module can now carry the export data for the packages its templates import. cmd/goexportdata generates it, keeping only the __.PKGDEF member so a blob is a few percent of the archive go build leaves behind (encoding/json/v2: 4.7 MB -> 170 KB), and emits an embed.FS shim. pkg/exportdata resolves from those blobs first and falls back to the toolchain, so a blob a newer toolchain cannot read costs attribution and nothing else. Each template builds one importer and reuses it across every Apply. Decoding dominated the call before: Apply against a 99 KB blob was 788,804 ns/op, of which 768,596 was the decode, and is now 20,642. RemoveUnusedImports kept a superseded import alive whenever another import bound the same qualifier, which is the shape a major-version package move takes once both sides are attributed. The qualifier fallback now yields to attribution where attribution has accounted for that name, and still keeps everything where nothing is attributed.
knutwannheden
force-pushed
the
explore-shipped-export-data-for-go-templates
branch
from
August 19, 2026 10:37
7046bc5 to
63cd778
Compare
…t-data-for-go-templates # Conflicts: # rewrite-go/pkg/recipe/golang/internal/imports.go # rewrite-go/pkg/recipe/golang/internal/imports_test.go
…t-data-for-go-templates
knutwannheden
force-pushed
the
explore-shipped-export-data-for-go-templates
branch
from
August 20, 2026 11:10
ab75715 to
d7f256b
Compare
knutwannheden
marked this pull request as ready for review
August 20, 2026 11:19
…t-data-for-go-templates
knutwannheden
force-pushed
the
explore-shipped-export-data-for-go-templates
branch
from
August 21, 2026 12:59
010bf12 to
87bfe5f
Compare
This was referenced Aug 21, 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.
A Go template is parsed by building a synthetic file and type-checking it. That file belongs to no module, so the parser uses
importer.Default(), which resolves the stdlib and nothing else.ExpressionTemplate("errors.Wrap(err, msg)").Imports("github.com/pkg/errors")yields aMethodInvocationwithMethodType == nil—matcher.IsResolvedfalse — and every recipe that later reads that output sees an unattributed tree. The packages this hurts most are the ones a build machine often cannot fetch at all:encoding/json/v2andencoding/json/jsontextare behindGOEXPERIMENT=jsonv2, and a machine with no module proxy access cannot resolve a third-party path either.A recipe module can now carry the compiler export data for the packages its templates import:
Against those blobs
jsontext.WithIndent(" ")maps to a resolvedMethodTypewith parameter types and a return type ofencoding/json/internal/jsonopts.Options; without them it isMethodType == nil. No network and nogo listat type-check time.How the blobs are read
go list -exportemits a full.aarchive including object code. Keeping only its__.PKGDEFmember takesencoding/json/v2from 4.7 MB to 170 KB andgithub.com/pkg/errorsfrom 205 KB to 10.6 KB; unpacked this would not be shippable. Blobs are not pinned to the machine that produced them — I rewrote the recorded GOOS/GOARCH tolinux amd64and the version togo1.19andgo1.99, and all still imported, because only the binary export format is checked on read.Two findings shaped the implementation. A non-nil
lookuptakesgo/importeroff its default search entirely — with a blob-only lookup,strings.Containswent unresolved — soImportermust chain toimporter.Default(), not replace it. And a package's own dependencies need no blobs of their own: importing a blob whose API mentionstime.Timeasked the lookup for exactly one path, andtime.Time.Year()still resolved.Why
ExportDatais per-template, not per-recipeThe obvious shape is one recipe-level option covering before and after. That is wrong here:
patternComparator.matchPropertiescomparesIdentifier.NameandMethodInvocationselect/name/arguments and reads no types at all, so the two sides answer to different things. OnWithAfterexport data decides the types the emitted code carries, which is what letsRemoveUnusedImportstell a superseded import from a live one. OnWithBeforeit only bears on shape:atomic.Pointer[int]ParameterizedTypeParameterizedTypeMap[int](1)MethodInvocation{TypeParameters}MethodInvocation{Select: ArrayAccess}So it belongs on a before-pattern only when the source is parsed the same way. It is a
BeforeOptionon both, doing different work on each.Sets are unioned into one
go/importerrather than one importer per set, so a package two sets both reference resolves to a single type instead of a copy per set.Decoding export data dominates template application, so each template builds one importer and reuses it across every
Apply: 788,804 → 20,642 ns/op against a 99 KB blob, of which the decode itself is 768,596 ns cold and 50.84 ns reused. That importer is shared, so it is mutex-guarded andTestTemplateIsSafeToApplyConcurrentlyruns under-race. Note this also speeds up templates that ship no export data at all (463,360 → 89,812 ns/op), becauseApplypreviously rebuiltimporter.Default()every call — a behaviour change outside this feature's lane, called out here rather than buried.Tests
TestExportDataGivesTemplateOutputRealSignaturespins resolved parameter and return types;TestUnreadableExportDataDegradesToNoExportDatapins that a stale blob lands exactly where no blob does.TestExportDataIsDecodedOncePerTemplatecounts blob opens through a wrappingfs.FSand would catch a regression that reintroduces per-Applydecoding.TestGenerateKeepsTheOldBlobsWhenAPathFailscovers regeneration atomicity, andTestGenerateIgnoresProgressOnStderruses agoshim onPATHthat writes to stderr first — without it,go listprogress output is concatenated with the archive path on any cold module cache, which is the normal case for the machine that generates blobs. Fixtures are compiled at test time rather than committed, so they always match the running toolchain.What this does not fix
A
SourceImportspackage swap under a preserved qualifier needs the after-template attributed. Where it is not — no export data, or a blob this toolchain cannot read — both imports are emitted and bind the same name, and the file does not compile.TestSwapWithoutAttributionLeavesBothImportspins both routes to that state. The same output appears when any un-rewritten reference to the old package survives, since a template rewrites one expression while the swap is a whole-file decision;TestSwapLeavesBothImportsWhenACallIsNotRewrittencovers that. Only a recipe with established whole-file coverage may swap this way, andSourceImportssays so.Two fixes outside this feature are folded in because a review of this branch surfaced them and they are two lines each:
MatchResult.Gettreats a typed-nil binding as unbound, since binding the nil result of a helper returning a concrete type leaves a non-nil interface that substitution dereferences into a panic (TestInstantiateTreatsATypedNilBindingAsUnbound), andinstantiate_test.gogets the copyright year every other file in the change carries. Say the word if you would rather they went separately.Three fixes this work surfaced were split out and have already landed on their own:
PackageNamefor semantically versioned import paths (#8548), gofmt group-separator placement in import add/remove (#8549), and the qualifier fallback yielding to type attribution (#8555). This branch is now only the feature.