303 lines
8.9 KiB
Go
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
|
|
}
|