check-safety/lint_pipeline.go
Alex Dunmow cd88c808b0 initial: standalone check-safety module hoisted from CMS
Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.

This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
  blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
  install-safety-checker targets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 13:04:02 +08:00

523 lines
14 KiB
Go

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
}
}