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 }