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_.]+`) ) // sqlScanNoise 扫描正则的已知假阳性词汇:来自 SQL 子句(ON CONFLICT DO // UPDATE SET、FOR UPDATE OF/SKIP LOCKED)与注释散文,不是真实表名。 // 它们不是策略豁免:新出现的未登记表仍会 fail-closed。 var sqlScanNoise = map[string]bool{ "set": true, "of": true, "skip": true, "below": true, "row": true, "state": true, "step": true, "doc": true, "docgov": true, "message": true, "project": true, "aggregation": true, "ref_count": true, } 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 { // FIND-ARCH-01 回归:未登记表不再静默跳过(fail-closed)。 // 仅豁免扫描正则的已知假阳性词汇;真实新表必须登记 table_owners。 if !sqlScanNoise[table] { out = append(out, Finding{ Kind: KindSQLWrite, Package: pkg, File: rel, Symbol: table, Detail: fmt.Sprintf("write to untracked table %s: register it in module-boundaries.yaml table_owners", table), }) } 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 }