package main import ( "bytes" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "git.dev.alexdunmow.com/block/check-safety/internal/helpers" ) const defaultPrettierConfig = `module.exports = { arrowParens: "always", bracketSameLine: true, bracketSpacing: true, experimentalTernaries: true, printWidth: 200, quoteProps: "consistent", semi: true, singleQuote: false, tabWidth: 4, trailingComma: "all", useTabs: true, }; ` func runStrictGoLint(repoRoot string, backendDirs []string, pluginRoots []string) (completed []string, skipped []string, failures []goLintFailure, err error) { targets, skipped, err := discoverGoLintTargets(repoRoot, backendDirs, pluginRoots) if err != nil { return nil, nil, nil, err } goBin, err := exec.LookPath("go") if err != nil { return nil, nil, nil, fmt.Errorf("go not found in PATH") } golangciLint, err := exec.LookPath("golangci-lint") if err != nil { return nil, nil, nil, fmt.Errorf("golangci-lint not found in PATH") } return runStrictGoLintWithBinaries(targets, skipped, goBin, golangciLint) } func runStrictGoLintWithBinaries(targets []goLintTarget, skippedTargets []string, goBin string, golangciLint string) (completed []string, skipped []string, failures []goLintFailure, err error) { lintArgs := []string{ "run", "--default=none", "--enable=errcheck", "--enable=govet", "--enable=ineffassign", "--enable=staticcheck", "--enable=unused", "./...", } lintFixArgs := []string{ "run", "--fix", "--default=none", "--enable=errcheck", "--enable=govet", "--enable=ineffassign", "--enable=staticcheck", "--enable=unused", "./...", } for _, target := range targets { output, runErr := runCommand(target.moduleRoot, goBin, "fix", "./...") if runErr != nil { failures = append(failures, goLintFailure{ target: target.label, stage: "go fix ./...", output: output, }) continue } output, runErr = runCommand(target.moduleRoot, golangciLint, lintFixArgs...) if runErr != nil { failures = append(failures, goLintFailure{ target: target.label, stage: "golangci-lint run --fix", output: output, }) continue } output, runErr = runCommand(target.moduleRoot, goBin, "test", "-run=^$", "./...") if runErr != nil { failures = append(failures, goLintFailure{ target: target.label, stage: "go test -run=^$ ./...", output: output, }) continue } output, runErr = runCommand(target.moduleRoot, goBin, "vet", "./...") if runErr != nil { failures = append(failures, goLintFailure{ target: target.label, stage: "go vet ./...", output: output, }) continue } output, runErr = runCommand(target.moduleRoot, golangciLint, lintArgs...) if runErr != nil { failures = append(failures, goLintFailure{ target: target.label, stage: "golangci-lint run", output: output, }) continue } completed = append(completed, target.label) } sort.Strings(completed) sort.Strings(skippedTargets) return completed, skippedTargets, failures, nil } func runFrontendAutoFixAndLint(repoRoot string, scanTargets []frontendScanTarget) (completed []string, skipped []string, failures []frontendLintFailure, err error) { targets, skipped, err := discoverFrontendLintTargets(repoRoot, scanTargets) if err != nil { return nil, nil, nil, err } toolRepoRoot := blockNinjaRepoRoot() for _, target := range targets { if target.hasTypeScript { if target.tsconfigPath == "" { failures = append(failures, frontendLintFailure{ target: target.label, stage: "tsc --noEmit", output: "TypeScript sources found but no tsconfig.json was located for this frontend target", }) continue } tscBin, err := resolveRequiredBinaryWithPath("tsc", filepath.Join(target.packageRoot, "node_modules", ".bin", "tsc"), filepath.Join(repoRoot, "web", "node_modules", ".bin", "tsc"), filepath.Join(repoRoot, "node_modules", ".bin", "tsc"), filepath.Join(toolRepoRoot, "web", "node_modules", ".bin", "tsc"), filepath.Join(toolRepoRoot, "node_modules", ".bin", "tsc"), ) if err != nil { return nil, nil, nil, err } output, runErr := runFrontendTypecheck(repoRoot, toolRepoRoot, target, tscBin) if runErr != nil { failures = append(failures, frontendLintFailure{ target: target.label, stage: "tsc --noEmit", output: output, }) continue } } prettierBin, err := resolveRequiredBinaryWithPath("prettier", filepath.Join(target.packageRoot, "node_modules", ".bin", "prettier"), filepath.Join(repoRoot, "node_modules", ".bin", "prettier"), filepath.Join(toolRepoRoot, "node_modules", ".bin", "prettier"), ) if err != nil { return nil, nil, nil, err } eslintBin, err := resolveRequiredBinaryWithPath("eslint", eslintBinaryCandidates(repoRoot, toolRepoRoot, target)...) if err != nil { return nil, nil, nil, err } if output, runErr := runCommand(target.packageRoot, prettierBin, "--write", target.sourceArg); runErr != nil { failures = append(failures, frontendLintFailure{ target: target.label, stage: "prettier --write", output: output, }) continue } if output, runErr := runCommand(target.packageRoot, eslintBin, eslintArgs(target, true)...); runErr != nil { failures = append(failures, frontendLintFailure{ target: target.label, stage: "eslint --fix", output: output, }) continue } if output, runErr := runCommand(target.packageRoot, eslintBin, eslintArgs(target, false)...); runErr != nil { failures = append(failures, frontendLintFailure{ target: target.label, stage: "eslint", output: output, }) continue } completed = append(completed, target.label) } sort.Strings(completed) sort.Strings(skipped) return completed, skipped, failures, nil } func eslintArgs(target frontendLintTarget, fix bool) []string { args := []string{ target.sourceArg, "--no-error-on-unmatched-pattern", } if !usesFlatESLintConfig(target.configPath) { args = append(args, "--ext", ".js,.jsx,.ts,.tsx") } if fix { args = append(args, "--fix") } else { args = append(args, "--max-warnings", "0") } return args } func eslintBinaryCandidates(repoRoot, toolRepoRoot string, target frontendLintTarget) []string { workspaceCandidates := []string{ filepath.Join(repoRoot, "web", "node_modules", ".bin", "eslint"), filepath.Join(repoRoot, "node_modules", ".bin", "eslint"), filepath.Join(toolRepoRoot, "web", "node_modules", ".bin", "eslint"), filepath.Join(toolRepoRoot, "node_modules", ".bin", "eslint"), } localCandidate := filepath.Join(target.packageRoot, "node_modules", ".bin", "eslint") if target.managedConfigs { return append(workspaceCandidates, localCandidate) } return append([]string{localCandidate}, workspaceCandidates...) } func runFrontendTypecheck(repoRoot, toolRepoRoot string, target frontendLintTarget, tscBin string) (string, error) { if target.pluginRules { return runPluginFrontendTypecheck(repoRoot, toolRepoRoot, target, tscBin) } tsconfigArg, err := filepath.Rel(target.packageRoot, target.tsconfigPath) if err != nil { return "", err } return runCommand( target.packageRoot, tscBin, "--noEmit", "--pretty", "false", "-p", tsconfigArg, ) } func runPluginFrontendTypecheck(repoRoot, toolRepoRoot string, target frontendLintTarget, tscBin string) (string, error) { hostNodeModules, err := resolvePluginWorkspaceNodeModules(repoRoot, toolRepoRoot) if err != nil { return "", err } tempRoot, err := os.MkdirTemp("", "check-safety-plugin-tsc-*") if err != nil { return "", err } defer helpers.LogDeferredError(nil, "remove temporary plugin typecheck root", func() error { return os.RemoveAll(tempRoot) }, "path", tempRoot) tempPackageRoot := filepath.Join(tempRoot, "package") if err := copyFrontendPackageForTypecheck(target.packageRoot, tempPackageRoot); err != nil { return "", err } tempNodeModules := filepath.Join(tempPackageRoot, "node_modules") if err := os.MkdirAll(tempNodeModules, 0755); err != nil { return "", err } localNodeModules := filepath.Join(target.packageRoot, "node_modules") if dirExists(localNodeModules) { if err := mergeNodeModules(localNodeModules, tempNodeModules); err != nil { return "", err } } if err := mergeNodeModules(hostNodeModules, tempNodeModules); err != nil { return "", err } relTSConfig, err := filepath.Rel(target.packageRoot, target.tsconfigPath) if err != nil { return "", err } return runCommand( tempPackageRoot, tscBin, "--noEmit", "--pretty", "false", "-p", relTSConfig, ) } func resolveRequiredBinaryWithPath(binaryName string, candidates ...string) (string, error) { for _, candidate := range candidates { info, err := os.Stat(candidate) if err == nil && !info.IsDir() { return candidate, nil } } if binaryName != "" { if path, err := exec.LookPath(binaryName); err == nil { return path, nil } } return "", fmt.Errorf("required frontend tool not found: %s", strings.Join(candidates, ", ")) } func resolvePluginWorkspaceNodeModules(repoRoot, toolRepoRoot string) (string, error) { for _, candidate := range []string{ filepath.Join(repoRoot, "web", "node_modules"), filepath.Join(toolRepoRoot, "web", "node_modules"), } { info, err := os.Stat(candidate) if err == nil && info.IsDir() { return candidate, nil } } return "", fmt.Errorf("plugin frontend typecheck requires a BlockNinja workspace node_modules directory") } func mergeNodeModules(srcRoot, dstRoot string) error { entries, err := os.ReadDir(srcRoot) if err != nil { return err } for _, entry := range entries { srcPath := filepath.Join(srcRoot, entry.Name()) dstPath := filepath.Join(dstRoot, entry.Name()) if entry.Name() == ".bin" || strings.HasPrefix(entry.Name(), "@") { if err := os.MkdirAll(dstPath, 0755); err != nil { return err } if err := mergeNodeModules(srcPath, dstPath); err != nil { return err } continue } if _, err := os.Lstat(dstPath); err == nil { continue } else if !os.IsNotExist(err) { return err } if err := os.Symlink(srcPath, dstPath); err != nil { return err } } return nil } func copyFrontendPackageForTypecheck(srcRoot, dstRoot string) error { return filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(srcRoot, path) if err != nil { return err } if relPath == "." { return os.MkdirAll(dstRoot, 0755) } if info.IsDir() { switch info.Name() { case "node_modules", "dist", "build", ".git": return filepath.SkipDir } return os.MkdirAll(filepath.Join(dstRoot, relPath), info.Mode()) } dstPath := filepath.Join(dstRoot, relPath) return copyFile(path, dstPath, info.Mode()) }) } func copyFile(srcPath, dstPath string, mode os.FileMode) error { src, err := os.Open(srcPath) if err != nil { return err } defer helpers.LogDeferredError(nil, "close lint temp source file", src.Close, "path", srcPath) if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { return err } dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) if err != nil { return err } defer helpers.LogDeferredError(nil, "close lint temp destination file", dst.Close, "path", dstPath) if _, err := io.Copy(dst, src); err != nil { return err } return nil } func blockNinjaRepoRoot() string { home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, "src", "blockninja", "cms") } func orchestratorRepoRoot() string { home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, "src", "blockninja", "orchestrator") } func ensureDefaultFrontendConfigs(repoRoot, packageRoot string) error { if err := ensureDefaultESLintConfig(repoRoot, packageRoot); err != nil { return err } if err := ensureDefaultPrettierConfig(repoRoot, packageRoot); err != nil { return err } return nil } func ensureDefaultESLintConfig(repoRoot, packageRoot string) error { if _, ok := findNearestESLintConfig(packageRoot); ok { return nil } configPath := filepath.Join(packageRoot, "eslint.config.js") sourceConfig := filepath.Join(repoRoot, "web", "eslint.config.js") if !fileExists(sourceConfig) { sourceConfig = filepath.Join(blockNinjaRepoRoot(), "web", "eslint.config.js") } relImport, err := filepath.Rel(packageRoot, sourceConfig) if err != nil { return err } relImport = filepath.ToSlash(relImport) if !strings.HasPrefix(relImport, ".") { relImport = "./" + relImport } content := fmt.Sprintf("import config from %q\n\nexport default config\n", relImport) return os.WriteFile(configPath, []byte(content), 0644) } func ensureDefaultPrettierConfig(repoRoot, packageRoot string) error { for _, name := range []string{ "prettier.config.js", "prettier.config.mjs", "prettier.config.cjs", ".prettierrc", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.json", ".prettierrc.yaml", ".prettierrc.yml", } { if info, err := os.Stat(filepath.Join(packageRoot, name)); err == nil && !info.IsDir() { return nil } } configPath := filepath.Join(packageRoot, "prettier.config.cjs") return os.WriteFile(configPath, []byte(defaultPrettierConfig), 0644) } func runCommand(workdir string, name string, args ...string) (string, error) { cmd := exec.Command(name, args...) cmd.Dir = workdir var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() output := strings.TrimSpace(stdout.String()) errOutput := strings.TrimSpace(stderr.String()) switch { case output == "" && errOutput == "": return "", err case output == "": return errOutput, err case errOutput == "": return output, err default: return output + "\n" + errOutput, err } }