package main import ( "bufio" "os" "path/filepath" "regexp" "strings" "git.dev.alexdunmow.com/block/check-safety/internal/helpers" ) type tailwindViolation struct { file string line int rule string snippet string } var ( // PostCSS config: bare tailwindcss plugin (v3 syntax) rePostCSSBarePlugin = regexp.MustCompile(`(?:['"]?tailwindcss['"]?\s*:|['"]tailwindcss['"])`) // PostCSS config: correct v4 plugin rePostCSSV4Plugin = regexp.MustCompile(`@tailwindcss/postcss`) // CSS: old @tailwind directives (v3) reTailwindDirective = regexp.MustCompile(`^\s*@tailwind\s+(base|components|utilities)`) // CSS: v4 import reTailwindV4Import = regexp.MustCompile(`@import\s+["']tailwindcss["']`) // CSS: @config directive reCSSConfigDirective = regexp.MustCompile(`^\s*@config\s+`) // CSS: old autoprefixer in postcss config (unnecessary with v4) rePostCSSAutoprefixer = regexp.MustCompile(`(?:['"]?autoprefixer['"]?\s*:|['"]autoprefixer['"])`) ) func checkTailwindV4(frontendDir string) []tailwindViolation { var violations []tailwindViolation projectDir := filepath.Dir(frontendDir) for _, v := range checkPostCSSConfig(projectDir) { v.file = filepath.Clean(filepath.Join("..", v.file)) violations = append(violations, v) } violations = append(violations, checkCSSDirectives(frontendDir, projectDir)...) return violations } func checkPostCSSConfig(projectDir string) []tailwindViolation { var violations []tailwindViolation configNames := []string{"postcss.config.js", "postcss.config.mjs", "postcss.config.cjs", "postcss.config.ts", ".postcssrc", ".postcssrc.js", ".postcssrc.json"} for _, name := range configNames { configPath := filepath.Join(projectDir, name) if !fileExists(configPath) { continue } violations = append(violations, scanPostCSSFile(configPath, name)...) } return violations } func scanPostCSSFile(configPath, name string) []tailwindViolation { var violations []tailwindViolation f, err := os.Open(configPath) if err != nil { return nil } defer helpers.LogDeferredError(nil, "close postcss config file", f.Close, "path", configPath) scanner := bufio.NewScanner(f) lineNum := 0 hasV4Plugin := false for scanner.Scan() { lineNum++ line := scanner.Text() if rePostCSSV4Plugin.MatchString(line) { hasV4Plugin = true } if rePostCSSBarePlugin.MatchString(line) && !rePostCSSV4Plugin.MatchString(line) { violations = append(violations, tailwindViolation{ file: name, line: lineNum, rule: "postcss-v4-plugin", snippet: strings.TrimSpace(line), }) } if hasV4Plugin && rePostCSSAutoprefixer.MatchString(line) { violations = append(violations, tailwindViolation{ file: name, line: lineNum, rule: "postcss-no-autoprefixer", snippet: strings.TrimSpace(line), }) } } return violations } func checkCSSDirectives(frontendDir, projectDir string) []tailwindViolation { var violations []tailwindViolation hasTailwindConfig := fileExists(filepath.Join(projectDir, "tailwind.config.ts")) || fileExists(filepath.Join(projectDir, "tailwind.config.js")) if err := filepath.Walk(frontendDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } if !strings.HasSuffix(path, ".css") { return nil } if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") { return nil } relPath, _ := filepath.Rel(frontendDir, path) if relPath == "" { relPath = path } f, openErr := os.Open(path) if openErr != nil { return nil } defer helpers.LogDeferredError(nil, "close CSS file for tailwind scan", f.Close, "path", path) scanner := bufio.NewScanner(f) lineNum := 0 hasTailwindImport := false hasConfigDirective := false for scanner.Scan() { lineNum++ line := scanner.Text() if reTailwindV4Import.MatchString(line) { hasTailwindImport = true } if reCSSConfigDirective.MatchString(line) { hasConfigDirective = true } if reTailwindDirective.MatchString(line) { violations = append(violations, tailwindViolation{ file: relPath, line: lineNum, rule: "css-v4-import", snippet: strings.TrimSpace(line), }) } } if hasTailwindImport && hasTailwindConfig && !hasConfigDirective { violations = append(violations, tailwindViolation{ file: relPath, line: 0, rule: "css-missing-config", snippet: "uses @import \"tailwindcss\" but missing @config directive (tailwind.config.* exists)", }) } return nil }); err != nil { violations = append(violations, tailwindViolation{ file: frontendDir, line: 0, rule: "walk-error", snippet: err.Error(), }) } return violations }