package main import ( "bytes" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path" "path/filepath" "sort" "strings" ) const defaultActiveTaskPath = "dev/ACTIVE_TASK.json" type ActiveTask struct { TaskFile string `json:"task_file"` TaskID string `json:"task_id"` TaskRevision int `json:"task_revision"` } type taskSelection struct { activePath string taskPath string } func checkDiff(args []string) error { selection, err := parseTaskSelection(args, true) if err != nil { return err } root, err := gitRepositoryRoot(".") if err != nil { return err } return checkDiffAt(root, selection) } func parseTaskSelection(args []string, defaultActive bool) (taskSelection, error) { var selection taskSelection for i := 0; i < len(args); i++ { switch args[i] { case "-active", "-task": if i+1 >= len(args) { return taskSelection{}, fmt.Errorf("missing value for %s", args[i]) } value := args[i+1] i++ if args[i-1] == "-active" { if selection.activePath != "" { return taskSelection{}, errors.New("duplicate -active") } selection.activePath = value } else { if selection.taskPath != "" { return taskSelection{}, errors.New("duplicate -task") } selection.taskPath = value } default: return taskSelection{}, fmt.Errorf("unknown argument %q", args[i]) } } if selection.activePath != "" && selection.taskPath != "" { return taskSelection{}, errors.New("-active and -task are mutually exclusive") } if selection.activePath == "" && selection.taskPath == "" { if !defaultActive { return taskSelection{}, errors.New("missing -active or -task ") } selection.activePath = defaultActiveTaskPath } return selection, nil } func gitRepositoryRoot(from string) (string, error) { out, err := runGit(from, "rev-parse", "--show-toplevel") if err != nil { return "", fmt.Errorf("locate Git repository: %w", err) } root := strings.TrimSpace(string(out)) if root == "" { return "", errors.New("Git repository root is empty") } return filepath.Clean(root), nil } func checkDiffAt(root string, selection taskSelection) error { taskPacket, taskPath, activePath, err := loadSelectedTask(root, selection) if err != nil { return err } if taskPacket.TaskID == "" || taskPacket.TaskRevision < 1 || taskPacket.BaseRepoRevision == "" || taskPacket.RiskTier == "" || taskPacket.Intent == "" { return errors.New("task packet missing task_id, positive task_revision, base_repo_revision, risk_tier, or intent") } patterns, err := compileScopePatterns(taskPacket.AllowedChangeScope.Paths) if err != nil { return err } if !matchesAny(taskPath, patterns) { return fmt.Errorf("task packet must allow its own path %s", taskPath) } if activePath != "" && !matchesAny(activePath, patterns) { return fmt.Errorf("active task packet must allow pointer path %s", activePath) } base, err := resolveBaseCommit(root, taskPacket.BaseRepoRevision) if err != nil { return err } changed, err := changedPaths(root, base) if err != nil { return err } var outside []string for _, changedPath := range changed { if !matchesAny(changedPath, patterns) { outside = append(outside, changedPath) } } if len(outside) > 0 { return fmt.Errorf("%d changed path(s) outside task %s allowed_change_scope: %s", len(outside), taskPacket.TaskID, strings.Join(outside, ", ")) } fmt.Printf("diff scope checks: OK task=%s base=%s paths=%d\n", taskPacket.TaskID, base, len(changed)) return nil } func loadSelectedTask(root string, selection taskSelection) (TaskPacket, string, string, error) { if selection.activePath != "" { activePath, err := cleanRepositoryPath(selection.activePath) if err != nil { return TaskPacket{}, "", "", fmt.Errorf("active pointer path: %w", err) } var active ActiveTask if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(activePath)), &active, true); err != nil { return TaskPacket{}, "", "", fmt.Errorf("active task pointer: %w", err) } if active.TaskID == "" || active.TaskRevision < 1 { return TaskPacket{}, "", "", errors.New("active task pointer missing task_id or positive task_revision") } taskPath, err := cleanTaskPath(active.TaskFile) if err != nil { return TaskPacket{}, "", "", err } taskPacket, err := loadTaskPacket(root, taskPath) if err != nil { return TaskPacket{}, "", "", err } if taskPacket.TaskID != active.TaskID || taskPacket.TaskRevision != active.TaskRevision { return TaskPacket{}, "", "", fmt.Errorf("active pointer %s revision %d does not match task packet %s revision %d", active.TaskID, active.TaskRevision, taskPacket.TaskID, taskPacket.TaskRevision) } return taskPacket, taskPath, activePath, nil } taskPath, err := cleanTaskPath(selection.taskPath) if err != nil { return TaskPacket{}, "", "", err } taskPacket, err := loadTaskPacket(root, taskPath) return taskPacket, taskPath, "", err } func loadTaskPacket(root, taskPath string) (TaskPacket, error) { var taskPacket TaskPacket if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, false); err != nil { return TaskPacket{}, fmt.Errorf("task packet: %w", err) } return taskPacket, nil } func decodeJSONFile(filePath string, target any, strict bool) error { b, err := os.ReadFile(filePath) if err != nil { return err } decoder := json.NewDecoder(bytes.NewReader(b)) if strict { decoder.DisallowUnknownFields() } if err := decoder.Decode(target); err != nil { return fmt.Errorf("invalid JSON: %w", err) } if err := decoder.Decode(&struct{}{}); errors.Is(err, io.EOF) { return nil } else if err != nil { return fmt.Errorf("invalid trailing JSON: %w", err) } return errors.New("invalid JSON: multiple values") } func cleanTaskPath(value string) (string, error) { cleaned, err := cleanRepositoryPath(value) if err != nil { return "", fmt.Errorf("task_file: %w", err) } if !strings.HasPrefix(cleaned, "dev/tasks/") || !strings.HasSuffix(cleaned, ".json") { return "", fmt.Errorf("task_file must be a JSON file below dev/tasks/: %s", cleaned) } return cleaned, nil } func cleanRepositoryPath(value string) (string, error) { if value == "" || strings.Contains(value, "\\") || strings.Contains(value, ":") || strings.HasPrefix(value, "/") { return "", fmt.Errorf("path must be non-empty, relative, slash-normalized and volume-free: %q", value) } cleaned := path.Clean(value) if cleaned != value || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { return "", fmt.Errorf("path is not a clean repository-relative path: %q", value) } return cleaned, nil } type scopePattern struct { raw string subtree string } func compileScopePatterns(values []string) ([]scopePattern, error) { patterns := make([]scopePattern, 0, len(values)) for _, value := range values { cleaned, err := cleanRepositoryPath(value) if err != nil { return nil, fmt.Errorf("invalid allowed_change_scope path: %w", err) } if strings.ContainsAny(cleaned, "[]{}") { return nil, fmt.Errorf("invalid allowed_change_scope path %q: only * and ? glob characters are supported", value) } pattern := scopePattern{raw: cleaned} if strings.HasSuffix(cleaned, "/**") { pattern.subtree = strings.TrimSuffix(cleaned, "/**") if pattern.subtree == "" || strings.ContainsAny(pattern.subtree, "*?") { return nil, fmt.Errorf("invalid subtree scope pattern %q", value) } } else if strings.Contains(cleaned, "**") { return nil, fmt.Errorf("invalid allowed_change_scope path %q: ** is supported only as a trailing subtree marker", value) } if _, err := path.Match(pattern.raw, pattern.raw); err != nil { return nil, fmt.Errorf("invalid allowed_change_scope path %q: %w", value, err) } patterns = append(patterns, pattern) } return patterns, nil } func matchesAny(candidate string, patterns []scopePattern) bool { for _, pattern := range patterns { if pattern.subtree != "" { if candidate == pattern.subtree || strings.HasPrefix(candidate, pattern.subtree+"/") { return true } continue } matched, _ := path.Match(pattern.raw, candidate) if matched { return true } } return false } func resolveBaseCommit(root, revision string) (string, error) { out, err := runGit(root, "rev-parse", "--verify", revision+"^{commit}") if err != nil { return "", fmt.Errorf("base_repo_revision %q does not resolve to a commit: %w", revision, err) } base := strings.TrimSpace(string(out)) if _, err := runGit(root, "merge-base", "--is-ancestor", base, "HEAD"); err != nil { return "", fmt.Errorf("base_repo_revision %s is not an ancestor of HEAD: %w", base, err) } return base, nil } func changedPaths(root, base string) ([]string, error) { tracked, err := runGit(root, "diff", "--name-only", "--no-renames", "-z", base, "--") if err != nil { return nil, fmt.Errorf("read tracked diff: %w", err) } untracked, err := runGit(root, "ls-files", "--others", "--exclude-standard", "-z") if err != nil { return nil, fmt.Errorf("read untracked paths: %w", err) } unique := make(map[string]struct{}) for _, raw := range append(splitNUL(tracked), splitNUL(untracked)...) { candidate := filepath.ToSlash(raw) cleaned, err := cleanRepositoryPath(candidate) if err != nil { return nil, fmt.Errorf("Git returned invalid changed path: %w", err) } unique[cleaned] = struct{}{} } result := make([]string, 0, len(unique)) for candidate := range unique { result = append(result, candidate) } sort.Strings(result) return result, nil } func splitNUL(value []byte) []string { parts := bytes.Split(value, []byte{0}) result := make([]string, 0, len(parts)) for _, part := range parts { if len(part) > 0 { result = append(result, string(part)) } } return result } func runGit(root string, args ...string) ([]byte, error) { cmd := exec.Command("git", append([]string{"-C", root}, args...)...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { message := strings.TrimSpace(stderr.String()) if message == "" { message = err.Error() } return nil, errors.New(message) } return out, nil }