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>
423 lines
14 KiB
Go
423 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
type frontendViolation struct {
|
|
file string
|
|
line int
|
|
rule string
|
|
snippet string
|
|
}
|
|
|
|
// Future enhancement (FUTURE-WO-012-ENFORCE): Add a lint rule that requires
|
|
// every string literal in a JSX text node to be wrapped in t(). This was
|
|
// intentionally omitted from WO-012 to avoid breaking all in-flight WOs that
|
|
// have untranslated strings. The scaffolding (i18next init, en.json catalog,
|
|
// t() + useTranslation exports) was shipped in WO-012; strict enforcement is
|
|
// deferred to a follow-up work order.
|
|
|
|
// Files/dirs where fetch() and manual clients are allowed.
|
|
// Each entry has a documented reason — add new entries only with justification.
|
|
var allowedFrontendFiles = map[string]bool{
|
|
"lib/transport.ts": true, // Global transport setup
|
|
"lib/api/transport.ts": true, // Orchestrator global transport setup
|
|
"lib/token-refresh.ts": true, // Auth bootstrap client (avoids interceptor loop)
|
|
"lib/sync/sync-engine.ts": true, // Jotai sync — lives outside React, can't use hooks
|
|
"components/media-library/media-upload-area.tsx": true, // Multipart file upload via FormData
|
|
"components/html-editor/preview-iframe.tsx": true, // Block HTML preview (returns HTML, not proto)
|
|
"lib/editor-bridge/iframe-script.ts": true, // Editor iframe preview
|
|
"player/human-proof/index.ts": true, // Standalone player bundle — no React, fetches public REST endpoint
|
|
|
|
// createClient exceptions — each has a technical reason why useQuery/useMutation can't be used:
|
|
// use-entity-search fans one debounced query out to 10 RPCs across 8 services
|
|
// via useQueries (single combined loading state + result blending for the
|
|
// command palette); generated per-RPC hooks can't express this without 10
|
|
// independent hook instances and manual aggregation.
|
|
"hooks/use-entity-search.ts": true,
|
|
"routes/admin/plugins.tsx": true, // Server-streaming (for await...of) — useMutation doesn't support streaming
|
|
"hooks/use-restart-operation.ts": true, // Server-streaming + raw Connect envelope for cross-origin orchestrator stream
|
|
"routes/admin/menus.tsx": true, // Imperative async callback in useCallback
|
|
"components/data-platform/tables/creation-wizard.tsx": true, // useMutation has serialization bug with oneof fields
|
|
}
|
|
|
|
// Plugin web files that are allowed to call plugin-owned REST handlers because
|
|
// there is no ConnectRPC surface for them yet. Keep this list narrow.
|
|
var allowedPluginRESTFiles = map[string]bool{
|
|
"backend/internal/plugins/calcomblock/web/settings.tsx": true, // Plugin settings/test endpoints are mounted via HTTPHandler; no generated hooks exist yet
|
|
"backend/internal/plugins/calcomblock/web/editor.tsx": true, // Block editor reads plugin /settings + /event-types via HTTPHandler routes
|
|
}
|
|
|
|
// Specific fetch paths that are non-proto REST endpoints (no ConnectRPC equivalent)
|
|
// These get a WARN, not a FAIL
|
|
var knownNonProtoFetches = map[string]bool{
|
|
"/api/plugins/": true, // Plugin REST APIs
|
|
"/api/mcp/": true, // MCP device auth flow
|
|
"/api/lists/subscribe": true, // Public subscribe endpoint
|
|
"/preview/": true, // Preview HTML endpoints
|
|
"/api/ai/chat/stream": true, // AI chat SSE streaming (EventSource/fetch — ConnectRPC doesn't support SSE)
|
|
"/api/support/": true, // CMS helpdesk widget: multipart attachment upload to the Chi proxy — connect-query can't send FormData
|
|
"/api/helpdesk/upload": true, // Orchestrator helpdesk: multipart attachment upload — same FormData limitation
|
|
}
|
|
|
|
var (
|
|
// Anti-pattern: importing axios
|
|
reAxios = regexp.MustCompile(`(?:import|require).*['"]axios['"]`)
|
|
|
|
// Anti-pattern: useQueryClient (should use refetch() instead)
|
|
reUseQueryClient = regexp.MustCompile(`useQueryClient\s*\(`)
|
|
|
|
// Anti-pattern in plugins: QueryClientProvider is already mounted by core
|
|
reQueryClientProvider = regexp.MustCompile(`\bQueryClientProvider\b`)
|
|
|
|
// Anti-pattern: createClient from connectrpc (should use generated hooks)
|
|
reCreateClient = regexp.MustCompile(`createClient\s*\(`)
|
|
|
|
// Anti-pattern: fetch() to /api/ or proto paths (should use ConnectRPC)
|
|
reFetchAPI = regexp.MustCompile(`fetch\s*\(\s*['\x60"/]`)
|
|
|
|
// Anti-pattern: createConnectTransport (only allowed in transport.ts)
|
|
reCreateTransport = regexp.MustCompile(`createConnectTransport\s*\(`)
|
|
|
|
// Anti-pattern: new XMLHttpRequest
|
|
reXHR = regexp.MustCompile(`new\s+XMLHttpRequest`)
|
|
|
|
// Anti-pattern: importing from @connectrpc/connect-web directly to create clients
|
|
reConnectWebImport = regexp.MustCompile(`from\s+['"]@connectrpc/connect-web['"]`)
|
|
|
|
// 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) {
|
|
if err := filepath.Walk(webSrcDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := filepath.Ext(path)
|
|
if ext != ".ts" && ext != ".tsx" && ext != ".js" && ext != ".jsx" {
|
|
return nil
|
|
}
|
|
|
|
// Skip node_modules and build output.
|
|
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
|
|
return nil
|
|
}
|
|
// Skip test/spec files — they legitimately use createClient/transport and
|
|
// fetch mocks to exercise the transport layer and are never shipped UI.
|
|
// (Matches the convention the coming-soon and TODO checks already follow.)
|
|
if strings.HasSuffix(path, ".test.ts") || strings.HasSuffix(path, ".test.tsx") ||
|
|
strings.HasSuffix(path, ".spec.ts") || strings.HasSuffix(path, ".spec.tsx") {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(webSrcDir, path)
|
|
if relPath == "" {
|
|
relPath = path
|
|
}
|
|
|
|
// Check if this file is in the allow list
|
|
isAllowed := false
|
|
for allowed := range allowedFrontendFiles {
|
|
if strings.HasSuffix(relPath, allowed) || relPath == allowed {
|
|
isAllowed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
for idx, line := range lines {
|
|
lineNum := idx + 1
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if isCommentLine(trimmed) {
|
|
continue
|
|
}
|
|
|
|
// Check axios
|
|
if reAxios.MatchString(line) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-axios",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
// Check useQueryClient
|
|
if reUseQueryClient.MatchString(line) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-useQueryClient",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
// Check XMLHttpRequest
|
|
if reXHR.MatchString(line) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-xhr",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
if isBrowserConfirmCall(lines, idx) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-browser-confirm",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Check createClient (only outside allowed files)
|
|
if reCreateClient.MatchString(line) {
|
|
// Check if it's a documented exception (streaming, oneof bug, etc.)
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-createClient",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
// Check createConnectTransport
|
|
if reCreateTransport.MatchString(line) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-createConnectTransport",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
// Check @connectrpc/connect-web imports (manual client creation)
|
|
if reConnectWebImport.MatchString(line) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-connect-web-import",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
// Check fetch() calls to /api/ or proto RPC paths
|
|
if reFetchAPI.MatchString(line) {
|
|
isProtoFetch := strings.Contains(line, "/blockninja.v1.")
|
|
isAPIFetch := strings.Contains(line, "/api/")
|
|
|
|
if isProtoFetch {
|
|
// fetch() to a proto RPC endpoint — should always use ConnectRPC client
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-fetch-proto",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
} else if isAPIFetch {
|
|
// Check if this is a known non-proto endpoint (warn, not fail)
|
|
isKnownNonProto := false
|
|
for prefix := range knownNonProtoFetches {
|
|
if strings.Contains(line, prefix) {
|
|
isKnownNonProto = true
|
|
break
|
|
}
|
|
}
|
|
if isKnownNonProto {
|
|
warnings = append(warnings, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "fetch-non-proto-api",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
} else {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-fetch-api",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
warnings = append(warnings, frontendViolation{
|
|
file: filepath.ToSlash(webSrcDir),
|
|
line: 0,
|
|
rule: "walk-error",
|
|
snippet: err.Error(),
|
|
})
|
|
}
|
|
|
|
return violations, warnings
|
|
}
|
|
|
|
// checkPluginPages scans plugin web page directories for plugin-specific frontend
|
|
// anti-patterns. Plugins must use useQuery/useMutation with { transport } options
|
|
// (AGENT-GUIDE §7.2c) — createClient+useMemo is forbidden. Plugins must also
|
|
// never use raw fetch() and must not mount their own QueryClientProvider because
|
|
// core already provides it.
|
|
func checkPluginPages(dirs []string) []frontendViolation {
|
|
var violations []frontendViolation
|
|
|
|
for _, dir := range dirs {
|
|
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := filepath.Ext(path)
|
|
if ext != ".ts" && ext != ".tsx" {
|
|
return nil
|
|
}
|
|
|
|
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(dir, path)
|
|
if relPath == "" {
|
|
relPath = path
|
|
}
|
|
pluginRESTAllowed := pluginRESTFileAllowed(path)
|
|
|
|
lines, openErr := readSourceLines(path)
|
|
if openErr != nil {
|
|
return nil
|
|
}
|
|
for idx, line := range lines {
|
|
lineNum := idx + 1
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if isCommentLine(trimmed) {
|
|
continue
|
|
}
|
|
|
|
if reFetchAPI.MatchString(line) && !pluginRESTAllowed {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-fetch-in-plugin",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
if reQueryClientProvider.MatchString(line) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-queryclientprovider-in-plugin",
|
|
snippet: "remove QueryClientProvider; BlockNinja core provides it.",
|
|
})
|
|
}
|
|
|
|
if reCreateClient.MatchString(line) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-createClient-in-plugin",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
if isBrowserConfirmCall(lines, idx) {
|
|
violations = append(violations, frontendViolation{
|
|
file: relPath, line: lineNum, rule: "no-browser-confirm-in-plugin",
|
|
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
|
|
}); err != nil {
|
|
violations = append(violations, frontendViolation{
|
|
file: filepath.ToSlash(dir),
|
|
line: 0,
|
|
rule: "walk-error",
|
|
snippet: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
func pluginRESTFileAllowed(path string) bool {
|
|
slashPath := filepath.ToSlash(path)
|
|
for allowed := range allowedPluginRESTFiles {
|
|
if strings.HasSuffix(slashPath, allowed) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func readSourceLines(path string) ([]string, error) {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return strings.Split(string(content), "\n"), nil
|
|
}
|
|
|
|
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) {
|
|
return true
|
|
}
|
|
|
|
if !reBareConfirm.MatchString(line) {
|
|
return false
|
|
}
|
|
|
|
start := max(idx-1, 0)
|
|
end := min(idx+8, len(lines)-1)
|
|
window := strings.Join(lines[start:end+1], " ")
|
|
window = strings.Join(strings.Fields(window), " ")
|
|
|
|
if strings.Contains(window, "await confirm(") {
|
|
return false
|
|
}
|
|
if strings.Contains(window, "confirm(") && strings.Contains(window, ").then(") {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|