Add an architecture edge ratchet and waiting-state tokens
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>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// 分层依赖棘轮: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
|
||||
}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# 分层棘轮:新增依赖边失败,消失的边只告警。
|
||||
# 负例:注入一条基线里没有的边,必须失败。
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
base="$root/policy/architecture-baseline.json"
|
||||
tool=(go run "$root/scripts/lint-architecture.go" -root "$root" -baseline "$base")
|
||||
|
||||
( cd "$root/scripts" && "${tool[@]}" )
|
||||
|
||||
set +e
|
||||
( cd "$root/scripts" && "${tool[@]}" -inject 'backend/store->backend/__architecture_probe__' ) >/tmp/architecture-probe.out 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
if [ "$code" -eq 0 ]; then
|
||||
echo "architecture: injected edge was accepted" >&2
|
||||
cat /tmp/architecture-probe.out >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '__architecture_probe__' /tmp/architecture-probe.out; then
|
||||
echo "architecture: injected edge did not fail for the expected reason" >&2
|
||||
cat /tmp/architecture-probe.out >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f /tmp/architecture-probe.out
|
||||
echo "architecture negative ok"
|
||||
@@ -96,4 +96,5 @@ done < <(find "$root/deps/agent-native" "$root/deps/agent-squad" "$root/modules/
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
bash "$root/scripts/lint-architecture.sh"
|
||||
echo "boundaries ok"
|
||||
|
||||
Reference in New Issue
Block a user