check-safety/proto_freshness.go
2026-08-25 21:27:37 +08:00

303 lines
8.9 KiB
Go

package main
import (
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
var protoGeneratedPaths = []string{
"backend/internal/api",
"backend/internal/mcpserver/generated",
"packages/api/src",
}
var protoSandboxPaths = []string{
"Makefile",
"buf.yaml",
"buf.gen.yaml",
"buf.lock",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"proto",
"backend/internal/api",
"backend/internal/mcpserver/generated",
"packages/api",
}
var protoSandboxTools = []struct {
name string
packagePath string
}{
{name: "protoc-gen-connect-query", packagePath: "@connectrpc/protoc-gen-connect-query"},
{name: "protoc-gen-es", packagePath: "@bufbuild/protoc-gen-es"},
{name: "tsx", packagePath: "tsx"},
}
type protoOutputState struct {
mode fs.FileMode
digest [sha256.Size]byte
linkTarget string
}
func checkProtoGeneratedFreshness(repoRoot string) (protoFreshnessResult, error) {
result := protoFreshnessResult{}
if !samePath(repoRoot, blockNinjaRepoRoot()) {
return result, nil
}
if !fileExists(filepath.Join(repoRoot, "Makefile")) || !fileExists(filepath.Join(repoRoot, "buf.gen.yaml")) {
return result, nil
}
return checkProtoGeneratedFreshnessInSandbox(repoRoot, "")
}
// checkProtoGeneratedFreshnessInSandbox runs generation against a private copy
// of its inputs and outputs. Each invocation owns its sandbox, so parallel
// checker processes never serialize on or mutate the scanned repository. An
// interrupted generator can leave only disposable state beneath the system
// temporary directory.
func checkProtoGeneratedFreshnessInSandbox(repoRoot, tempParent string) (result protoFreshnessResult, returnErr error) {
result.checked = true
sandboxRoot, err := os.MkdirTemp(tempParent, "check-safety-proto-*")
if err != nil {
return result, fmt.Errorf("create proto freshness sandbox: %w", err)
}
defer func() {
if cleanupErr := os.RemoveAll(sandboxRoot); cleanupErr != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("remove proto freshness sandbox: %w", cleanupErr))
}
}()
if err := prepareProtoSandbox(repoRoot, sandboxRoot); err != nil {
return result, fmt.Errorf("prepare proto freshness sandbox: %w", err)
}
before, err := snapshotProtoOutputs(sandboxRoot)
if err != nil {
return result, fmt.Errorf("snapshot generated proto outputs before generation: %w", err)
}
output, runErr := runCommand(sandboxRoot, "make", "proto")
result.output = output
after, snapshotErr := snapshotProtoOutputs(sandboxRoot)
if snapshotErr == nil {
result.afterStatus = strings.Join(diffProtoOutputs(before, after), "\n")
}
var generationErrs []error
if runErr != nil {
generationErrs = append(generationErrs, fmt.Errorf("make proto failed: %w", runErr))
}
if snapshotErr != nil {
generationErrs = append(generationErrs, fmt.Errorf("snapshot generated proto outputs after generation: %w", snapshotErr))
}
if len(generationErrs) > 0 {
return result, errors.Join(generationErrs...)
}
return result, nil
}
func prepareProtoSandbox(repoRoot, sandboxRoot string) error {
for _, relPath := range protoSandboxPaths {
if err := copyProtoSandboxPath(repoRoot, sandboxRoot, relPath); err != nil {
return fmt.Errorf("copy %s: %w", relPath, err)
}
}
return linkProtoSandboxTools(repoRoot, sandboxRoot)
}
func copyProtoSandboxPath(repoRoot, sandboxRoot, relPath string) error {
sourcePath := filepath.Join(repoRoot, relPath)
if _, err := os.Lstat(sourcePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
return filepath.WalkDir(sourcePath, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(repoRoot, path)
if err != nil {
return fmt.Errorf("resolve relative path for %s: %w", path, err)
}
if entry.IsDir() && (rel == filepath.Join("packages", "api", "node_modules") || rel == filepath.Join("packages", "api", "dist")) {
return filepath.SkipDir
}
destinationPath := filepath.Join(sandboxRoot, rel)
info, err := entry.Info()
if err != nil {
return fmt.Errorf("inspect %s: %w", path, err)
}
switch {
case info.IsDir():
if err := os.MkdirAll(destinationPath, info.Mode().Perm()); err != nil {
return fmt.Errorf("create directory %s: %w", destinationPath, err)
}
return nil
case info.Mode().IsRegular():
if err := copyFile(path, destinationPath, info.Mode()); err != nil {
return fmt.Errorf("copy file %s: %w", path, err)
}
return nil
case info.Mode()&fs.ModeSymlink != 0:
target, err := os.Readlink(path)
if err != nil {
return fmt.Errorf("read symlink %s: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil {
return fmt.Errorf("create symlink parent for %s: %w", destinationPath, err)
}
if err := os.Symlink(target, destinationPath); err != nil {
return fmt.Errorf("copy symlink %s: %w", path, err)
}
return nil
default:
return fmt.Errorf("unsupported file type %s at %s", info.Mode().Type(), path)
}
})
}
func linkProtoSandboxTools(repoRoot, sandboxRoot string) error {
sourceBinDir := filepath.Join(repoRoot, "packages", "api", "node_modules", ".bin")
destinationBinDir := filepath.Join(sandboxRoot, "packages", "api", "node_modules", ".bin")
for _, tool := range protoSandboxTools {
sourcePath := filepath.Join(sourceBinDir, tool.name)
if _, err := os.Lstat(sourcePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return fmt.Errorf("inspect proto tool %s: %w", tool.name, err)
}
if err := os.MkdirAll(destinationBinDir, 0o755); err != nil {
return fmt.Errorf("create proto tool directory: %w", err)
}
if err := os.Symlink(sourcePath, filepath.Join(destinationBinDir, tool.name)); err != nil {
return fmt.Errorf("link proto tool %s: %w", tool.name, err)
}
sourcePackagePath := filepath.Join(repoRoot, "packages", "api", "node_modules", filepath.FromSlash(tool.packagePath))
if _, err := os.Lstat(sourcePackagePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return fmt.Errorf("inspect proto tool package %s: %w", tool.packagePath, err)
}
destinationPackagePath := filepath.Join(sandboxRoot, "packages", "api", "node_modules", filepath.FromSlash(tool.packagePath))
if err := os.MkdirAll(filepath.Dir(destinationPackagePath), 0o755); err != nil {
return fmt.Errorf("create proto tool package directory for %s: %w", tool.packagePath, err)
}
if err := os.Symlink(sourcePackagePath, destinationPackagePath); err != nil {
return fmt.Errorf("link proto tool package %s: %w", tool.packagePath, err)
}
}
return nil
}
func snapshotProtoOutputs(root string) (map[string]protoOutputState, error) {
states := make(map[string]protoOutputState)
for _, generatedPath := range protoGeneratedPaths {
absolutePath := filepath.Join(root, generatedPath)
if _, err := os.Lstat(absolutePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return nil, fmt.Errorf("inspect %s: %w", generatedPath, err)
}
err := filepath.WalkDir(absolutePath, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return fmt.Errorf("resolve generated output path %s: %w", path, err)
}
rel = filepath.ToSlash(rel)
info, err := entry.Info()
if err != nil {
return fmt.Errorf("inspect generated output %s: %w", rel, err)
}
state := protoOutputState{mode: info.Mode().Type() | info.Mode().Perm()}
switch {
case info.Mode().IsRegular():
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read generated output %s: %w", rel, err)
}
state.digest = sha256.Sum256(content)
case info.Mode()&fs.ModeSymlink != 0:
target, err := os.Readlink(path)
if err != nil {
return fmt.Errorf("read generated output symlink %s: %w", rel, err)
}
state.linkTarget = target
default:
return fmt.Errorf("unsupported generated output type %s at %s", info.Mode().Type(), rel)
}
states[rel] = state
return nil
})
if err != nil {
return nil, fmt.Errorf("walk generated outputs under %s: %w", generatedPath, err)
}
}
return states, nil
}
func diffProtoOutputs(before, after map[string]protoOutputState) []string {
pathSet := make(map[string]struct{}, len(before)+len(after))
for path := range before {
pathSet[path] = struct{}{}
}
for path := range after {
pathSet[path] = struct{}{}
}
paths := make([]string, 0, len(pathSet))
for path := range pathSet {
paths = append(paths, path)
}
sort.Strings(paths)
changes := make([]string, 0)
for _, path := range paths {
beforeState, existedBefore := before[path]
afterState, existsAfter := after[path]
switch {
case !existedBefore:
changes = append(changes, "?? "+path)
case !existsAfter:
changes = append(changes, " D "+path)
case beforeState != afterState:
changes = append(changes, " M "+path)
}
}
return changes
}