Why: New cross-layer imports and unused exports could land unnoticed, and confirm-waiting reused the success color. Evidence: bash scripts/lint-boundaries.sh passed, including a probe that a new edge fails. Both design-guard tests passed. Knip counts stay at the baselines. Impact: make test-boundaries now checks architecture edges. Frontend and ui test targets also run the knip ratchet. Refs: feature/architecture-baseline Co-authored-by: Cursor <cursoragent@cursor.com>
271 lines
6.2 KiB
Go
271 lines
6.2 KiB
Go
// 分层依赖棘轮: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
|
|
}
|