fix: align unexported package member diagnostics - #2265
Conversation
There was a problem hiding this comment.
Review summary
Rewrites the go/types diagnostic name X not exported by package … into the gc-style undefined: pkg.X so the go1.26 errorcheck case runtime.go passes, and drops the corresponding xfail.yaml entry. The mechanism is sound: keying by err.Pos (== sel.Sel.Pos()) is exact and collision-free, closure-capture ordering is safe (the map is populated before NewChecker(...).Files(...) runs), and the message-prefix guard makes an unintended rewrite effectively impossible since that message is package-exclusive in go/types.
One correctness concern worth addressing (inline), plus a minor efficiency note below.
Minor: eager, unconditional AST walk on the per-package load path
internal/packages/load.go (~line 373): the ast.Inspect walk and map[token.Pos]string are built for every package on every load, even when the package type-checks cleanly and no message ever needs rewriting. It also records all ident-qualified selectors (v.Field, x.Method, …), not just import-qualified ones, so the map is larger than the single use case requires. This is pure overhead for the common (no-error) case. Since it runs once per package before type-checking, the cost is likely modest relative to type-checking itself — but building the map lazily inside the types.Error branch (only when the target message is actually seen), or restricting insertion to import-qualified selectors, would eliminate it. Non-blocking.
There was a problem hiding this comment.
Review: link-time DCE for ABI method slots + per-package frontend options
Thanks for this — the DCE analysis is well-structured, the cl.Options refactor that replaces the process-wide Enable* globals is clean and consistently threaded, the sync.Once around llssa.Initialize is safe (init flags are a compile-time constant), and the RewriteMainPrefix removal left no dangling references. Comprehensive tests accompany the new packages.
Findings below, ordered by severity. The correctness-class items (a live method being pruned, or a panic on valid input) matter most because they surface only under -deadcodedrop.
Correctness / robustness
-
internal/dcepass/dcepass.go:147—panicon an unsupported global value is reachable.cloneGlobalValuehandles onlyIsAFunctionandIsAGlobalVariable; any otherGlobalValue(GlobalAlias,GlobalIFunc) hitspanic("dcepass: unsupported global value"). llgo does emit aliases (ssa/decl.go:148llvm.AddAlias), so if a pruned ABI type descriptor ever references one — directly or through a cloned field — the whole build aborts. Prefer rebinding aliases by name to a dst declaration (as functions are handled) or keeping the value, rather than panicking. (inline) -
internal/dcepass/dcepass.go:114-127—cloneConstdoes not recurse intoConstantExpr. It recurses only throughConstantStruct; aConstantExpr(GEP / bitcast / inttoptr, all produced elsewhere in codegen) falls through toreturn v, leaving the dst-module initializer pointing back into the source module's value graph. Latent today under opaque pointers where the observed fields are plain global refs, but a typed-pointer build or a codegen change that materializes a field as a constant-expression would produce a cross-module reference. Consider recursing throughConstantExpr/ConstantArrayoperands, or explicitly asserting the field shapes you support. (inline) -
internal/dcepass/dcepass.go:68-76— method-array element read as a 4-operand struct without validation.methodArrayvalidates the element type has 4 fields, but the drop branch unconditionally readsorig.Operand(0..3)(andOperand(2/3).Name()when verbose) on each element value. AConstantAggregateZeroelement hasOperandsCount()==0, soOperand(i)would be out of range. Safe today because llgo always emits non-null ifn/tfn, but it is an unguarded assumption on IR shape. (inline) -
internal/deadcode/analyze.go:46-52/internal/build/build.go(dceEntryRootCandidates) — missing entry roots are silently dropped.Analyzeskips any root name absent from the summary. If a mandatory entry root such asmain.mainever fails to resolve (e.g. a metadata-collection regression), the pass silently treats far more slots as dead and prunes live code with no diagnostic. A cheap guard/verbose-warning whenmain.main/main.initresolve to nothing would turn a silent miscompile into a visible error.
Performance
-
internal/deadcode/analyze.go:279-284—popWorkis O(n) per dequeue → O(n²) flood.copy(d.workQueue, d.workQueue[1:])shifts the entire remaining slice on every pop, so draining N enqueued symbols costs O(N²) over a whole-program analysis. The loop runs to a fixpoint, so FIFO vs LIFO is irrelevant to correctness — popping from the tail (d.workQueue[len-1]; d.workQueue = d.workQueue[:len-1]) is O(1), or use a head index. Single highest-value change in the analysis. (inline) -
internal/meta/global.go:264-272—OrdinaryEdgesgrows viaappendwith no pre-size on the hot flood path, unlike the structurally identicalTypeChildren(line 282) which usesmake([]Symbol, len(local)). Pre-size tolen(edges)for consistency. Separately,markUsedInIfacere-enqueues an already-reachablesymbol, causingfloodto re-walk its ordinary edges + demands purely to reach theusedInIfacebranch — a separate "already flooded" guard would avoid that duplicate work.
Minor / docs
-
internal/dcepass/dcepass.go:20-21— doc says "clears IFn/TFn" but the code replaces both with theunreachableMethodstub (the slots stay populated, pointing at a trap). "redirects IFn/TFn to an unreachable stub" describes it accurately. (inline) -
internal/dcepass/dcepass.go:53— redundant map write.emitTypeOverridesetse.values[srcType] = dstType, butensureOverrideGlobal(called the line above) already assigns the same key. The line-53 write is dead. -
cl/compile.go:2117-2119— comment steers multi-package drivers to a now-deprecated function. The block comment says multi-package drivers "useNewPackageExWithEmbed", which this PR marksDeprecated: use NewPackageExWithEmbedMetaOptions. Point the guidance at the new entry point.
No blocking issues; the panic-robustness and the O(n²) dequeue are the two I'd most want addressed before this runs on large programs.
Findings without inline locations
internal/dcepass/dcepass.go:147:cloneGlobalValuehandles only functions and global variables; any otherGlobalValue(GlobalAlias,GlobalIFunc) reaches thispanic. llgo does emit aliases (ssa/decl.go:148), so if a pruned ABI type descriptor references one — directly or via a cloned field — the build aborts on valid input. Consider rebinding aliases by name to a dst declaration (like functions above) or falling back to keeping the value, rather than panicking.internal/dcepass/dcepass.go:120:cloneConstrecurses only throughConstantStruct. AConstantExpr(GEP/bitcast/inttoptr) falls through toreturn vhere, leaving the dst-module initializer referencing the source module's value graph. Latent under opaque pointers (observed fields are plain global refs), but a typed-pointer build or a codegen change that materializes a field as a constant-expression would produce a cross-module reference. Consider recursing throughConstantExpr/ConstantArrayoperands, or asserting the shapes you support.internal/dcepass/dcepass.go:70: The drop branch unconditionally readsorig.Operand(0..3)(andOperand(2/3).Name()when verbose).methodArrayvalidated the element type has 4 fields, but not that each element value is a materialized 4-operandConstantStruct— aConstantAggregateZeroelement hasOperandsCount()==0and would fault here. Safe today since ifn/tfn are always non-null, but it's an unguarded assumption on IR shape.internal/deadcode/analyze.go:283:copy(d.workQueue, d.workQueue[1:])shifts the whole remaining slice on every pop, makingflood()O(n²) over a whole-program analysis. The loop runs to a fixpoint so ordering is irrelevant — popping from the tail is O(1):internal/dcepass/dcepass.go:20: Nit: "clears IFn/TFn" — the code replaces both fields with theunreachableMethodstub (slots stay populated, pointing at a trap) rather than clearing them. "redirects IFn/TFn to an unreachable stub" would be accurate.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Review (corrected): align unexported-package-member diagnostics
⚠️ My earlier review on this PR reviewed the wrong diff — it compared the head branch against the currentmaintip and so pulled in dozens of already-merged, unrelated commits (the DCE feature, thecl.Optionsrefactor, etc.). Please disregard that review. This PR's real base is2310ff87and it changes only 3 files. This review supersedes it.
The actual change makes llgo report undefined: alias.hidden (matching gc) instead of name hidden not exported by package ... when source references an unexported member of another package, and removes the now-passing runtime.go go1.26 errorcheck case from xfail.yaml. It's focused and well-tested (load_test.go asserts both the new message and the absence of the old one).
Only minor observations — nothing blocking.
Observations
-
internal/packages/load.go:291— the rewrite is coupled to go/types' exact error wording. The trigger isstrings.HasPrefix(err.Msg, "name "+name+" not exported by package "). If a future Go toolchain rephrases that diagnostic, the rewrite silently stops firing and the original (non-gc) message resurfaces. The failure mode is graceful (you fall back to the upstream message, and the goroot errorcheck test would flag the regression), so this is acceptable — but a brief comment noting the dependency on the go/types string would help the next reader understand why the match is so literal. -
internal/packages/load.go:383-385—packageSelectorsrecords every ident-qualified selector, not just package qualifiers (x.field,v.Method, andpkg.Nameall land in the map). This is harmless because the rewrite at line 289-293 only activates on thename X not exported by packagemessage, which go/types emits exclusively for package-qualified access to an unexported identifier — so a non-package qualifier can never reach the rewrite. Worth being aware of if that message guard is ever loosened; today it is safe by construction. -
internal/packages/load.go:260,289— nil-map read is safe.packageSelectorsis declared nil at line 260 and only populated at line 376, after parse/driver errors but before type-checking. The line-289 lookup runs only fortypes.Error(emitted during type-checking, after population), and a read from a nil map returns the zero value regardless — so there is no ordering hazard or panic risk. Noting it only to confirm it was checked.
Overall this looks correct and appropriately scoped. No changes required.
| // from type checker | ||
| if selector, ok := packageSelectors[err.Pos]; ok { | ||
| name := selector[strings.LastIndexByte(selector, '.')+1:] | ||
| if strings.HasPrefix(err.Msg, "name "+name+" not exported by package ") { |
There was a problem hiding this comment.
This rewrite is coupled to go/types' exact diagnostic wording (name X not exported by package ). If a future Go toolchain rephrases that message, the rewrite silently stops firing and the original message resurfaces. The fallback is graceful and the goroot errorcheck test would catch the regression, so this is fine — a short comment noting the dependency on the go/types string would help the next reader understand why the match is so literal.
| } | ||
| qualifier, ok := sel.X.(*ast.Ident) | ||
| if ok { | ||
| packageSelectors[sel.Sel.Pos()] = qualifier.Name + "." + sel.Sel.Name |
There was a problem hiding this comment.
This records every ident-qualified selector (x.field, v.Method, pkg.Name), not only package qualifiers. Harmless today because the rewrite only activates on the name X not exported by package message, which go/types emits exclusively for package-qualified unexported access — so a non-package qualifier can never reach it. Just flagging it in case that message guard is ever loosened.
LLGo baseline benchmarks
Program measurements
Core language and compiler benchmarks
No main baseline exists yet; all metrics are marked Warning
|
No description provided.