// 分层依赖棘轮:backend internal 包与前端目录组之间的导入边。 // 相对基线多出来的边失败;基线里已经消失的边只告警。 package main import ( "bufio" "bytes" "encoding/json" "flag" "fmt" "go/parser" "go/token" "os" "path/filepath" "sort" "strings" ) func main() { root := flag.String("root", ".", "repository root") baseline := flag.String("baseline", "policy/architecture-baseline.json", "recorded edges") write := flag.Bool("write", false, "rewrite the baseline from the current tree") inject := flag.String("inject", "", "extra edge from->to, used to prove a new edge fails") flag.Parse() if err := checkPolicy(*root); err != nil { fmt.Fprintf(os.Stderr, "architecture: %v\n", err) os.Exit(1) } edges, err := collect(*root) if err != nil { fmt.Fprintf(os.Stderr, "architecture: %v\n", err) os.Exit(1) } if *inject != "" { parts := strings.Split(*inject, "->") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { fmt.Fprintln(os.Stderr, "architecture: -inject must be from->to") os.Exit(2) } edges = append(edges, parts[0]+" "+parts[1]) } sort.Strings(edges) edges = unique(edges) if *write { raw, err := json.MarshalIndent(edges, "", " ") if err != nil { fmt.Fprintf(os.Stderr, "architecture: %v\n", err) os.Exit(1) } raw = append(raw, '\n') if err := os.WriteFile(*baseline, raw, 0o644); err != nil { fmt.Fprintf(os.Stderr, "architecture: %v\n", err) os.Exit(1) } fmt.Printf("wrote %d edges\n", len(edges)) return } recorded, err := readBaseline(*baseline) if err != nil { fmt.Fprintf(os.Stderr, "architecture: %v\n", err) os.Exit(1) } observed := map[string]bool{} for _, e := range edges { observed[e] = true } var fresh, stale []string for _, e := range edges { if !recorded[e] { fresh = append(fresh, e) } } for e := range recorded { if !observed[e] { stale = append(stale, e) } } sort.Strings(stale) if len(stale) > 0 { fmt.Fprintln(os.Stderr, "warn: architecture edges no longer present; delete them from the baseline") for _, e := range stale { fmt.Fprintln(os.Stderr, " ", e) } } if len(fresh) > 0 { fmt.Fprintln(os.Stderr, "architecture: new dependency edges") for _, e := range fresh { fmt.Fprintln(os.Stderr, " ", e) } os.Exit(1) } fmt.Printf("architecture ok (%d edges)\n", len(edges)) } func readBaseline(path string) (map[string]bool, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } var list []string if err := json.Unmarshal(raw, &list); err != nil { return nil, err } out := map[string]bool{} for _, e := range list { out[e] = true } return out, nil } func checkPolicy(root string) error { raw, err := os.ReadFile(filepath.Join(root, "policy/architecture.yaml")) if err != nil { return err } text := string(raw) for _, need := range []string{"modules/backend/internal", "modules/frontend/src", "components"} { if !strings.Contains(text, need) { return fmt.Errorf("policy/architecture.yaml missing %s", need) } } return nil } func collect(root string) ([]string, error) { var edges []string be, err := backendEdges(filepath.Join(root, "modules/backend/internal")) if err != nil { return nil, err } edges = append(edges, be...) fe, err := frontendEdges(filepath.Join(root, "modules/frontend/src")) if err != nil { return nil, err } edges = append(edges, fe...) return edges, nil } func backendEdges(dir string) ([]string, error) { var edges []string err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil } from := backendNode(dir, path) if from == "" { return nil } fset := token.NewFileSet() f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) if err != nil { return err } for _, imp := range f.Imports { p := strings.Trim(imp.Path.Value, `"`) const prefix = "agent-system-backend/internal/" if !strings.HasPrefix(p, prefix) { continue } rest := strings.TrimPrefix(p, prefix) seg := strings.Split(rest, "/")[0] to := "backend/" + seg if to == from { continue } edges = append(edges, from+" "+to) } return nil }) return edges, err } func backendNode(root, path string) string { rel, err := filepath.Rel(root, path) if err != nil { return "" } seg := strings.Split(filepath.ToSlash(rel), "/")[0] if seg == "" || seg == "." { return "" } return "backend/" + seg } func frontendEdges(src string) ([]string, error) { var edges []string err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if !strings.HasSuffix(path, ".ts") && !strings.HasSuffix(path, ".tsx") { return nil } if strings.HasSuffix(path, ".d.ts") { return nil } from := frontendNode(src, path) text, err := os.ReadFile(path) if err != nil { return err } for _, spec := range importSpecs(text) { if !strings.HasPrefix(spec, ".") { continue } target := filepath.Clean(filepath.Join(filepath.Dir(path), spec)) if !strings.HasPrefix(target, src) { continue } to := frontendNode(src, target) if to == "" || to == from { continue } edges = append(edges, from+" "+to) } return nil }) return edges, err } func frontendNode(src, path string) string { rel, err := filepath.Rel(src, path) if err != nil { return "" } rel = filepath.ToSlash(rel) if strings.HasPrefix(rel, "components/") || rel == "components" { return "frontend/components" } return "frontend/app" } func importSpecs(src []byte) []string { var specs []string sc := bufio.NewScanner(bytes.NewReader(src)) for sc.Scan() { line := sc.Text() for _, marker := range []string{"from '", `from "`, "import('", `import("`} { i := strings.Index(line, marker) if i < 0 { continue } rest := line[i+len(marker):] quote := marker[len(marker)-1] end := strings.IndexByte(rest, quote) if end > 0 { specs = append(specs, rest[:end]) } } } return specs } func unique(in []string) []string { if len(in) == 0 { return in } out := in[:1] for _, s := range in[1:] { if s != out[len(out)-1] { out = append(out, s) } } return out }