detached 25e2a20

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 @@
11 package cli
22
33 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
47 "os"
58 "strings"
69 "testing"
@@ -67,14 +70,24 @@ func TestUIAdoptionMatrixCoverage(t *testing.T) {
6770 t.Fatalf("ui adoption matrix for %s is empty", expect.family)
6871 }
6972
70- content, err := os.ReadFile(expect.file)
73+ fset := token.NewFileSet()
74+ fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
7175 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)
7377 }
7478
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+
7689 for _, fn := range expect.functions {
77- if !strings.Contains(source, "func "+fn+"(") {
90+ if !found[fn] {
7891 t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file)
7992 }
8093 }
@@ -1,6 +1,9 @@
1 package cli1 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 {
2626 }
2727
2828 func cliRenderEmptyState(msg string) string {
29- return styles.Theme.Muted.Render(msg)
29+ return cliRenderMuted(msg)
3030 }
3131
3232 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) {
8484 hasHelperCall = true
8585 }
8686 case *ast.SelectorExpr:
87- if isForbiddenRawPrintCall(fun) {
87+ if isForbiddenRawPrintCall(call, fun) {
8888 hasForbiddenRawPrint = true
8989 }
9090 pkgIdent, ok := fun.X.(*ast.Ident)
@@ -112,7 +112,7 @@ func TestUIAdoption(t *testing.T) {
112112 }
113113 }
114114
115-func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool {
115+func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool {
116116 pkgIdent, ok := sel.X.(*ast.Ident)
117117 if !ok {
118118 return false
@@ -120,8 +120,25 @@ func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool {
120120
121121 if pkgIdent.Name == "fmt" {
122122 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+ }
125142 }
126143 }
127144
@@ -84,7 +84,7 @@ func TestUIAdoption(t *testing.T) {
84 hasHelperCall = true84 hasHelperCall = true
85 }85 }
86 case *ast.SelectorExpr:86 case *ast.SelectorExpr:
87- if isForbiddenRawPrintCall(fun) {87+ if isForbiddenRawPrintCall(call, fun) {
88 hasForbiddenRawPrint = true88 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 false118 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 true124+ 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close