package configkit import ( "bytes" "encoding/json" "go/ast" "go/importer" "go/parser" "go/token" "go/types" "io" "os" "os/exec" "strings" "testing" ) func TestPublicFacadeBoundary(t *testing.T) { t.Parallel() // Use the active toolchain's build selection and export data, not a second // source-level approximation of aliases, generics, or promoted method sets. command := exec.Command("go", "list", "-export", "-deps", "-json", ".") command.Env = append(os.Environ(), "GOPROXY=off") var stderr bytes.Buffer command.Stderr = &stderr output, err := command.Output() if err != nil { t.Fatalf("load package exports: %v\n%s", err, &stderr) } exports := make(map[string]string) var root string decoder := json.NewDecoder(bytes.NewReader(output)) for { var entry struct { ImportPath string Export string DepOnly bool } if err := decoder.Decode(&entry); err == io.EOF { break } else if err != nil { t.Fatal(err) } exports[entry.ImportPath] = entry.Export if !entry.DepOnly { root = entry.ImportPath } } loader := importer.ForCompiler(token.NewFileSet(), "gc", func(path string) (io.ReadCloser, error) { return os.Open(exports[path]) }) pkg, err := loader.Import(root) if err != nil { t.Fatal(err) } for _, name := range publicBoundaryViolations(pkg) { t.Errorf("%s exposes an internal type or is a public type alias", name) } } func publicBoundaryViolations(pkg *types.Package) []string { var violations []string for _, name := range pkg.Scope().Names() { object := pkg.Scope().Lookup(name) if !object.Exported() { continue } if named, ok := object.(*types.TypeName); ok && named.IsAlias() { violations = append(violations, name) continue } if exposesInternalType(object.Type(), pkg, make(map[types.Type]bool)) { violations = append(violations, name) } } return violations } func exposesInternalType(value types.Type, root *types.Package, seen map[types.Type]bool) bool { if value == nil || seen[value] { return false } seen[value] = true visit := func(child types.Type) bool { return exposesInternalType(child, root, seen) } parameters := func(list *types.TypeParamList) bool { for i := 0; i < list.Len(); i++ { if visit(list.At(i)) { return true } } return false } switch value := value.(type) { case *types.Alias: return visit(types.Unalias(value)) case *types.Named: owner := value.Obj().Pkg() if owner != nil && (strings.HasPrefix(owner.Path(), "internal/") || strings.Contains(owner.Path(), "/internal/")) { return true } for i := 0; i < value.TypeArgs().Len(); i++ { if visit(value.TypeArgs().At(i)) { return true } } // Other public packages own their implementation boundaries. Only root // named types need their exported fields, methods, and constraints walked. if owner != root { return false } if parameters(value.TypeParams()) || visit(value.Underlying()) { return true } for _, receiver := range []types.Type{value, types.NewPointer(value)} { methods := types.NewMethodSet(receiver) for i := 0; i < methods.Len(); i++ { method := methods.At(i).Obj() if method.Exported() && visit(method.Type()) { return true } } } case *types.Pointer: return visit(value.Elem()) case *types.Slice: return visit(value.Elem()) case *types.Array: return visit(value.Elem()) case *types.Map: return visit(value.Key()) || visit(value.Elem()) case *types.Chan: return visit(value.Elem()) case *types.Struct: for i := 0; i < value.NumFields(); i++ { field := value.Field(i) if (field.Exported() || field.Embedded()) && visit(field.Type()) { return true } } case *types.Signature: return parameters(value.TypeParams()) || parameters(value.RecvTypeParams()) || visit(value.Params()) || visit(value.Results()) case *types.Tuple: for i := 0; i < value.Len(); i++ { if visit(value.At(i).Type()) { return true } } case *types.Interface: for i := 0; i < value.NumMethods(); i++ { method := value.Method(i) if method.Exported() && visit(method.Type()) { return true } } for i := 0; i < value.NumEmbeddeds(); i++ { if visit(value.EmbeddedType(i)) { return true } } case *types.TypeParam: return visit(value.Constraint()) case *types.Union: for i := 0; i < value.Len(); i++ { if visit(value.Term(i).Type()) { return true } } } return false } type boundaryFixtureImporter struct{ engine *types.Package } func (i boundaryFixtureImporter) Import(path string) (*types.Package, error) { return i.engine, nil } func TestPublicBoundaryDeclarations(t *testing.T) { t.Parallel() fset := token.NewFileSet() engineFile, err := parser.ParseFile(fset, "engine.go", "package engine; type Path struct{}", 0) if err != nil { t.Fatal(err) } engine, err := new(types.Config).Check("fixture/internal/engine", fset, []*ast.File{engineFile}, nil) if err != nil { t.Fatal(err) } for _, test := range []struct { name, declaration string exposed bool }{ {"collection", "type API []engine.Path", true}, {"alias result", "type hidden = engine.Path; func API() hidden { panic(0) }", true}, {"constraint", "type API[T interface{ ~[]engine.Path }] struct{}", true}, {"alias", "type API = engine.Path", true}, {"field", "type API struct { Value engine.Path }", true}, {"embedding", "type API struct { engine.Path }", true}, {"function type", "type API func(engine.Path)", true}, {"interface", "type API interface { Value() engine.Path }", true}, {"pointer method", "type API struct{}; func (*API) Value() engine.Path { panic(0) }", true}, {"promoted method", "type hidden struct{}; func (*hidden) Value() engine.Path { panic(0) }; type API struct{ hidden }", true}, {"nested record", "type hidden struct { Values map[string][2]engine.Path }; type API struct { Value *hidden }", true}, {"channel", "var API chan engine.Path", true}, {"function constraint", "func API[T interface{ ~[]engine.Path }]() {}", true}, {"generic argument", "type hidden[T any] struct{ value T }; func API() hidden[engine.Path] { panic(0) }", true}, {"wrapper", "type API struct{ value engine.Path }; func (API) String() string { return \"\" }", false}, {"public record", "type Path struct{ value engine.Path }; type API struct{ Paths []Path }", false}, {"generic wrapper", "type API[T any] struct{ Config T; value engine.Path }", false}, {"recursive wrapper", "type API struct{ Next *API; value engine.Path }", false}, {"private helper", "type hidden = engine.Path; type API struct{ value hidden }", false}, } { t.Run(test.name, func(t *testing.T) { // Separate declarations also ensure resolution is package-wide. file, err := parser.ParseFile(fset, "fixture.go", "package facade; import \"fixture/internal/engine\"; var _ engine.Path; "+test.declaration, 0) if err != nil { t.Fatal(err) } config := types.Config{Importer: boundaryFixtureImporter{engine}} pkg, err := config.Check("fixture", fset, []*ast.File{file}, nil) if err != nil { t.Fatal(err) } got := publicBoundaryViolations(pkg) if (len(got) != 0) != test.exposed { t.Fatalf("violations = %v; want exposed=%v", got, test.exposed) } }) } }