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*\(`) ) 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 } } 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), }) } // 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), }) } } 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, "/*") } 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 }