archscout helps you keep architecture visible and enforceable in Go codebases.
Use it to:
- Explore code structure quickly (packages, files, types, calls, dependencies)
- Write architecture tests as code
- Validate dependency boundaries continuously in CI
Architecture often lives in docs, not in tests. archscout lets you move those rules into executable checks.
Examples:
- "domain must not depend on infrastructure"
- "library code must not call panic or os.Exit"
- "application layer may only depend on domain"
When a rule is violated, you get source refs you can print in test failures.
Most Go architecture tools focus on a narrow slice of the problem: check a dependency, enforce a layer rule, done. Archscout is different in four important ways.
Understanding a codebase matters as much as policing it. Archscout ships exploration helpers — UniqueTargets(), UniqueSourcePackages(), GroupBySourcePackage(), GroupByTargetPackage() — designed for asking questions like "who imports my domain layer?" or "what does the UI layer actually reach?" Most tools give you a pass/fail assertion. Archscout also gives you the map.
Every code element — packages, files, types, functions, variables, function calls, and raw import dependencies — is a filterable, chainable collection with the same API. You don't learn a separate DSL per check. You learn InPackage, IsNotTest, Match once and apply them everywhere. Checking for panic calls uses exactly the same pattern as checking dependency boundaries.
Archscout builds a proper directed dependency graph with BuildPackageGraph, letting you ask transitive questions: does the domain layer ever reach infrastructure, through any number of hops? Which packages are reachable from the UI layer? Who (directly or transitively) imports the domain? Other tools check direct edges only.
Load a workspace, write a Go test function, call .Test(t, workspace). No layer definitions to register upfront, no config files, no parsing phases to manage manually. Rules are plain Go values — they compose, they can be shared across test files, and they live exactly where your tests live.
Archscout is a thin layer over Go's own analysis tooling — it doesn't hide the underlying code model behind opaque abstractions. Every Match predicate receives a real typed value (Type, Function, Variable, FunctionCall, Dependency) that you can inspect with plain Go code. If the built-in filters don't cover your case, you reach into the item directly:
import "github.com/saintedlama/archscout"
// Find all exported functions whose name starts with "New" but have no receiver —
// a check no built-in rule needs to exist for.
refs := workspace.Functions.
InPackage("github.com/your-project/...").
IsNotTest().
Match(func(f archscout.Function) bool {
return len(f.Name) >= 3 &&
f.Name[:3] == "New" &&
f.Receiver == "" &&
f.Name[0] >= 'A' && f.Name[0] <= 'Z'
})There is no "escape hatch" needed — the item is the data. This makes archscout equally useful for ad-hoc exploration and for hardening automation that runs in CI.
go get github.com/saintedlama/archscoutpackage architecture_test
import (
"context"
"testing"
"github.com/saintedlama/archscout"
)
func TestDomainDoesNotDependOnInfrastructure(t *testing.T) {
workspace, err := archscout.LoadWorkspace(context.Background(), ".")
if err != nil {
t.Fatalf("LoadWorkspace failed: %v", err)
}
rule := archscout.Rule("domain must not depend on infrastructure").
Dependencies().
InPackage("github.com/your-project/domain/...").
DependOn("github.com/your-project/infrastructure/...")
rule.Test(t, workspace)
}import "github.com/saintedlama/archscout"
refs := workspace.FunctionCalls.
InPackage("github.com/your-project/...").
IsNotTest().
Match(func(call archscout.FunctionCall) bool {
return call.Callee == "fmt.Errorf"
})Each FunctionCall also carries the function declaration that lexically
encloses the call site. This is useful for asking who, exactly, is calling
something:
// Every method on *Service that calls fmt.Errorf.
refs := workspace.FunctionCalls.Match(func(call archscout.FunctionCall) bool {
return call.Callee == "fmt.Errorf" &&
call.CallerReceiver == "*Service"
})CallerName and CallerReceiver are empty for calls that appear at package
level (for example, inside a var x = foo() initializer). For methods,
CallerReceiver mirrors the raw receiver text from Function.Receiver
(e.g. "*Service" for a pointer receiver, "Service" for a value receiver).
CallerQName is the canonical fully-qualified name of the enclosing
function (or empty at package level), composed identically to
Function.QName. It's the join key when correlating calls back to function
declarations:
// Every method on *Service, with each one's set of distinct callees.
calls := workspace.FunctionCalls.Match(func(call archscout.FunctionCall) bool {
return call.CallerQName == "github.com/your-project/api.Service.Run"
})Function.QName and Type.QName are always populated and follow the same
convention: <importpath>.<Name> for plain functions and types,
<importpath>.<RecvType>.<Name> for methods (pointer indirection on the
receiver stripped). They're the identifier you'd persist in any external
graph or store; the unqualified Name + Receiver remain available for
display purposes.
The default Callee field is the syntactic callee text from source — it's
useful for grep-style matches but treats crypto.Sign and c.Sign (a method
on a type aliased crypto) as different callees. For cross-package edges,
load with WithTypeInfo() to get fully-qualified resolution:
ws, err := archscout.LoadWorkspace(ctx, ".", archscout.WithTypeInfo())
// Every call into the strings package, regardless of how it was written.
refs := ws.FunctionCalls.Match(func(call archscout.FunctionCall) bool {
return call.CalleePackage == "strings"
})
// All method calls on api.Service.
refs = ws.FunctionCalls.Match(func(call archscout.FunctionCall) bool {
return call.CalleeIsMethod &&
call.CalleeQName == "example.com/your-project/api.Service.Run"
})CalleeQName has the form <importpath>.<TypeName>.<MethodName> for methods
(pointer indirection on the receiver is stripped) and <importpath>.<FuncName>
for plain functions. For interface dispatch, the qname resolves to the
interface's defining method — type information alone cannot know the dynamic
implementer at runtime.
WithTypeInfo() is opt-in because loading full Go type information is
substantially slower and uses more memory than the default mode. The disk
cache fingerprints type-info loads separately, so toggling the option will
not return stale, partially-populated workspaces.
import "github.com/saintedlama/archscout"
forbidden := map[string]bool{"panic": true, "os.Exit": true}
rule := archscout.Rule("panic and os.Exit forbidden in library code").
FunctionCalls().
InPackage("github.com/your-project/...").
NotInPackage("github.com/your-project/internal/...").
IsNotTest().
Match(func(fc archscout.FunctionCall) bool {
return forbidden[fc.Callee]
})
rule.Test(t, workspace)Use ShouldExist() on Packages, Types, and Functions rules to assert that at
least one entry survives the filter chain. Combine with Match to pin a specific item:
import "github.com/saintedlama/archscout"
archscout.Rule("domain package must exist").
Packages().
InPackage("github.com/your-project/domain").
ShouldExist().
Test(t, workspace)
archscout.Rule("Repository interface must be defined in domain").
Types().
InPackage("github.com/your-project/domain").
ShouldExist().
Match(func(t archscout.Type) bool { return t.Name == "Repository" }).
Test(t, workspace)Dependency checks can be done directly or through files/packages.
import "github.com/saintedlama/archscout"
rule := archscout.Rule("files with no stdlib deps").
Files().
Match(func(file archscout.File) bool {
return file.Dependencies().IsStandardLibrary().Len() == 0
})
rule.Test(t, workspace)For hierarchy-style reporting, use workspace.Dependencies.Tree().
Three aggregation helpers make it easy to answer high-level questions without counting raw import statements:
import (
"fmt"
"github.com/saintedlama/archscout"
)
mod := archscout.Module("github.com/your-project")
// What does the UI layer reach (workspace-internal, non-test)?
targets := workspace.Dependencies.
InPackage(mod.Pkg("ui/...")).
IsNotTest().
IsWithinWorkspace().
UniqueTargets()
// → ["github.com/your-project/audio", "github.com/your-project/domain", ...]
// Who imports the domain layer?
importers := workspace.Dependencies.
DependOn(mod.Pkg("domain/...")).
IsNotTest().
UniqueSourcePackages()
// → ["github.com/your-project/application", "github.com/your-project/ui/tracker", ...]
// Full per-package breakdown
for pkg, deps := range workspace.Dependencies.IsNotTest().IsWithinWorkspace().GroupBySourcePackage() {
fmt.Printf("%s → %v\n", pkg, deps.UniqueTargets())
}Use Module to avoid repeating the module path across patterns:
import "github.com/saintedlama/archscout"
mod := archscout.Module("github.com/your-project")
archscout.Rule("ui/common must not depend on other internal packages").
Dependencies().
InPackage(mod.Pkg("ui/common/...")).
IsNotTest().
DependOn(mod.Pkgs(
"audio/...",
"persistence/...",
"player/...",
)...).
Test(t, workspace)mod.Pkg("sub/path") returns a single fully-qualified pattern.
mod.Pkgs("a/...", "b/...") returns a []string of fully-qualified patterns.
archscout exposes seven collections on Workspace:
| Field | Item type | Notable fields |
|---|---|---|
Packages |
Package |
ID, Name, Files, Dependencies() |
Files |
File |
Filename, Dependencies() |
Types |
Type |
Name, QName, Kind, Fields, Methods, Embeds |
Functions |
Function |
Name, QName, Receiver |
Variables |
Variable |
Name, Kind |
FunctionCalls |
FunctionCall |
Callee, CalleePackage, CalleeQName, CalleeIsMethod, CallerName, CallerReceiver, CallerQName |
Dependencies |
Dependency |
ImportPath, WithinWorkspace, External, StandardLibrary, TargetPackageName |
All collections support:
| Method | Description |
|---|---|
All() |
Returns a snapshot slice of all items |
Len() |
Number of items |
Match(func) |
Applies a predicate; returns matching Refs |
InPackage(patterns...) |
Keeps items whose source package matches any pattern |
NotInPackage(patterns...) |
Excludes items whose source package matches any pattern |
IsTest() |
Keeps items from _test.go files |
IsNotTest() |
Excludes items from _test.go files |
Dependencies additionally support:
| Method | Description |
|---|---|
DependOn(patterns...) |
Keeps items whose import path matches any pattern |
DependsOn(pattern) |
Keeps items whose import path matches a single pattern |
DoNotDependOn(patterns...) |
Excludes items whose import path matches any pattern |
IsWithinWorkspace() |
Keeps imports that resolve to workspace packages |
IsExternal() |
Keeps imports that resolve outside the workspace |
IsStandardLibrary() |
Keeps standard library imports |
IsThirdParty() |
Keeps external, non-stdlib imports |
UniqueTargets() |
Sorted, deduplicated import paths in the collection |
UniqueSourcePackages() |
Sorted, deduplicated source package IDs in the collection |
GroupBySourcePackage() |
Partitions into one sub-collection per source package |
GroupByTargetPackage() |
Partitions into one sub-collection per imported package |
Tree() |
Builds a hierarchical TreeNode from import paths |
Enable disk cache to make repeated LoadWorkspace calls much faster on large
codebases, especially when exploring them interactively. Cache entries are
invalidated automatically when .go files or go.sum change.
Use zero-config caching:
import (
"context"
"github.com/saintedlama/archscout"
)
workspace, err := archscout.LoadWorkspace(
context.Background(), ".",
archscout.WithDiskCache(),
archscout.WithReporter(func(msg string) { fmt.Println(msg) }),
)Use WithDiskCacheDir(dir) when you need an explicit location (for example in CI):
workspace, err := archscout.LoadWorkspace(
context.Background(), ".",
archscout.WithDiskCacheDir("/tmp/my-project-cache"),
)Note: After loading from disk cache, AST
Nodefields arenil. Normal filters and rule checks continue to work.
BuildPackageGraph converts a dependency collection into a directed graph that
supports transitive reachability queries. It only considers workspace-internal
imports, so filter the collection first if needed:
import "github.com/saintedlama/archscout"
mod := archscout.Module("github.com/your-project")
graph := archscout.BuildPackageGraph(
workspace.Dependencies.IsNotTest().IsWithinWorkspace(),
)
// All workspace packages in the graph
pkgs := graph.Packages()
// Direct imports of the application layer
direct := graph.DirectDependencies(mod.Pkg("application/..."))
// Everything reachable (any number of hops) from the UI layer
all := graph.TransitiveDependencies(mod.Pkg("ui/..."))
// Does domain ever (transitively) reach infrastructure?
if graph.TransitivelyReaches(
[]string{mod.Pkg("domain/...")},
[]string{mod.Pkg("infrastructure/...")},
) {
t.Error("domain must not depend on infrastructure")
}
// Single-hop version of the same check
if graph.DirectlyReaches(
[]string{mod.Pkg("application/...")},
[]string{mod.Pkg("infrastructure/...")},
) {
t.Error("application must not directly import infrastructure")
}
// Who imports the domain layer?
importers := graph.Importers(mod.Pkg("domain/..."))
// → ["github.com/your-project/application", "github.com/your-project/ui/tracker"]PackageGraph methods:
| Method | Description |
|---|---|
Packages() |
Sorted set of all package IDs (sources and targets) |
DirectDependencies(patterns...) |
Packages directly imported by packages matching patterns |
TransitiveDependencies(patterns...) |
All packages reachable via one or more hops from matching packages |
TransitivelyReaches(fromPatterns, toPatterns) |
Reports whether any matching source can reach any matching target |
DirectlyReaches(fromPatterns, toPatterns) |
Same as above but only considers single-hop edges |
Importers(patterns...) |
Packages that directly import any package matching patterns |
All methods support the /... glob convention.
When a workspace is loaded with WithTypeInfo(), BuildImplementsGraph lets
you ask which concrete types satisfy a given interface and which interfaces
a given type implements:
import "github.com/saintedlama/archscout"
ws, err := archscout.LoadWorkspace(ctx, ".", archscout.WithTypeInfo())
graph := archscout.BuildImplementsGraph(ws)
// Who implements example.com/api.Greeter?
for _, qname := range graph.Implementers("example.com/api.Greeter") {
fmt.Println(qname)
}
// Restrict to a specific package — useful when generated mocks should be
// excluded from a "who satisfies this in production code?" query.
real := graph.Implementers(
"example.com/api.Greeter",
"example.com/your-project/...",
)
// Which interfaces does example.com/your-project.PointerGreeter satisfy?
ifaces := graph.Interfaces("example.com/your-project.PointerGreeter")Empty interfaces (interface{} / any) are intentionally not indexed —
every concrete type trivially implements them, which is rarely the answer
you want. A type that satisfies an interface only via its pointer method set
is still listed; the receiver semantics live in the underlying *types.Named
if you need them.
BuildImplementsGraph returns an empty (but safe) graph when the workspace
was loaded without WithTypeInfo() or restored from a disk cache, since type
information is not serialized.
ImplementsGraph methods:
| Method | Description |
|---|---|
Implementers(ifaceQName, patterns...) |
Sorted concrete-type qnames that satisfy the interface, optionally filtered by package |
Interfaces(typeQName) |
Sorted interface qnames the given type satisfies |
Type items expose the inner shape of structs and interfaces:
import "github.com/saintedlama/archscout"
ws, err := archscout.LoadWorkspace(ctx, ".", archscout.WithTypeInfo())
// Find every struct that embeds inner.Base.
for _, t := range ws.Types.All() {
for _, embed := range t.Embeds {
if embed == "example.com/your-project/inner.Base" {
fmt.Println(t.Name, "embeds inner.Base")
}
}
}
// Find every interface with at least three directly declared methods.
for _, t := range ws.Types.All() {
if t.Kind == "interface" && len(t.Methods) >= 3 {
fmt.Println(t.Name)
}
}Fields is the declared struct fields, including embedded entries
(Embedded == true, Name == ""). Multi-name fields like
Age, Year int fan out to one FieldInfo per name. Tags are returned
without the surrounding backticks. With WithTypeInfo(), TypeQName
resolves cross-package field types; without it, only the syntactic
TypeName is populated.
Methods lists only the methods declared directly on an interface.
Methods contributed by embedded interfaces are not flattened in — follow
Embeds for those.
Embeds is a flat list of embedded type identifiers — fully qualified when
WithTypeInfo() is enabled, syntactic source text otherwise. It exists for
both struct embeds and embedded interfaces, so the question "what does this
type compose with?" is one slice access regardless of kind.
Concrete-type methods (those declared via func (T) ...) live in the
Functions collection with a non-empty Receiver; they are intentionally
not duplicated under Type.Methods.
Rule violations are returned as Refs — each Ref identifies a source location:
import (
"fmt"
"github.com/saintedlama/archscout"
)
refs, err := rule.Evaluate(workspace)
fmt.Println(archscout.FormatRefs(refs))
// Customise output
fmt.Println(archscout.FormatRefs(refs,
archscout.WithRefPackage(),
archscout.WithRefKind(),
archscout.WithoutRefColumn(),
))Available format options: WithRefPackage(), WithRefKind(), WithoutRefFile(),
WithoutRefLine(), WithoutRefColumn(), WithoutRefMatch(), WithRefSeparator(sep),
WithoutSeparator().
LoadWorkspace(ctx, dir, opts...) (*Workspace, error)WithReporter(func(string)) LoadWorkspaceOption— progress callbackWithInMemoryCache() LoadWorkspaceOption— reuse a loaded workspace within the processWithDiskCache() LoadWorkspaceOption— persist a workspace snapshot in the platform-default cache directoryWithDiskCacheDir(dir string) LoadWorkspaceOption— persist a workspace snapshot in an explicit directoryWithTypeInfo() LoadWorkspaceOption— load full Go type information soFunctionCall.CalleePackage,CalleeQNameandCalleeIsMethodare populatedModule(path)— helper for building fully-qualified package patternsBuildPackageGraph(c dependencies.Collection) *PackageGraph— builds a transitive package graph from a dependency collectionBuildImplementsGraph(ws *Workspace) *ImplementsGraph— builds an interface-implementation graph from aWithTypeInfo()workspaceRule(name)— entry point for all rule construction
Rule types expose:
- fluent filters (package/test and kind-specific filters)
ShouldExist()— assert at least one match exists (Packages,Types,Functions)Match(func)Evaluate(workspace) (Refs, error)Test(t, workspace)— fails the test if any refs are returned (or none whenShouldExist)
make fmt
make vet
make lint
make build
make test-verboseLoadWorkspaceexpects a Go module directory withgo.mod.WithReporter(...)is optional and useful for progress output.WithInMemoryCache()is optional and reuses a loaded workspace by path.WithDiskCache()is optional; stores cache files inos.UserCacheDir()/archscout(falls back toos.TempDir()/archscout-cache). Different projects sharing the same cache directory never collide because the absolute project path is part of the fingerprint hash.WithDiskCacheDir(dir)is optional; identical toWithDiskCache()but lets you control exactly where cache files are written.- On a cache hit, go/ast
Nodefields (Function.Node,Type.Node, etc.) arenil. All string-based queries, filter chains, and rule checks work normally; only custom predicates that dereference the raw AST pointer are affected. - Pattern matching: a pattern ending in
/...matches the base path and all sub-paths.
This project is licensed under the MIT License. See LICENSE for details.
