check-safety/comingsoon.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

286 lines
7.9 KiB
Go

package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
)
type comingSoonViolation struct {
file string
line int
snippet string
}
var comingSoonAllowedFiles = map[string]bool{
"components/pages/system-pages-list.tsx": true, // Real system page type
"components/settings/ai-action-matrix.tsx": true, // AI task for site status copy
"components/settings/site-settings-form.tsx": true, // Settings navigation text
"components/settings/site-status-section.tsx": true, // Legitimate Site Status feature UI
"lib/nav-items.ts": true, // Admin navigation metadata
"routes/admin/pages.tsx": true, // System page type enum wiring
"lib/api/gen/blockninja/v1/pages_pb.d.ts": true, // Generated proto enum SYSTEM_PAGE_TYPE_COMING_SOON
"lib/api/gen/blockninja/v1/settings_pb.d.ts": true, // Generated proto enum SITE_MODE_COMING_SOON
}
var placeholderCopyPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bcoming[\s_-]+soon\b`),
regexp.MustCompile(`(?i)\bunder[\s_-]+construction\b`),
regexp.MustCompile(`(?i)\bwork[\s_-]+in[\s_-]+progress\b`),
regexp.MustCompile(`(?i)\bnot[\s_-]+yet[\s_-]+available\b`),
regexp.MustCompile(`(?i)\bavailable[\s_-]+soon\b`),
regexp.MustCompile(`(?i)\bcoming[\s_-]+later\b`),
}
var rePlaceholderComment = regexp.MustCompile(`(?i)\bplaceholder\b`)
var rePlaceholderStringLiteral = regexp.MustCompile("`[^`]*\\bplaceholder\\b[^`]*`|\"[^\"]*\\bplaceholder\\b[^\"]*\"|'[^']*\\bplaceholder\\b[^']*'")
func checkComingSoon(webSrcDir string) []comingSoonViolation {
var violations []comingSoonViolation
if err := filepath.Walk(webSrcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if todoDirSkips[info.Name()] {
return filepath.SkipDir
}
return nil
}
ext := filepath.Ext(path)
if !todoFileExts[ext] {
return nil
}
if strings.HasSuffix(path, ".test.ts") || strings.HasSuffix(path, ".test.tsx") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
if relPath == "" {
relPath = path
}
relPath = filepath.ToSlash(relPath)
if comingSoonAllowedFiles[relPath] {
return nil
}
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close coming soon scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if isCommentLine(trimmed) || !containsPlaceholderCopy(line) {
continue
}
violations = append(violations, comingSoonViolation{
file: relPath,
line: lineNum,
snippet: strings.TrimSpace(line),
})
}
return nil
}); err != nil {
violations = append(violations, comingSoonViolation{
file: filepath.ToSlash(webSrcDir),
snippet: err.Error(),
})
}
sort.Slice(violations, func(i, j int) bool {
if violations[i].file == violations[j].file {
return violations[i].line < violations[j].line
}
return violations[i].file < violations[j].file
})
return violations
}
func checkPlaceholderLanguage(roots []todoScanRoot) []comingSoonViolation {
var violations []comingSoonViolation
for _, scanRoot := range roots {
if scanRoot.root == "" {
continue
}
if err := filepath.Walk(scanRoot.root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if todoDirSkips[info.Name()] {
return filepath.SkipDir
}
return nil
}
ext := filepath.Ext(path)
if ext != ".go" && ext != ".tsx" {
return nil
}
if strings.HasSuffix(path, "_test.go") || strings.HasSuffix(path, ".test.tsx") {
return nil
}
if strings.Contains(filepath.ToSlash(path), "cmd/check-safety/") {
return nil
}
relPath, _ := filepath.Rel(scanRoot.root, path)
if relPath == "" {
relPath = path
}
relPath = filepath.ToSlash(relPath)
if isAllowedPlaceholderFile(relPath) {
return nil
}
if scanRoot.displayPrefix != "" {
relPath = scanRoot.displayPrefix + "/" + relPath
}
if isAllowedPlaceholderFile(relPath) {
return nil
}
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close placeholder scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if !containsPlaceholderLanguage(line, trimmed) {
continue
}
violations = append(violations, comingSoonViolation{
file: relPath,
line: lineNum,
snippet: trimmed,
})
}
return nil
}); err != nil {
violations = append(violations, comingSoonViolation{
file: filepath.ToSlash(scanRoot.root),
snippet: err.Error(),
})
}
}
sort.Slice(violations, func(i, j int) bool {
if violations[i].file == violations[j].file {
return violations[i].line < violations[j].line
}
return violations[i].file < violations[j].file
})
return violations
}
func containsPlaceholderLanguage(line string, trimmed string) bool {
lowerLine := strings.ToLower(line)
if strings.Contains(lowerLine, "placeholder=") || strings.Contains(lowerLine, "placeholder:") || strings.Contains(lowerLine, "=placeholder") {
return false
}
if strings.Contains(lowerLine, `.placeholder`) ||
strings.Contains(lowerLine, `["placeholder"]`) ||
strings.Contains(lowerLine, `['placeholder']`) ||
strings.Contains(lowerLine, `json:"placeholder`) ||
strings.Contains(lowerLine, `data-[placeholder]`) {
return false
}
if isPlaceholderCommentLine(trimmed) && rePlaceholderComment.MatchString(line) {
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "render a placeholder") ||
strings.Contains(lower, "renders a placeholder") ||
strings.Contains(lower, "show placeholder") ||
strings.Contains(lower, "show empty placeholder") ||
strings.Contains(lower, "slug placeholder") ||
strings.Contains(lower, "variable placeholder") ||
strings.Contains(lower, "placeholder patterns") ||
strings.Contains(lower, `"placeholder"`) ||
strings.Contains(lower, "`placeholder`") ||
strings.Contains(lower, "placeholder with edit button") ||
strings.Contains(lower, "streaming placeholder") ||
strings.Contains(lower, "message placeholder") ||
strings.Contains(lower, "placeholder on error") ||
strings.Contains(lower, "empty placeholder") ||
strings.Contains(lower, "placeholder color") ||
strings.Contains(lower, "placeholder option") {
return false
}
return true
}
for _, match := range rePlaceholderStringLiteral.FindAllString(line, -1) {
literal := strings.TrimSpace(strings.Trim(match, "\"'`"))
if literal == "" {
continue
}
if strings.EqualFold(literal, "placeholder") {
continue
}
if strings.HasPrefix(strings.ToLower(literal), "placeholder-") {
continue
}
if strings.Contains(strings.ToLower(literal), "slot-placeholder") {
continue
}
switch strings.ToLower(literal) {
case "placeholder color", "placeholder text color":
continue
}
switch strings.ToLower(literal) {
case "placeholder content":
continue
}
return true
}
return false
}
func containsPlaceholderCopy(line string) bool {
for _, pattern := range placeholderCopyPatterns {
if pattern.MatchString(line) {
return true
}
}
return false
}
func isPlaceholderCommentLine(trimmed string) bool {
return strings.HasPrefix(trimmed, "//") ||
strings.HasPrefix(trimmed, "/*") ||
strings.HasPrefix(trimmed, "*") ||
strings.HasPrefix(trimmed, "{/*")
}
func isAllowedPlaceholderFile(relPath string) bool {
normalized := strings.TrimPrefix(filepath.ToSlash(relPath), "web/src/")
return comingSoonAllowedFiles[normalized]
}