test(cli): tighten UI adoption guardrails
Detect forbidden fmt Fprint writes to stdout paths, switch matrix coverage to AST-based function detection, and deduplicate empty-state rendering helper.
- commit
- 25e2a206e29ee5089464f1dbc21deae759607539
- author
- brice · · 2026-02-12 17:50 UTC
- parent
- 3679601
- signature
- unsigned
3 files · +39 -9
M internal/adapters/in/cli/parity_matrix_test.go +17 -4
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "go/ast" | |
| 5 | + | "go/parser" | |
| 6 | + | "go/token" | |
| 4 | 7 | "os" | |
| 5 | 8 | "strings" | |
| 6 | 9 | "testing" | |
| @@ -67,14 +70,24 @@ func TestUIAdoptionMatrixCoverage(t *testing.T) { | |||
| 67 | 70 | t.Fatalf("ui adoption matrix for %s is empty", expect.family) | |
| 68 | 71 | } | |
| 69 | 72 | ||
| 70 | - | content, err := os.ReadFile(expect.file) | |
| 73 | + | fset := token.NewFileSet() | |
| 74 | + | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |
| 71 | 75 | if err != nil { | |
| 72 | - | t.Fatalf("failed to read %s: %v", expect.file, err) | |
| 76 | + | t.Fatalf("failed to parse %s: %v", expect.file, err) | |
| 73 | 77 | } | |
| 74 | 78 | ||
| 75 | - | source := string(content) | |
| 79 | + | found := make(map[string]bool, len(expect.functions)) | |
| 80 | + | ast.Inspect(fileNode, func(n ast.Node) bool { | |
| 81 | + | fn, ok := n.(*ast.FuncDecl) | |
| 82 | + | if !ok { | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | found[fn.Name.Name] = true | |
| 86 | + | return true | |
| 87 | + | }) | |
| 88 | + | ||
| 76 | 89 | for _, fn := range expect.functions { | |
| 77 | - | if !strings.Contains(source, "func "+fn+"(") { | |
| 90 | + | if !found[fn] { | |
| 78 | 91 | t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file) | |
| 79 | 92 | } | |
| 80 | 93 | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "go/ast" | |||
| 5 | + | "go/parser" | |||
| 6 | + | "go/token" | |||
| 4 | "os" | 7 | "os" | ||
| 5 | "strings" | 8 | "strings" | ||
| 6 | "testing" | 9 | "testing" | ||
| @@ -67,14 +70,24 @@ func TestUIAdoptionMatrixCoverage(t *testing.T) { | |||||
| 67 | t.Fatalf("ui adoption matrix for %s is empty", expect.family) | 70 | t.Fatalf("ui adoption matrix for %s is empty", expect.family) | ||
| 68 | } | 71 | } | ||
| 69 | 72 | ||||
| 70 | - | content, err := os.ReadFile(expect.file) | 73 | + | fset := token.NewFileSet() |
| 74 | + | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |||
| 71 | if err != nil { | 75 | if err != nil { | ||
| 72 | - | t.Fatalf("failed to read %s: %v", expect.file, err) | 76 | + | t.Fatalf("failed to parse %s: %v", expect.file, err) |
| 73 | } | 77 | } | ||
| 74 | 78 | ||||
| 75 | - | source := string(content) | 79 | + | found := make(map[string]bool, len(expect.functions)) |
| 80 | + | ast.Inspect(fileNode, func(n ast.Node) bool { | |||
| 81 | + | fn, ok := n.(*ast.FuncDecl) | |||
| 82 | + | if !ok { | |||
| 83 | + | return true | |||
| 84 | + | } | |||
| 85 | + | found[fn.Name.Name] = true | |||
| 86 | + | return true | |||
| 87 | + | }) | |||
| 88 | + | ||||
| 76 | for _, fn := range expect.functions { | 89 | for _, fn := range expect.functions { | ||
| 77 | - | if !strings.Contains(source, "func "+fn+"(") { | 90 | + | if !found[fn] { |
| 78 | t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file) | 91 | t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file) | ||
| 79 | } | 92 | } | ||
| 80 | } | 93 | } | ||
M internal/adapters/in/cli/presentation.go +1 -1
| @@ -26,7 +26,7 @@ func cliRenderMuted(msg string) string { | |||
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | func cliRenderEmptyState(msg string) string { | |
| 29 | - | return styles.Theme.Muted.Render(msg) | |
| 29 | + | return cliRenderMuted(msg) | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | func cliRenderListItem(msg string) string { | |
| @@ -26,7 +26,7 @@ func cliRenderMuted(msg string) string { | |||||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | func cliRenderEmptyState(msg string) string { | 28 | func cliRenderEmptyState(msg string) string { | ||
| 29 | - | return styles.Theme.Muted.Render(msg) | 29 | + | return cliRenderMuted(msg) |
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | func cliRenderListItem(msg string) string { | 32 | func cliRenderListItem(msg string) string { | ||
M internal/adapters/in/cli/ui_adoption_test.go +21 -4
| @@ -84,7 +84,7 @@ func TestUIAdoption(t *testing.T) { | |||
| 84 | 84 | hasHelperCall = true | |
| 85 | 85 | } | |
| 86 | 86 | case *ast.SelectorExpr: | |
| 87 | - | if isForbiddenRawPrintCall(fun) { | |
| 87 | + | if isForbiddenRawPrintCall(call, fun) { | |
| 88 | 88 | hasForbiddenRawPrint = true | |
| 89 | 89 | } | |
| 90 | 90 | pkgIdent, ok := fun.X.(*ast.Ident) | |
| @@ -112,7 +112,7 @@ func TestUIAdoption(t *testing.T) { | |||
| 112 | 112 | } | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | - | func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool { | |
| 115 | + | func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool { | |
| 116 | 116 | pkgIdent, ok := sel.X.(*ast.Ident) | |
| 117 | 117 | if !ok { | |
| 118 | 118 | return false | |
| @@ -120,8 +120,25 @@ func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool { | |||
| 120 | 120 | ||
| 121 | 121 | if pkgIdent.Name == "fmt" { | |
| 122 | 122 | switch sel.Sel.Name { | |
| 123 | - | case "Print", "Printf", "Println": | |
| 124 | - | return true | |
| 123 | + | case "Print", "Printf", "Println", "Fprint", "Fprintf", "Fprintln": | |
| 124 | + | if sel.Sel.Name == "Print" || sel.Sel.Name == "Printf" || sel.Sel.Name == "Println" { | |
| 125 | + | return true | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | if len(call.Args) == 0 { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | switch dst := call.Args[0].(type) { | |
| 133 | + | case *ast.SelectorExpr: | |
| 134 | + | if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") { | |
| 135 | + | return true | |
| 136 | + | } | |
| 137 | + | case *ast.Ident: | |
| 138 | + | if dst.Name == "out" { | |
| 139 | + | return true | |
| 140 | + | } | |
| 141 | + | } | |
| 125 | 142 | } | |
| 126 | 143 | } | |
| 127 | 144 | ||
| @@ -84,7 +84,7 @@ func TestUIAdoption(t *testing.T) { | |||||
| 84 | hasHelperCall = true | 84 | hasHelperCall = true | ||
| 85 | } | 85 | } | ||
| 86 | case *ast.SelectorExpr: | 86 | case *ast.SelectorExpr: | ||
| 87 | - | if isForbiddenRawPrintCall(fun) { | 87 | + | if isForbiddenRawPrintCall(call, fun) { |
| 88 | hasForbiddenRawPrint = true | 88 | hasForbiddenRawPrint = true | ||
| 89 | } | 89 | } | ||
| 90 | pkgIdent, ok := fun.X.(*ast.Ident) | 90 | pkgIdent, ok := fun.X.(*ast.Ident) | ||
| @@ -112,7 +112,7 @@ func TestUIAdoption(t *testing.T) { | |||||
| 112 | } | 112 | } | ||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | - | func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool { | 115 | + | func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool { |
| 116 | pkgIdent, ok := sel.X.(*ast.Ident) | 116 | pkgIdent, ok := sel.X.(*ast.Ident) | ||
| 117 | if !ok { | 117 | if !ok { | ||
| 118 | return false | 118 | return false | ||
| @@ -120,8 +120,25 @@ func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool { | |||||
| 120 | 120 | ||||
| 121 | if pkgIdent.Name == "fmt" { | 121 | if pkgIdent.Name == "fmt" { | ||
| 122 | switch sel.Sel.Name { | 122 | switch sel.Sel.Name { | ||
| 123 | - | case "Print", "Printf", "Println": | 123 | + | case "Print", "Printf", "Println", "Fprint", "Fprintf", "Fprintln": |
| 124 | - | return true | 124 | + | if sel.Sel.Name == "Print" || sel.Sel.Name == "Printf" || sel.Sel.Name == "Println" { |
| 125 | + | return true | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | if len(call.Args) == 0 { | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | switch dst := call.Args[0].(type) { | |||
| 133 | + | case *ast.SelectorExpr: | |||
| 134 | + | if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") { | |||
| 135 | + | return true | |||
| 136 | + | } | |||
| 137 | + | case *ast.Ident: | |||
| 138 | + | if dst.Name == "out" { | |||
| 139 | + | return true | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 125 | } | 142 | } | ||
| 126 | } | 143 | } | ||
| 127 | 144 | ||||