feat(frontend-check): flag raw <button> in JSX — use the shared <Button> component

New rules no-raw-button / no-raw-button-in-plugin ride inside Check 5's
walkers, same as the browser-confirm rules. Raw buttons bypass the
action=/entity= automation attributes (docs/BUTTON_AUTOMATION.md), so
MCP/Puppeteer can't target them.

Scope: .tsx/.jsx only (plain .ts builds DOM strings for non-React
surfaces), components/ui/ exempt (defines the primitives — same
carve-out as the button-automation check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-06-10 15:15:46 +08:00
parent fbe14f6c57
commit c3bb958f23
2 changed files with 142 additions and 0 deletions

View File

@ -92,6 +92,11 @@ var (
// Anti-pattern: browser confirm() instead of the shared confirm dialog
reWindowConfirm = regexp.MustCompile(`\bwindow\.confirm\s*\(`)
reBareConfirm = regexp.MustCompile(`(?:^|[^\w.])confirm\s*\(`)
// Anti-pattern: raw <button> in JSX instead of the shared <Button> component.
// Raw buttons bypass the action=/entity= automation attributes
// (docs/BUTTON_AUTOMATION.md), so MCP/Puppeteer can't target them.
reRawButton = regexp.MustCompile(`<button\b`)
)
func checkFrontend(webSrcDir string) (violations []frontendViolation, warnings []frontendViolation) {
@ -131,6 +136,13 @@ func checkFrontend(webSrcDir string) (violations []frontendViolation, warnings [
}
}
// Raw <button> only applies to JSX files; plain .ts/.js build DOM via
// HTML strings for non-React surfaces (player, editor iframe) where the
// <Button> component doesn't exist. components/ui/ defines the
// primitives, so it is exempt (same carve-out as the automation check).
checkRawButton := (ext == ".tsx" || ext == ".jsx") &&
!strings.HasPrefix(filepath.ToSlash(relPath), "components/ui/")
lines, err := readSourceLines(path)
if err != nil {
return nil
@ -174,6 +186,13 @@ func checkFrontend(webSrcDir string) (violations []frontendViolation, warnings [
})
}
if checkRawButton && isRawButtonJSXLine(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-raw-button",
snippet: strings.TrimSpace(line),
})
}
// For allowed files, skip the remaining checks
if isAllowed {
continue
@ -320,6 +339,14 @@ func checkPluginPages(dirs []string) []frontendViolation {
snippet: strings.TrimSpace(line),
})
}
if ext == ".tsx" && !strings.HasPrefix(filepath.ToSlash(relPath), "components/ui/") &&
isRawButtonJSXLine(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-raw-button-in-plugin",
snippet: strings.TrimSpace(line),
})
}
}
return nil
@ -359,6 +386,16 @@ func isCommentLine(trimmed string) bool {
return strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "/*")
}
// isRawButtonJSXLine reports whether the line opens a raw HTML <button>
// element. JSX comment lines ({/* ... */}) are ignored here; the walkers
// already filter //-style comment lines via isCommentLine.
func isRawButtonJSXLine(line string) bool {
if strings.HasPrefix(strings.TrimSpace(line), "{/*") {
return false
}
return reRawButton.MatchString(line)
}
func isBrowserConfirmCall(lines []string, idx int) bool {
line := lines[idx]
if reWindowConfirm.MatchString(line) {

View File

@ -96,6 +96,111 @@ func TestIsBrowserConfirmCall(t *testing.T) {
}
}
func TestIsRawButtonJSXLine(t *testing.T) {
tests := []struct {
line string
want bool
}{
{line: `<button>`, want: true},
{line: ` <button`, want: true},
{line: `<button className="h-8 w-8" onClick={onPick}>`, want: true},
{line: `return <button type="button">{label}</button>`, want: true},
{line: `<Button action="save">Save</Button>`, want: false},
{line: `</button>`, want: false},
{line: `{/* <button> is banned in admin UI */}`, want: false},
{line: `<buttonish-element>`, want: false},
}
for _, tt := range tests {
if got := isRawButtonJSXLine(tt.line); got != tt.want {
t.Fatalf("isRawButtonJSXLine(%q) = %v, want %v", tt.line, got, tt.want)
}
}
}
func TestCheckFrontendFlagsRawButton(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "components", "editor"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "components", "editor", "toolbar.tsx"), []byte(`export function Toolbar() {
return (
<div>
<button onClick={save}>Save</button>
<Button action="cancel">Cancel</Button>
</div>
)
}
`), 0o644); err != nil {
t.Fatal(err)
}
violations, _ := checkFrontend(dir)
var rawButton []frontendViolation
for _, v := range violations {
if v.rule == "no-raw-button" {
rawButton = append(rawButton, v)
}
}
if len(rawButton) != 1 {
t.Fatalf("expected exactly one no-raw-button violation, got %d: %#v", len(rawButton), violations)
}
if rawButton[0].line != 4 {
t.Fatalf("violation line = %d, want 4", rawButton[0].line)
}
}
func TestCheckFrontendRawButtonExemptions(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "components", "ui"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "lib"), 0o755); err != nil {
t.Fatal(err)
}
// The Button primitive itself renders a raw <button> — components/ui/ is exempt.
if err := os.WriteFile(filepath.Join(dir, "components", "ui", "button.tsx"), []byte(`const Comp = asChild ? Slot : "button";
return <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
`), 0o644); err != nil {
t.Fatal(err)
}
// Non-React surfaces build DOM via HTML strings in plain .ts — <Button> does not exist there.
if err := os.WriteFile(filepath.Join(dir, "lib", "iframe-script.ts"), []byte("const html = `<button class=\"bn-edit\">Edit</button>`\n"), 0o644); err != nil {
t.Fatal(err)
}
violations, _ := checkFrontend(dir)
for _, v := range violations {
if v.rule == "no-raw-button" {
t.Fatalf("expected no no-raw-button violations, got %#v", v)
}
}
}
func TestCheckPluginPagesFlagsRawButton(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "settings.tsx"), []byte(`export function Settings() {
return <button onClick={connect}>Connect</button>
}
`), 0o644); err != nil {
t.Fatal(err)
}
violations := checkPluginPages([]string{dir})
found := false
for _, v := range violations {
if v.rule == "no-raw-button-in-plugin" {
found = true
break
}
}
if !found {
t.Fatalf("expected no-raw-button-in-plugin violation, got %#v", violations)
}
}
func TestPluginRESTFileAllowed(t *testing.T) {
if !pluginRESTFileAllowed("/tmp/repo/backend/internal/plugins/calcomblock/web/settings.tsx") {
t.Fatalf("expected documented calcom settings panel to be allowlisted")