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>
523 lines
14 KiB
Go
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
|
|
}
|
|
}
|