Files
Assistant/cmd/devctl/diff_scope.go
T
2026-08-22 11:03:21 +03:00

328 lines
10 KiB
Go

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 <json> or -task <json>")
}
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, true); 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
}