Freeze the three fact events, intentflow allowlist, and plan table ownership so later units cannot invent producers or write paths outside app/intentflow. Co-authored-by: Cursor <cursoragent@cursor.com>
459 lines
11 KiB
Go
459 lines
11 KiB
Go
package archtest
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const internalPrefix = "chat-agent-system/internal/"
|
|
|
|
var (
|
|
sqlWriteRe = regexp.MustCompile(`(?is)\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([a-zA-Z_][a-zA-Z0-9_]*)`)
|
|
eventNameRe = regexp.MustCompile(`(?:document|intent\.plan)\.[a-z0-9_.]+`)
|
|
)
|
|
|
|
type fileClass int
|
|
|
|
const (
|
|
classIgnore fileClass = iota
|
|
classProduction
|
|
classTestOrFixture
|
|
classGoose
|
|
)
|
|
|
|
type ScanOptions struct {
|
|
Root string
|
|
// ExtraRoots are additional trees scanned as production Go (used by self-tests).
|
|
ExtraRoots []string
|
|
// ExtraSQL are extra files classified as goose/fixture SQL.
|
|
ExtraSQL []string
|
|
}
|
|
|
|
func Scan(opts ScanOptions, b *Boundaries) ([]Finding, error) {
|
|
var findings []Finding
|
|
roots := append([]string{filepath.Join(opts.Root, "internal")}, opts.ExtraRoots...)
|
|
fset := token.NewFileSet()
|
|
for _, root := range roots {
|
|
if root == "" {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(root); err != nil {
|
|
continue
|
|
}
|
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
name := d.Name()
|
|
if name == "testdata" || name == "vendor" || name == ".git" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
class := classifyFile(opts.Root, path)
|
|
if strings.HasSuffix(path, ".go") {
|
|
switch class {
|
|
case classProduction:
|
|
fs, perr := scanProductionGo(fset, opts.Root, path, b)
|
|
if perr != nil {
|
|
return perr
|
|
}
|
|
findings = append(findings, fs...)
|
|
case classTestOrFixture, classGoose, classIgnore:
|
|
// Goose / tests / fixtures may mention tables and paths; they are not production writers.
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for _, sqlPath := range opts.ExtraSQL {
|
|
// Explicit proof that goose SQL is classified away from production writes.
|
|
if classifyFile(opts.Root, sqlPath) != classGoose && !strings.Contains(filepath.ToSlash(sqlPath), "goose") {
|
|
continue
|
|
}
|
|
}
|
|
return dedupe(findings), nil
|
|
}
|
|
|
|
func classifyFile(repoRoot, path string) fileClass {
|
|
rel := path
|
|
if repoRoot != "" {
|
|
if r, err := filepath.Rel(repoRoot, path); err == nil {
|
|
rel = r
|
|
}
|
|
}
|
|
slash := filepath.ToSlash(rel)
|
|
base := filepath.Base(path)
|
|
if strings.Contains(slash, "/testdata/") || strings.HasPrefix(slash, "testdata/") {
|
|
if strings.Contains(slash, "/goose/") || strings.HasPrefix(base, "000") && strings.HasSuffix(base, ".sql") {
|
|
return classGoose
|
|
}
|
|
if strings.HasSuffix(base, "_test.go") || strings.Contains(slash, "/fixture") || strings.Contains(slash, "/fixtures/") {
|
|
if strings.HasSuffix(base, ".go") && !strings.HasSuffix(base, "_test.go") && strings.Contains(slash, "/prod/") {
|
|
return classProduction
|
|
}
|
|
return classTestOrFixture
|
|
}
|
|
}
|
|
if strings.HasPrefix(slash, "deploy/postgres/goose/") || strings.Contains(slash, "/postgres/goose/") {
|
|
return classGoose
|
|
}
|
|
if strings.HasSuffix(base, "_test.go") {
|
|
return classTestOrFixture
|
|
}
|
|
if strings.HasSuffix(base, ".go") && strings.HasPrefix(slash, "internal/") {
|
|
if strings.HasPrefix(slash, "internal/archtest/") {
|
|
return classIgnore
|
|
}
|
|
return classProduction
|
|
}
|
|
if strings.HasSuffix(base, ".go") && (strings.Contains(slash, "/prod/") || strings.Contains(slash, "fixtures/prod/")) {
|
|
return classProduction
|
|
}
|
|
return classIgnore
|
|
}
|
|
|
|
func scanProductionGo(fset *token.FileSet, repoRoot, path string, b *Boundaries) ([]Finding, error) {
|
|
src, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
file, err := parser.ParseFile(fset, path, src, parser.SkipObjectResolution)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
pkg := packageID(repoRoot, path)
|
|
rel := relFile(repoRoot, path)
|
|
var out []Finding
|
|
out = append(out, scanImports(file, pkg, rel, b)...)
|
|
out = append(out, scanSQLWrites(string(src), pkg, rel, b)...)
|
|
out = append(out, scanPaths(file, pkg, rel, b)...)
|
|
out = append(out, scanEvents(file, string(src), pkg, rel, b)...)
|
|
out = append(out, scanUnwrap(string(src), pkg, rel, b)...)
|
|
out = append(out, scanDocflowLiteral(string(src), pkg, rel)...)
|
|
return out, nil
|
|
}
|
|
|
|
func packageID(repoRoot, path string) string {
|
|
rel := relFile(repoRoot, path)
|
|
slash := filepath.ToSlash(rel)
|
|
slash = strings.TrimPrefix(slash, "internal/")
|
|
dir := filepath.ToSlash(filepath.Dir(slash))
|
|
if dir == "." {
|
|
return filepath.Base(filepath.Dir(path))
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func relFile(repoRoot, path string) string {
|
|
if repoRoot == "" {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
rel, err := filepath.Rel(repoRoot, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
func scanImports(file *ast.File, pkg, rel string, b *Boundaries) []Finding {
|
|
allowed, ok := b.Imports[pkg]
|
|
if !ok {
|
|
// Unknown production package is itself a new import-graph node.
|
|
return []Finding{{Kind: KindImport, Package: pkg, File: rel, Symbol: pkg, Detail: "package missing from import allowlist"}}
|
|
}
|
|
var out []Finding
|
|
for _, spec := range file.Imports {
|
|
path := strings.Trim(spec.Path.Value, `"`)
|
|
if !strings.HasPrefix(path, internalPrefix) {
|
|
continue
|
|
}
|
|
dep := strings.TrimPrefix(path, internalPrefix)
|
|
if dep == pkg {
|
|
continue
|
|
}
|
|
if !allowedHas(allowed, dep) {
|
|
out = append(out, Finding{
|
|
Kind: KindImport,
|
|
Package: pkg,
|
|
File: rel,
|
|
Symbol: dep,
|
|
Detail: fmt.Sprintf("production import %s -> %s", pkg, dep),
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scanSQLWrites(src, pkg, rel string, b *Boundaries) []Finding {
|
|
var out []Finding
|
|
for _, m := range sqlWriteRe.FindAllStringSubmatch(src, -1) {
|
|
table := strings.ToLower(m[2])
|
|
owners, tracked := b.TableOwners[table]
|
|
if !tracked {
|
|
continue
|
|
}
|
|
if allowedHas(owners, pkg) {
|
|
continue
|
|
}
|
|
out = append(out, Finding{
|
|
Kind: KindSQLWrite,
|
|
Package: pkg,
|
|
File: rel,
|
|
Symbol: table,
|
|
Detail: fmt.Sprintf("%s writes %s", strings.ToLower(strings.Join(strings.Fields(m[1]), " ")), table),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scanPaths(file *ast.File, pkg, rel string, b *Boundaries) []Finding {
|
|
consts := map[string]string{}
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
vs, ok := n.(*ast.ValueSpec)
|
|
if !ok {
|
|
return true
|
|
}
|
|
for i, name := range vs.Names {
|
|
if i < len(vs.Values) {
|
|
if lit, ok := vs.Values[i].(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
|
consts[name.Name] = strings.Trim(lit.Value, `"`)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
var out []Finding
|
|
seen := map[string]bool{}
|
|
add := func(symbol, detail string) {
|
|
key := symbol + "|" + detail
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
owners, tracked := b.PathOwners[symbol]
|
|
if !tracked {
|
|
return
|
|
}
|
|
if allowedHas(owners, pkg) {
|
|
return
|
|
}
|
|
out = append(out, Finding{
|
|
Kind: KindPath,
|
|
Package: pkg,
|
|
File: rel,
|
|
Symbol: symbol,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
switch x := n.(type) {
|
|
case *ast.BasicLit:
|
|
if x.Kind != token.STRING {
|
|
return true
|
|
}
|
|
val := strings.Trim(x.Value, `"`)
|
|
for lit := range b.PathOwners {
|
|
if pathLiteralHit(val, lit) {
|
|
add(lit, "string literal "+lit)
|
|
}
|
|
}
|
|
case *ast.Ident:
|
|
if val, ok := consts[x.Name]; ok {
|
|
for lit := range b.PathOwners {
|
|
if pathLiteralHit(val, lit) {
|
|
add(lit, "indirect const "+x.Name)
|
|
}
|
|
}
|
|
}
|
|
case *ast.CallExpr:
|
|
if !isJoinLike(x.Fun) {
|
|
return true
|
|
}
|
|
for _, arg := range x.Args {
|
|
switch a := arg.(type) {
|
|
case *ast.BasicLit:
|
|
if a.Kind == token.STRING {
|
|
val := strings.Trim(a.Value, `"`)
|
|
for lit := range b.PathOwners {
|
|
if pathLiteralHit(val, lit) {
|
|
add(lit, "filepath.Join/path.Join")
|
|
}
|
|
}
|
|
}
|
|
case *ast.Ident:
|
|
if val, ok := consts[a.Name]; ok {
|
|
for lit := range b.PathOwners {
|
|
if pathLiteralHit(val, lit) {
|
|
add(lit, "filepath.Join via const "+a.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case *ast.BinaryExpr:
|
|
if x.Op != token.ADD {
|
|
return true
|
|
}
|
|
vals := []string{litOrConst(x.X, consts), litOrConst(x.Y, consts)}
|
|
for _, val := range vals {
|
|
for lit := range b.PathOwners {
|
|
if pathLiteralHit(val, lit) {
|
|
add(lit, "string concatenation")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return out
|
|
}
|
|
|
|
func litOrConst(e ast.Expr, consts map[string]string) string {
|
|
switch x := e.(type) {
|
|
case *ast.BasicLit:
|
|
if x.Kind == token.STRING {
|
|
return strings.Trim(x.Value, `"`)
|
|
}
|
|
case *ast.Ident:
|
|
return consts[x.Name]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isJoinLike(fun ast.Expr) bool {
|
|
sel, ok := fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
pkg, ok := sel.X.(*ast.Ident)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if sel.Sel.Name != "Join" && sel.Sel.Name != "FromSlash" {
|
|
return false
|
|
}
|
|
return pkg.Name == "filepath" || pkg.Name == "path"
|
|
}
|
|
|
|
func pathLiteralHit(val, lit string) bool {
|
|
if val == "" || lit == "" {
|
|
return false
|
|
}
|
|
if val == lit {
|
|
return true
|
|
}
|
|
return strings.Contains(val, lit)
|
|
}
|
|
|
|
func scanEvents(file *ast.File, src, pkg, rel string, b *Boundaries) []Finding {
|
|
var out []Finding
|
|
seen := map[string]bool{}
|
|
add := func(symbol, role, detail string) {
|
|
key := symbol + "|" + role
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
var owners []string
|
|
var tracked bool
|
|
if role == "producer" {
|
|
owners, tracked = b.EventProducers[symbol]
|
|
} else {
|
|
owners, tracked = b.EventConsumers[symbol]
|
|
}
|
|
if !tracked {
|
|
return
|
|
}
|
|
if allowedHas(owners, pkg) {
|
|
return
|
|
}
|
|
out = append(out, Finding{
|
|
Kind: KindEvent,
|
|
Package: pkg,
|
|
File: rel,
|
|
Symbol: symbol,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
if strings.Contains(src, "OnOutbox") {
|
|
isAssign := strings.Contains(src, "OnOutbox =")
|
|
isCallOrField := strings.Contains(src, "OnOutbox(") || strings.Contains(src, "OnOutbox func(")
|
|
if isCallOrField && !isAssign {
|
|
add("outbox.notify", "producer", "OnOutbox hook")
|
|
}
|
|
if isAssign {
|
|
add("outbox.notify", "consumer", "OnOutbox assignment")
|
|
}
|
|
}
|
|
for _, name := range eventNameRe.FindAllString(src, -1) {
|
|
if _, ok := b.EventProducers[name]; ok {
|
|
if allowedHas(b.EventConsumers[name], pkg) || allowedHas(b.EventConsumers["outbox.notify"], pkg) {
|
|
continue
|
|
}
|
|
add(name, "producer", "event name literal")
|
|
}
|
|
}
|
|
_ = file
|
|
return out
|
|
}
|
|
|
|
func scanUnwrap(src, pkg, rel string, b *Boundaries) []Finding {
|
|
if !strings.Contains(src, "uow.AsPG") {
|
|
return nil
|
|
}
|
|
if allowedHas(b.UnwrapOwners, pkg) {
|
|
return nil
|
|
}
|
|
return []Finding{{
|
|
Kind: KindImport,
|
|
Package: pkg,
|
|
File: rel,
|
|
Symbol: "uow.AsPG",
|
|
Detail: "application layer must not unwrap TxScope",
|
|
}}
|
|
}
|
|
|
|
func scanDocflowLiteral(src, pkg, rel string) []Finding {
|
|
if pkg != "server" {
|
|
return nil
|
|
}
|
|
if !strings.Contains(src, "docflow.Commands{") {
|
|
return nil
|
|
}
|
|
return []Finding{{
|
|
Kind: KindImport,
|
|
Package: pkg,
|
|
File: rel,
|
|
Symbol: "docflow.Commands",
|
|
Detail: "HTTP adapters must use the composition-root Commands instance",
|
|
}}
|
|
}
|
|
|
|
func dedupe(in []Finding) []Finding {
|
|
seen := map[string]bool{}
|
|
var out []Finding
|
|
for _, f := range in {
|
|
k := f.Key()
|
|
if seen[k] {
|
|
continue
|
|
}
|
|
seen[k] = true
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|