package main import ( "context" "encoding/json" "errors" "fmt" "go/importer" "go/token" "go/types" "io" "os" "os/exec" "sort" "strings" ) const modulePath = "gitea.maximumdirect.net/eric/ReleaseKit" var facades = []string{modulePath, modulePath + "/buildinfo"} type listedPackage struct { ImportPath string Export string Standard bool Imports []string } func main() { os.Exit(run(context.Background(), ".", os.Stdout, os.Stderr)) } func run(ctx context.Context, dir string, stdout, stderr io.Writer) int { if err := checkRepository(ctx, dir); err != nil { fmt.Fprintln(stderr, err) return 1 } fmt.Fprintln(stdout, "public API and dependency boundaries pass") return 0 } func checkRepository(ctx context.Context, dir string) error { packages, err := listPackages(ctx, dir) if err != nil { return err } if err := checkImportDirections(packages); err != nil { return err } lookup := make(map[string]string, len(packages)) for _, pkg := range packages { if pkg.Export != "" { lookup[pkg.ImportPath] = pkg.Export } } apiImporter := importer.ForCompiler(token.NewFileSet(), "gc", func(path string) (io.ReadCloser, error) { export, ok := lookup[path] if !ok { return nil, fmt.Errorf("no compiled export data for %q", path) } return os.Open(export) }) for _, path := range facades { pkg, err := apiImporter.Import(path) if err != nil { return fmt.Errorf("load facade %s: %w", path, err) } if violations := checkExports(pkg); len(violations) != 0 { return fmt.Errorf("facade %s has invalid public contracts:\n %s", path, strings.Join(violations, "\n ")) } if path == modulePath { if violations := checkValidationTypes(pkg); len(violations) != 0 { return fmt.Errorf("facade %s has invalid validation diagnostics:\n %s", path, strings.Join(violations, "\n ")) } } } return nil } func checkValidationTypes(pkg *types.Package) []string { var violations []string for _, name := range []string{"ValidationCheck", "ValidationFinding", "FileSizeMeasurement", "ValidationError"} { object, ok := pkg.Scope().Lookup(name).(*types.TypeName) if !ok { violations = append(violations, fmt.Sprintf("missing facade-owned type %s", name)) continue } named, ok := types.Unalias(object.Type()).(*types.Named) if !ok || named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != pkg.Path() { violations = append(violations, fmt.Sprintf("%s is not a facade-owned named type", name)) } } validationError, ok := pkg.Scope().Lookup("ValidationError").(*types.TypeName) if !ok { return violations } named, ok := types.Unalias(validationError.Type()).(*types.Named) if !ok { return violations } structure, ok := named.Underlying().(*types.Struct) if !ok { return append(violations, "ValidationError is not a struct") } fields := map[string]string{ "Check": "ValidationCheck", "Findings": "[]ValidationFinding", "Measurement": "*FileSizeMeasurement", } for fieldName, expected := range fields { fieldIndex := -1 for index := 0; index < structure.NumFields(); index++ { if structure.Field(index).Name() == fieldName && structure.Field(index).Exported() { fieldIndex = index break } } if fieldIndex < 0 || !validationFieldMatches(structure.Field(fieldIndex).Type(), expected, pkg.Path()) { violations = append(violations, fmt.Sprintf("ValidationError.%s must expose %s", fieldName, expected)) } } if cause := fieldByName(structure, "Cause"); cause == nil || !cause.Exported() || !types.Identical(cause.Type(), types.Universe.Lookup("error").Type()) { violations = append(violations, "ValidationError.Cause must expose error") } return violations } func fieldByName(structure *types.Struct, name string) *types.Var { for index := 0; index < structure.NumFields(); index++ { if structure.Field(index).Name() == name { return structure.Field(index) } } return nil } func validationFieldMatches(fieldType types.Type, expected, packagePath string) bool { switch expected { case "[]ValidationFinding": slice, ok := types.Unalias(fieldType).(*types.Slice) if !ok { return false } fieldType = slice.Elem() case "*FileSizeMeasurement": pointer, ok := types.Unalias(fieldType).(*types.Pointer) if !ok { return false } fieldType = pointer.Elem() } named, ok := types.Unalias(fieldType).(*types.Named) return ok && named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == packagePath && named.Obj().Name() == strings.TrimLeft(expected, "*[]") } func listPackages(ctx context.Context, dir string) ([]listedPackage, error) { cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-export", "-json", "./...") cmd.Dir = dir output, err := cmd.Output() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return nil, fmt.Errorf("go list -deps -export failed: %s", strings.TrimSpace(string(exitErr.Stderr))) } return nil, fmt.Errorf("go list -deps -export: %w", err) } decoder := json.NewDecoder(strings.NewReader(string(output))) var packages []listedPackage for { var pkg listedPackage if err := decoder.Decode(&pkg); errors.Is(err, io.EOF) { break } else if err != nil { return nil, fmt.Errorf("decode go list output: %w", err) } packages = append(packages, pkg) } return packages, nil } func checkImportDirections(packages []listedPackage) error { byPath := make(map[string]listedPackage, len(packages)) for _, pkg := range packages { byPath[pkg.ImportPath] = pkg } var violations []string for _, pkg := range packages { if !isInternalPackage(pkg.ImportPath) { continue } for _, imported := range pkg.Imports { commandDependency := strings.HasPrefix(imported, modulePath+"/cmd/") owner := commandInternalRoot(pkg.ImportPath) ownHelper := owner != "" && strings.HasPrefix(imported+"/", owner) if imported == modulePath || imported == modulePath+"/buildinfo" || commandDependency && !ownHelper { violations = append(violations, fmt.Sprintf("internal package %s imports public or command package %s", pkg.ImportPath, imported)) } } } seen := make(map[string]bool) var visit func(string) visit = func(path string) { if seen[path] { return } seen[path] = true pkg, ok := byPath[path] if !ok { return } if path != modulePath+"/buildinfo" && !pkg.Standard { if !strings.HasPrefix(path, modulePath+"/internal/") { violations = append(violations, fmt.Sprintf("buildinfo depends on non-runtime package %s", path)) } else if forbiddenRuntimeDependency(path) { violations = append(violations, fmt.Sprintf("buildinfo depends on excluded implementation package %s", path)) } } for _, imported := range pkg.Imports { visit(imported) } } visit(modulePath + "/buildinfo") if len(violations) == 0 { return nil } sort.Strings(violations) return errors.New(strings.Join(violations, "\n")) } func isInternalPackage(path string) bool { return strings.HasPrefix(path, modulePath+"/") && (strings.Contains(strings.TrimPrefix(path, modulePath), "/internal/") || strings.HasSuffix(path, "/internal")) } // commandInternalRoot identifies the helper subtree owned by one executable. func commandInternalRoot(path string) string { if !strings.HasPrefix(path, modulePath+"/cmd/") { return "" } relative := strings.TrimPrefix(path, modulePath+"/cmd/") owner, _, ok := strings.Cut(relative+"/", "/internal/") if !ok { return "" } return modulePath + "/cmd/" + owner + "/internal/" } func forbiddenRuntimeDependency(path string) bool { for _, part := range strings.Split(strings.ToLower(path), "/") { switch part { case "orchestration", "orchestrator", "engine", "release", "process", "build", "builder", "gitea", "delivery", "cli": return true } } return false } var allowedExternalTypes = map[string]bool{ "context.Context": true, "io.Reader": true, "io.Writer": true, } func checkExports(pkg *types.Package) []string { checker := contractChecker{ packagePath: pkg.Path(), checkedTypes: make(map[types.Type]bool), allowedNamed: allowedExternalTypes, violationsSet: make(map[string]bool), } for _, name := range pkg.Scope().Names() { if !token.IsExported(name) { continue } object := pkg.Scope().Lookup(name) switch obj := object.(type) { case *types.TypeName: checker.checkType(obj.Type(), name) case *types.Func: checker.checkType(obj.Type(), name) case *types.Var: checker.checkType(obj.Type(), name) case *types.Const: checker.checkType(obj.Type(), name) } } return checker.violations() } type contractChecker struct { packagePath string checkedTypes map[types.Type]bool allowedNamed map[string]bool violationsSet map[string]bool } func (c *contractChecker) violations() []string { violations := make([]string, 0, len(c.violationsSet)) for violation := range c.violationsSet { violations = append(violations, violation) } sort.Strings(violations) return violations } func (c *contractChecker) violation(message string) { c.violationsSet[message] = true } func (c *contractChecker) checkType(typ types.Type, path string) { if typ == nil || c.checkedTypes[typ] { return } c.checkedTypes[typ] = true switch typ := typ.(type) { case *types.Basic: case *types.Alias: if params := typ.TypeParams(); params != nil { for i := 0; i < params.Len(); i++ { c.checkType(params.At(i), path+" type parameter") } } if args := typ.TypeArgs(); args != nil { for i := 0; i < args.Len(); i++ { c.checkType(args.At(i), path+" type argument") } } c.checkType(types.Unalias(typ), path) case *types.Named: object := typ.Obj() if object.Pkg() != nil && object.Pkg().Path() != c.packagePath { key := object.Pkg().Path() + "." + object.Name() if !c.allowedNamed[key] { c.violation(fmt.Sprintf("%s exposes named type %s", path, key)) } return } if params := typ.TypeParams(); params != nil { for i := 0; i < params.Len(); i++ { c.checkType(params.At(i), path+" type parameter") } } if args := typ.TypeArgs(); args != nil { for i := 0; i < args.Len(); i++ { c.checkType(args.At(i), path+" type argument") } } c.checkType(typ.Underlying(), path) for _, receiver := range []types.Type{typ, types.NewPointer(typ)} { methodSet := types.NewMethodSet(receiver) for i := 0; i < methodSet.Len(); i++ { method := methodSet.At(i).Obj() if !method.Exported() { continue } function, _ := method.(*types.Func) if method.Pkg() != nil && method.Pkg().Path() != c.packagePath && !c.allowedMethod(function) { c.violation(fmt.Sprintf("%s promotes method %s from %s", path, method.Name(), method.Pkg().Path())) } c.checkType(method.Type(), path+" method "+method.Name()) } } case *types.Pointer: c.checkType(typ.Elem(), path) case *types.Array: c.checkType(typ.Elem(), path) case *types.Slice: c.checkType(typ.Elem(), path) case *types.Map: c.checkType(typ.Key(), path) c.checkType(typ.Elem(), path) case *types.Chan: c.checkType(typ.Elem(), path) case *types.Struct: for i := 0; i < typ.NumFields(); i++ { field := typ.Field(i) if field.Exported() || field.Embedded() { c.checkType(field.Type(), path+" field "+field.Name()) } } case *types.Interface: typ.Complete() for i := 0; i < typ.NumMethods(); i++ { method := typ.Method(i) if method.Pkg() != nil && method.Pkg().Path() != c.packagePath && !c.allowedMethod(method) { c.violation(fmt.Sprintf("%s exposes method %s from %s", path, method.Name(), method.Pkg().Path())) } c.checkType(method.Type(), path+" method "+method.Name()) } for i := 0; i < typ.NumEmbeddeds(); i++ { c.checkType(typ.EmbeddedType(i), path+" embedded interface") } case *types.Signature: if params := typ.TypeParams(); params != nil { for i := 0; i < params.Len(); i++ { c.checkType(params.At(i), path+" type parameter") } } c.checkType(typ.Params(), path+" parameter") c.checkType(typ.Results(), path+" result") if typ.Variadic() && typ.Params().Len() != 0 { c.checkType(typ.Params().At(typ.Params().Len()-1).Type(), path+" variadic parameter") } case *types.Tuple: for i := 0; i < typ.Len(); i++ { c.checkType(typ.At(i).Type(), path) } case *types.TypeParam: c.checkType(typ.Constraint(), path+" constraint") case *types.Union: for i := 0; i < typ.Len(); i++ { c.checkType(typ.Term(i).Type(), path+" constraint term") } default: c.violation(fmt.Sprintf("%s has uninspected type %T", path, typ)) } } func (c *contractChecker) allowedMethod(method *types.Func) bool { if method.Pkg() == nil { return true } for typeName := range c.allowedNamed { if strings.HasPrefix(typeName, method.Pkg().Path()+".") { return true } } return false }