- resolveScanRoots recognizes the orchestrator layout (backend/go.mod + frontend/src) so backendDir resolves to backend/ — fixes the go.mod check, classifies the frontend as a standalone app (not a plugin), and routes tsc through the plain typecheck. Clears three orchestrator false-positives. - check 2 (RBAC): only require MethodRoles for proto services a target actually serves (mounted connect handler). The orchestrator carries but does not serve the blockninja.v1 surface (714 false 'missing' -> 0). Exclude cli (definitions-only, vendors a registry client) from check 2. - Skip buf-generated gen/ output in the placeholder check (real proto enum COMING_SOON values are not TODO debt). - Normalize the color allowlist path so theme-card.tsx swatch previews match regardless of scan root. - Whitelist the gitignored, tool-regenerated styles/package-lock.json. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
310 lines
8.8 KiB
Go
310 lines
8.8 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[^']*'")
|
|
|
|
// isGeneratedProtoOutput reports whether path lives under a `gen/` directory
|
|
// segment — the buf codegen output tree (e.g. `.../lib/api/gen/blockninja/v1/`).
|
|
// Scoped to the placeholder/"ship the feature now" check only: generated proto
|
|
// code is never hand-authored, so its symbols (COMING_SOON enum values, etc.)
|
|
// must not be flagged as placeholder debt.
|
|
func isGeneratedProtoOutput(path string) bool {
|
|
return strings.Contains(filepath.ToSlash(path), "/gen/")
|
|
}
|
|
|
|
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
|
|
}
|
|
// Skip buf codegen output (any `gen/` directory segment, e.g.
|
|
// lib/api/gen/blockninja/v1/*_pb.d.ts). These files are regenerated by
|
|
// buf and must never be hand-edited; their enum values like
|
|
// SYSTEM_PAGE_TYPE_COMING_SOON / SITE_MODE_COMING_SOON are real proto
|
|
// symbols, not authored placeholder debt.
|
|
if isGeneratedProtoOutput(path) {
|
|
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
|
|
}
|
|
slashPath := filepath.ToSlash(path)
|
|
if strings.Contains(slashPath, "/generated/") ||
|
|
strings.HasSuffix(slashPath, ".pb.go") ||
|
|
strings.HasSuffix(slashPath, ".connect.go") ||
|
|
isGeneratedProtoOutput(path) {
|
|
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]
|
|
}
|