Add public API boundary checks
This commit is contained in:
@@ -26,10 +26,13 @@ go run ./cmd/releasekit version
|
|||||||
version injection, and delivery, with the initial implementation plan.
|
version injection, and delivery, with the initial implementation plan.
|
||||||
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
||||||
policy reading.
|
policy reading.
|
||||||
|
- [CLI contract](docs/cli.md): the current `version` command and its output.
|
||||||
|
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications
|
||||||
|
read their embedded version through `buildinfo`.
|
||||||
|
- [Internal package inventory](docs/internal/overview.md): implemented internal
|
||||||
|
components and their responsibilities.
|
||||||
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency
|
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency
|
||||||
boundaries for development.
|
boundaries for development.
|
||||||
- [Runtime version guidance](docs/runtime.md): how applications read their
|
|
||||||
embedded version through `buildinfo`.
|
|
||||||
- [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision.
|
- [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision.
|
||||||
- [Policy adoption roadmap](docs/roadmap/policy.md): completed adoption and pending
|
- [Policy adoption roadmap](docs/roadmap/policy.md): completed adoption and pending
|
||||||
release bootstrap work.
|
release bootstrap work.
|
||||||
|
|||||||
13
docs/cli.md
Normal file
13
docs/cli.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Command line
|
||||||
|
|
||||||
|
ReleaseKit currently provides one command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/releasekit version
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints `releasekit <version>` followed by a newline and exits with status 0.
|
||||||
|
The version comes from [`buildinfo.Version`](consumers/runtime-version.md).
|
||||||
|
|
||||||
|
Any other argument sequence prints `usage: releasekit version` to standard
|
||||||
|
error and exits with status 2.
|
||||||
@@ -14,11 +14,3 @@ Otherwise it returns the executable's main module version when that metadata is
|
|||||||
available and is not `(devel)`. It returns `dev` when neither source provides a
|
available and is not `(devel)`. It returns `dev` when neither source provides a
|
||||||
release identifier. Applications should use this accessor for version output;
|
release identifier. Applications should use this accessor for version output;
|
||||||
the runtime implementation and ReleaseKit's builder own the injection detail.
|
the runtime implementation and ReleaseKit's builder own the injection detail.
|
||||||
|
|
||||||
ReleaseKit's current command prints its own runtime version:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go run ./cmd/releasekit version
|
|
||||||
```
|
|
||||||
|
|
||||||
The output has the form `releasekit <version>`.
|
|
||||||
@@ -42,10 +42,16 @@ Run the offline Go test suite from the repository root with:
|
|||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
The implemented package inventory currently contains the root `releasekit`
|
Run the compiled public API and dependency-boundary checks from the repository
|
||||||
facade and `buildinfo` runtime facade, plus the `cmd/releasekit` executable.
|
root with:
|
||||||
Orchestration operations are added as their behavior is implemented; see the
|
|
||||||
[implementation plan](roadmap/implementation.md) for current progress.
|
```sh
|
||||||
|
go run ./internal/apicheck
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [internal package inventory](internal/overview.md) and
|
||||||
|
[implementation plan](roadmap/implementation.md) for current implementation
|
||||||
|
progress.
|
||||||
|
|
||||||
For prose changes, review the diff and check local links, fragment destinations,
|
For prose changes, review the diff and check local links, fragment destinations,
|
||||||
provenance, template residue, and consistency with current repository contents.
|
provenance, template residue, and consistency with current repository contents.
|
||||||
|
|||||||
18
docs/internal/overview.md
Normal file
18
docs/internal/overview.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Internal package inventory
|
||||||
|
|
||||||
|
Public facades and their contracts are defined in Go declarations and GoDoc.
|
||||||
|
The [runtime consumer guide](../consumers/runtime-version.md) covers application
|
||||||
|
usage, and the [CLI contract](../cli.md) covers command behavior.
|
||||||
|
|
||||||
|
Implemented internal packages:
|
||||||
|
|
||||||
|
- `internal/runtimeinfo` holds the injected version value and selects the
|
||||||
|
executable's main-module version when no injected value is present.
|
||||||
|
- `internal/versioncontract` names the linker symbol shared with the future
|
||||||
|
release builder.
|
||||||
|
- `internal/apicheck` checks compiled exports with `go/types` and checks import
|
||||||
|
direction. Run it with `go run ./internal/apicheck` from the repository root.
|
||||||
|
|
||||||
|
The executable currently imports only the runtime facade. Release orchestration,
|
||||||
|
process execution, build, Git, and Gitea implementation packages are introduced
|
||||||
|
as their responsibilities are implemented.
|
||||||
@@ -481,7 +481,7 @@ guidance and documented the command contract.
|
|||||||
|
|
||||||
## Stage 3: Protect public contracts and dependency direction
|
## Stage 3: Protect public contracts and dependency direction
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 2.
|
Status: Complete. Depends on Stage 2.
|
||||||
|
|
||||||
1. Add a compiled-export boundary checker for both facades using `go list` export
|
1. Add a compiled-export boundary checker for both facades using `go list` export
|
||||||
data and `go/types`. Inspect exported signatures, fields, callbacks, aliases,
|
data and `go/types`. Inspect exported signatures, fields, callbacks, aliases,
|
||||||
@@ -499,6 +499,15 @@ Acceptance: actual exports pass and meaningful negative fixtures fail. Tests
|
|||||||
inspect contracts and imports, with no fixed inventory of future internal
|
inspect contracts and imports, with no fixed inventory of future internal
|
||||||
package names. Extend these checks as subsequent exports land.
|
package names. Extend these checks as subsequent exports land.
|
||||||
|
|
||||||
|
Completion evidence: added `internal/apicheck`, which loads compiled exports
|
||||||
|
from `go list -deps -export` and inspects signatures, fields, aliases, generic
|
||||||
|
constraints, and pointer and value method sets through `go/types`. The checks
|
||||||
|
pass for both public facades and enforce internal import direction and the
|
||||||
|
runtime dependency boundary. Negative fixtures detect leaked types, aliases,
|
||||||
|
callbacks, generic constraints, and promoted implementation methods; a private
|
||||||
|
implementation field and selected standard-library contracts pass. Added the
|
||||||
|
checker command and internal package inventory to the canonical developer docs.
|
||||||
|
|
||||||
## Stage 4: Implement profile loading and command adaptation
|
## Stage 4: Implement profile loading and command adaptation
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 3.
|
Status: Pending. Depends on Stage 3.
|
||||||
|
|||||||
334
internal/apicheck/main.go
Normal file
334
internal/apicheck/main.go
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
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 "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 !strings.HasPrefix(pkg.ImportPath, modulePath+"/internal/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, imported := range pkg.Imports {
|
||||||
|
if imported == modulePath || imported == modulePath+"/buildinfo" || strings.HasPrefix(imported, modulePath+"/cmd/") {
|
||||||
|
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 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
|
||||||
|
}
|
||||||
180
internal/apicheck/main_test.go
Normal file
180
internal/apicheck/main_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/importer"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"go/types"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExportedContractBoundaries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "leaked implementation type",
|
||||||
|
source: `package facade
|
||||||
|
import "example.com/implementation"
|
||||||
|
func New() *implementation.State { return nil }
|
||||||
|
`,
|
||||||
|
wantError: "exposes named type example.com/implementation.State",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "alias to implementation type",
|
||||||
|
source: `package facade
|
||||||
|
import "example.com/implementation"
|
||||||
|
type Alias = implementation.State
|
||||||
|
`,
|
||||||
|
wantError: "exposes named type example.com/implementation.State",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "callback leaks implementation type",
|
||||||
|
source: `package facade
|
||||||
|
import "example.com/implementation"
|
||||||
|
type Callback func() *implementation.State
|
||||||
|
`,
|
||||||
|
wantError: "exposes named type example.com/implementation.State",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic constraint leaks implementation type",
|
||||||
|
source: `package facade
|
||||||
|
import "example.com/implementation"
|
||||||
|
type Box[T interface{ Value() *implementation.State }] struct { value T }
|
||||||
|
`,
|
||||||
|
wantError: "exposes named type example.com/implementation.State",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic instantiation leaks implementation type",
|
||||||
|
source: `package facade
|
||||||
|
import "example.com/implementation"
|
||||||
|
type Box[T any] struct { value T }
|
||||||
|
func New() Box[implementation.State] { return Box[implementation.State]{} }
|
||||||
|
`,
|
||||||
|
wantError: "exposes named type example.com/implementation.State",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "promoted implementation method",
|
||||||
|
source: `package facade
|
||||||
|
import "example.com/implementation"
|
||||||
|
type state = implementation.State
|
||||||
|
type Handle struct { state }
|
||||||
|
`,
|
||||||
|
wantError: "promotes method Promoted from example.com/implementation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "promoted pointer implementation method",
|
||||||
|
source: `package facade
|
||||||
|
import "example.com/implementation"
|
||||||
|
type state = implementation.State
|
||||||
|
type Handle struct { state }
|
||||||
|
`,
|
||||||
|
wantError: "promotes method PointerPromoted from example.com/implementation",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
violations := checkFixture(t, test.source)
|
||||||
|
if !strings.Contains(strings.Join(violations, "\n"), test.wantError) {
|
||||||
|
t.Fatalf("checkExports() = %v, want an error containing %q", violations, test.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateImplementationFieldAndSelectedStandardTypes(t *testing.T) {
|
||||||
|
source := `package facade
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"example.com/implementation"
|
||||||
|
)
|
||||||
|
type Handle struct { state *implementation.State }
|
||||||
|
func Open(context.Context) *Handle { return nil }
|
||||||
|
`
|
||||||
|
if violations := checkFixture(t, source); len(violations) != 0 {
|
||||||
|
t.Fatalf("private implementation field and selected standard type were rejected: %v", violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActualRepositoryBoundaries(t *testing.T) {
|
||||||
|
if err := checkRepository(t.Context(), "../.."); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInternalPackagesCannotImportPublicFacades(t *testing.T) {
|
||||||
|
packages := []listedPackage{
|
||||||
|
{ImportPath: modulePath + "/internal/operations", Imports: []string{modulePath}},
|
||||||
|
{ImportPath: modulePath},
|
||||||
|
}
|
||||||
|
err := checkImportDirections(packages)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "internal package "+modulePath+"/internal/operations imports public or command package "+modulePath) {
|
||||||
|
t.Fatalf("checkImportDirections() error = %v, want internal-to-facade violation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeFacadeCannotDependOnExternalPackages(t *testing.T) {
|
||||||
|
packages := []listedPackage{
|
||||||
|
{ImportPath: modulePath + "/buildinfo", Imports: []string{"example.com/runtime"}},
|
||||||
|
{ImportPath: "example.com/runtime"},
|
||||||
|
}
|
||||||
|
err := checkImportDirections(packages)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "buildinfo depends on non-runtime package example.com/runtime") {
|
||||||
|
t.Fatalf("checkImportDirections() error = %v, want runtime dependency violation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkFixture(t *testing.T, facadeSource string) []string {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
implementation := typecheck(t, fset, "example.com/implementation", `package implementation
|
||||||
|
type State struct{}
|
||||||
|
func (State) Promoted() {}
|
||||||
|
func (*State) PointerPromoted() {}
|
||||||
|
`)
|
||||||
|
fixtureImporter := singlePackageImporter{path: "example.com/implementation", pkg: implementation}
|
||||||
|
facade := typecheckWithImporter(t, fset, "example.com/facade", facadeSource, fixtureImporter)
|
||||||
|
return checkExports(facade)
|
||||||
|
}
|
||||||
|
|
||||||
|
type singlePackageImporter struct {
|
||||||
|
path string
|
||||||
|
pkg *types.Package
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i singlePackageImporter) Import(path string) (*types.Package, error) {
|
||||||
|
if path == i.path {
|
||||||
|
return i.pkg, nil
|
||||||
|
}
|
||||||
|
return importer.Default().Import(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func typecheck(t *testing.T, fset *token.FileSet, path, source string) *types.Package {
|
||||||
|
t.Helper()
|
||||||
|
return typecheckWithImporter(t, fset, path, source, importer.Default())
|
||||||
|
}
|
||||||
|
|
||||||
|
func typecheckWithImporter(t *testing.T, fset *token.FileSet, path, source string, imports types.Importer) *types.Package {
|
||||||
|
t.Helper()
|
||||||
|
file, err := parser.ParseFile(fset, path+".go", source, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var errors []error
|
||||||
|
config := types.Config{
|
||||||
|
Importer: imports,
|
||||||
|
Error: func(err error) {
|
||||||
|
errors = append(errors, err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pkg, err := config.Check(path, fset, []*ast.File{file}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("typecheck %s: %v (%v)", path, err, errors)
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user