Files
promptkit/public_boundary_test.go

398 lines
11 KiB
Go

package promptkit_test
import (
"encoding/json"
"fmt"
"go/ast"
"go/importer"
"go/parser"
"go/token"
"go/types"
"io"
"os"
"os/exec"
"runtime"
"sort"
"strings"
"testing"
)
func TestPublicFacadeDoesNotExposeInternalTypes(t *testing.T) {
metadata := listedPackages(t, "-export", ".")
if len(metadata) != 1 {
t.Fatalf("root package metadata count = %d, want one", len(metadata))
}
pkg := importCompiledPackage(t, metadata[0])
violations := publicSurfaceViolations(pkg, metadata[0].ImportPath)
if len(violations) > 0 {
t.Fatalf("public facade exposes internal implementation:\n%s", strings.Join(violations, "\n"))
}
}
func TestInternalProductionPackagesDoNotImportFacade(t *testing.T) {
root := listedPackages(t, ".")
if len(root) != 1 {
t.Fatalf("root package metadata count = %d, want one", len(root))
}
var violations []string
for _, pkg := range listedPackages(t, "./internal/...") {
for _, imported := range pkg.Imports {
if imported == root[0].ImportPath {
violations = append(violations, pkg.ImportPath)
}
}
}
sort.Strings(violations)
if len(violations) > 0 {
t.Fatalf("internal production packages import the public facade:\n%s", strings.Join(violations, "\n"))
}
}
func TestPublicBoundaryCheckerFixtures(t *testing.T) {
tests := []struct {
name string
source string
wantReject bool
}{
{
name: "private internal state and sealed option are allowed",
source: `package facade
import "example.test/facade/internal/hidden"
type privateState struct { value hidden.Value }
type Public struct { state privateState }
type Option interface { apply(*privateState) }
`,
},
{
name: "returned private type method is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
type result struct{}
func (result) Leak() hidden.Value { return hidden.Value{} }
func New() result { return result{} }
`,
wantReject: true,
},
{
name: "reachable private pointer method is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
type result struct{}
func (*result) Leak() hidden.Value { return hidden.Value{} }
type Public struct { Result result }
`,
wantReject: true,
},
{
name: "internal generic argument is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
type Box[T any] struct{}
func New() Box[hidden.Value] { return Box[hidden.Value]{} }
`,
wantReject: true,
},
{
name: "internal alias argument is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
type Box[T any] = struct{}
func New() Box[hidden.Value] { return Box[hidden.Value]{} }
`,
wantReject: true,
},
{
name: "public generic argument and recursive methods are allowed",
source: `package facade
type Value struct{}
type Box[T any] struct{}
func New() Box[Value] { return Box[Value]{} }
type result struct{}
func NewResult() result { return result{} }
func (*result) Next() *result { return nil }
`,
},
{
name: "direct internal signature is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
func Direct(value hidden.Value) {}
`,
wantReject: true,
},
{
name: "nested internal signature is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
func Nested(values []map[string]hidden.Value) {}
`,
wantReject: true,
},
{
name: "internal public field is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
type Public struct { Value hidden.Value }
`,
wantReject: true,
},
{
name: "internal alias is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
type Public = hidden.Value
`,
wantReject: true,
},
{
name: "promoted internal method is rejected",
source: `package facade
import "example.test/facade/internal/hidden"
type wrapper struct { hidden.Value }
type Public struct { wrapper }
`,
wantReject: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
pkg := fixturePackage(t, test.source)
violations := publicSurfaceViolations(pkg, "example.test/facade")
if got := len(violations) > 0; got != test.wantReject {
t.Fatalf("rejected = %t, want %t; violations = %v", got, test.wantReject, violations)
}
})
}
}
type packageMetadata struct {
ImportPath string
Export string
Imports []string
}
func listedPackages(t *testing.T, args ...string) []packageMetadata {
t.Helper()
command := exec.Command("go", append([]string{"list", "-json"}, args...)...)
output, err := command.Output()
if err != nil {
t.Fatalf("go list %s: %v", strings.Join(args, " "), err)
}
decoder := json.NewDecoder(strings.NewReader(string(output)))
var packages []packageMetadata
for decoder.More() {
var metadata packageMetadata
if err := decoder.Decode(&metadata); err != nil {
t.Fatalf("decode go list %s: %v", strings.Join(args, " "), err)
}
packages = append(packages, metadata)
}
return packages
}
func importCompiledPackage(t *testing.T, root packageMetadata) *types.Package {
t.Helper()
exports := map[string]string{root.ImportPath: root.Export}
lookup := func(importPath string) (io.ReadCloser, error) {
export, ok := exports[importPath]
if !ok {
metadata := listedPackages(t, "-export", importPath)
if len(metadata) != 1 || metadata[0].Export == "" {
return nil, fmt.Errorf("export data unavailable for %s", importPath)
}
export = metadata[0].Export
exports[importPath] = export
}
return os.Open(export)
}
compiled := importer.ForCompiler(token.NewFileSet(), runtime.Compiler, lookup)
pkg, err := compiled.Import(root.ImportPath)
if err != nil {
t.Fatalf("import compiled root package: %v", err)
}
return pkg
}
func publicSurfaceViolations(pkg *types.Package, modulePath string) []string {
var violations []string
seen := map[types.Type]bool{}
record := func(where string, typ types.Type) {
violations = append(violations, fmt.Sprintf("%s: %s", where, typ.String()))
}
var inspect func(types.Type, string)
var inspectTuple func(*types.Tuple, string)
var inspectTypeParams func(*types.TypeParamList, string)
var inspectTypeArgs func(*types.TypeList, string)
var inspectMethods func(*types.Named, string)
inspect = func(typ types.Type, where string) {
if typ == nil || seen[typ] {
return
}
seen[typ] = true
switch value := typ.(type) {
case *types.Named:
inspectTypeArgs(value.TypeArgs(), where)
if isInternalPackage(value.Obj().Pkg(), modulePath) {
record(where, value)
return
}
if value.Obj().Pkg() == pkg {
inspectTypeParams(value.TypeParams(), where)
inspect(value.Underlying(), where)
inspectMethods(value, where)
}
case *types.Alias:
inspectTypeArgs(value.TypeArgs(), where)
if isInternalPackage(value.Obj().Pkg(), modulePath) {
record(where, value)
return
}
inspectTypeParams(value.TypeParams(), where)
inspect(types.Unalias(value), where)
case *types.Pointer:
inspect(value.Elem(), where)
case *types.Array:
inspect(value.Elem(), where)
case *types.Slice:
inspect(value.Elem(), where)
case *types.Map:
inspect(value.Key(), where)
inspect(value.Elem(), where)
case *types.Chan:
inspect(value.Elem(), where)
case *types.Struct:
for index := 0; index < value.NumFields(); index++ {
field := value.Field(index)
if field.Exported() {
inspect(field.Type(), where+"."+field.Name())
}
}
case *types.Interface:
value.Complete()
for index := 0; index < value.NumEmbeddeds(); index++ {
inspect(value.EmbeddedType(index), where)
}
for index := 0; index < value.NumMethods(); index++ {
method := value.Method(index)
if method.Exported() {
inspect(method.Type(), where+"."+method.Name())
}
}
case *types.Signature:
if value.Recv() != nil {
inspect(value.Recv().Type(), where)
}
inspectTuple(value.Params(), where)
inspectTuple(value.Results(), where)
inspectTypeParams(value.TypeParams(), where)
case *types.TypeParam:
inspect(value.Constraint(), where)
case *types.Union:
for index := 0; index < value.Len(); index++ {
inspect(value.Term(index).Type(), where)
}
}
}
inspectTuple = func(tuple *types.Tuple, where string) {
for index := 0; index < tuple.Len(); index++ {
inspect(tuple.At(index).Type(), where)
}
}
inspectTypeParams = func(params *types.TypeParamList, where string) {
if params == nil {
return
}
for index := 0; index < params.Len(); index++ {
inspect(params.At(index), where)
}
}
inspectTypeArgs = func(args *types.TypeList, where string) {
for index := 0; index < args.Len(); index++ {
inspect(args.At(index), where)
}
}
inspectMethods = func(named *types.Named, where string) {
for _, methodSet := range []*types.MethodSet{types.NewMethodSet(named), types.NewMethodSet(types.NewPointer(named))} {
for index := 0; index < methodSet.Len(); index++ {
selection := methodSet.At(index)
method := selection.Obj().(*types.Func)
if !method.Exported() {
continue
}
if isInternalPackage(method.Pkg(), modulePath) {
record(where+"."+method.Name(), selection.Type())
continue
}
inspect(selection.Type(), where+"."+method.Name())
}
}
}
for _, name := range pkg.Scope().Names() {
if token.IsExported(name) {
inspect(pkg.Scope().Lookup(name).Type(), name)
}
}
sort.Strings(violations)
return compactStrings(violations)
}
func isInternalPackage(pkg *types.Package, modulePath string) bool {
return pkg != nil && strings.HasPrefix(pkg.Path(), modulePath+"/internal/")
}
func compactStrings(values []string) []string {
if len(values) == 0 {
return nil
}
unique := values[:1]
for _, value := range values[1:] {
if value != unique[len(unique)-1] {
unique = append(unique, value)
}
}
return unique
}
func fixturePackage(t *testing.T, source string) *types.Package {
t.Helper()
const rootPath = "example.test/facade"
const internalPath = rootPath + "/internal/hidden"
fset := token.NewFileSet()
internalFile, err := parser.ParseFile(fset, "hidden.go", `package hidden
type Value struct{}
func (Value) Exposed() {}
`, 0)
if err != nil {
t.Fatalf("parse internal fixture: %v", err)
}
internalPkg, err := (&types.Config{}).Check(internalPath, fset, []*ast.File{internalFile}, nil)
if err != nil {
t.Fatalf("type-check internal fixture: %v", err)
}
rootFile, err := parser.ParseFile(fset, "facade.go", source, 0)
if err != nil {
t.Fatalf("parse facade fixture: %v", err)
}
pkg, err := (&types.Config{Importer: fixtureImporter(func(path string) (*types.Package, error) {
if path == internalPath {
return internalPkg, nil
}
return nil, fmt.Errorf("unexpected fixture import %s", path)
})}).Check(rootPath, fset, []*ast.File{rootFile}, nil)
if err != nil {
t.Fatalf("type-check facade fixture: %v", err)
}
return pkg
}
type fixtureImporter func(string) (*types.Package, error)
func (f fixtureImporter) Import(path string) (*types.Package, error) { return f(path) }