Files
Assistant/cmd/devctl/diff_scope_test.go
m 111ad75dea
CI / test (push) Successful in 11s
feat: enforce exact task diff scope
2026-08-16 16:47:06 +03:00

289 lines
9.3 KiB
Go

package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCheckDiffAcceptsExactSubtreeAndSegmentGlob(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"README.md": "base\n"})
repo.writeTask(t, []string{
repo.taskPath,
repo.activePath,
"exact.txt",
"nested/**",
"logs/*.txt",
})
writeFixture(t, filepath.Join(repo.root, "exact.txt"), "exact\n")
writeFixture(t, filepath.Join(repo.root, "nested", "deep", "value.txt"), "nested\n")
writeFixture(t, filepath.Join(repo.root, "logs", "today.txt"), "log\n")
if err := repo.check(t); err != nil {
t.Fatalf("accepted scope rejected: %v", err)
}
}
func TestCheckDiffRejectsTrackedStagedUnstagedAndUntrackedPaths(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, *diffRepo)
}{
{
name: "tracked unstaged",
mutate: func(t *testing.T, repo *diffRepo) {
writeFixture(t, filepath.Join(repo.root, "tracked.txt"), "changed\n")
},
},
{
name: "tracked staged",
mutate: func(t *testing.T, repo *diffRepo) {
writeFixture(t, filepath.Join(repo.root, "tracked.txt"), "changed\n")
repo.git(t, "add", "tracked.txt")
},
},
{
name: "untracked",
mutate: func(t *testing.T, repo *diffRepo) {
writeFixture(t, filepath.Join(repo.root, "outside.txt"), "new\n")
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"tracked.txt": "base\n"})
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
test.mutate(t, repo)
assertDiffError(t, repo.check(t), "outside task")
})
}
}
func TestCheckDiffRejectsDeletionAndBothSidesOfRename(t *testing.T) {
t.Run("deletion", func(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"remove.txt": "base\n"})
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
if err := os.Remove(filepath.Join(repo.root, "remove.txt")); err != nil {
t.Fatal(err)
}
assertDiffError(t, repo.check(t), "remove.txt")
})
t.Run("rename source", func(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"source.txt": "base\n"})
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "destination.txt"})
repo.git(t, "mv", "source.txt", "destination.txt")
assertDiffError(t, repo.check(t), "source.txt")
})
t.Run("rename destination", func(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"source.txt": "base\n"})
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "source.txt"})
repo.git(t, "mv", "source.txt", "destination.txt")
assertDiffError(t, repo.check(t), "destination.txt")
})
}
func TestCheckDiffRejectsInvalidActiveTaskState(t *testing.T) {
t.Run("missing pointer", func(t *testing.T) {
repo := newDiffRepo(t, nil)
assertDiffError(t, repo.check(t), "active task pointer")
})
t.Run("malformed pointer", func(t *testing.T) {
repo := newDiffRepo(t, nil)
writeFixture(t, filepath.Join(repo.root, repo.activePath), "{")
assertDiffError(t, repo.check(t), "invalid JSON")
})
t.Run("mismatched pointer", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
active := ActiveTask{TaskFile: repo.taskPath, TaskID: "OTHER", TaskRevision: 1}
repo.writeJSON(t, repo.activePath, active)
assertDiffError(t, repo.check(t), "does not match")
})
t.Run("task outside dev tasks", func(t *testing.T) {
repo := newDiffRepo(t, nil)
active := ActiveTask{TaskFile: "task.json", TaskID: "TEST-1", TaskRevision: 1}
repo.writeJSON(t, repo.activePath, active)
assertDiffError(t, repo.check(t), "below dev/tasks")
})
}
func TestCheckDiffRejectsInvalidBaseRevisions(t *testing.T) {
t.Run("missing object or history", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.base = "0000000000000000000000000000000000000000"
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
assertDiffError(t, repo.check(t), "does not resolve")
})
t.Run("non ancestor", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.git(t, "checkout", "--orphan", "unrelated")
if err := os.Remove(filepath.Join(repo.root, "base.txt")); err != nil {
t.Fatal(err)
}
writeFixture(t, filepath.Join(repo.root, "unrelated.txt"), "unrelated\n")
repo.git(t, "add", "-A")
repo.git(t, "commit", "-m", "unrelated")
unrelated := strings.TrimSpace(repo.git(t, "rev-parse", "HEAD"))
repo.git(t, "checkout", "master")
repo.base = unrelated
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
assertDiffError(t, repo.check(t), "not an ancestor")
})
}
func TestCheckDiffRejectsMalformedScopeAndImplicitPrefix(t *testing.T) {
tests := []struct {
name string
pattern string
want string
}{
{"parent traversal", "../outside", "clean repository-relative"},
{"backslash", `directory\\file`, "slash-normalized"},
{"unsupported class", "file[0].txt", "only * and ?"},
{"invalid double star", "directory/**/file", "trailing subtree"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath, test.pattern})
assertDiffError(t, repo.check(t), test.want)
})
}
t.Run("implicit prefix", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "directory"})
writeFixture(t, filepath.Join(repo.root, "directory", "file.txt"), "outside\n")
assertDiffError(t, repo.check(t), "directory/file.txt")
})
t.Run("segment glob cannot cross slash", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "logs/*.txt"})
writeFixture(t, filepath.Join(repo.root, "logs", "nested", "file.txt"), "outside\n")
assertDiffError(t, repo.check(t), "logs/nested/file.txt")
})
}
func TestCheckDiffRequiresTaskAndPointerInScope(t *testing.T) {
t.Run("task packet", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.activePath})
assertDiffError(t, repo.check(t), "allow its own path")
})
t.Run("active pointer", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath})
assertDiffError(t, repo.check(t), "allow pointer path")
})
}
func TestCheckDiffCoversMultipleCommitsFromBase(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "first.txt", "second.txt"})
writeFixture(t, filepath.Join(repo.root, "first.txt"), "first\n")
repo.git(t, "add", "-A")
repo.git(t, "commit", "-m", "first task commit")
writeFixture(t, filepath.Join(repo.root, "second.txt"), "second\n")
if err := repo.check(t); err != nil {
t.Fatalf("multi-commit diff rejected: %v", err)
}
}
func TestTaskSelectionIsExplicit(t *testing.T) {
if _, err := parseTaskSelection([]string{"-active", "active.json", "-task", "task.json"}, true); err == nil {
t.Fatal("active and task selection accepted together")
}
if _, err := parseTaskSelection([]string{"-skip"}, true); err == nil {
t.Fatal("unknown skip argument accepted")
}
selection, err := parseTaskSelection(nil, true)
if err != nil || selection.activePath != defaultActiveTaskPath {
t.Fatalf("default active selection = %+v, %v", selection, err)
}
}
type diffRepo struct {
root string
base string
taskPath string
activePath string
}
func newDiffRepo(t *testing.T, baseFiles map[string]string) *diffRepo {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is required")
}
repo := &diffRepo{
root: t.TempDir(),
taskPath: "dev/tasks/TEST-0001.json",
activePath: defaultActiveTaskPath,
}
repo.git(t, "init", "--initial-branch=master")
repo.git(t, "config", "user.email", "devctl-test@example.invalid")
repo.git(t, "config", "user.name", "devctl test")
writeFixture(t, filepath.Join(repo.root, "base.txt"), "base\n")
for name, content := range baseFiles {
writeFixture(t, filepath.Join(repo.root, filepath.FromSlash(name)), content)
}
repo.git(t, "add", "-A")
repo.git(t, "commit", "-m", "base")
repo.base = strings.TrimSpace(repo.git(t, "rev-parse", "HEAD"))
return repo
}
func (repo *diffRepo) writeTask(t *testing.T, allowed []string) {
t.Helper()
packet := TaskPacket{
TaskID: "TEST-0001",
TaskRevision: 1,
BaseRepoRevision: repo.base,
RiskTier: "E",
Intent: "test exact diff scope",
}
packet.AllowedChangeScope.Paths = allowed
repo.writeJSON(t, repo.taskPath, packet)
repo.writeJSON(t, repo.activePath, ActiveTask{TaskFile: repo.taskPath, TaskID: packet.TaskID, TaskRevision: packet.TaskRevision})
}
func (repo *diffRepo) writeJSON(t *testing.T, name string, value any) {
t.Helper()
b, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatal(err)
}
writeFixture(t, filepath.Join(repo.root, filepath.FromSlash(name)), string(b)+"\n")
}
func (repo *diffRepo) check(t *testing.T) error {
t.Helper()
return checkDiffAt(repo.root, taskSelection{activePath: repo.activePath})
}
func (repo *diffRepo) git(t *testing.T, args ...string) string {
t.Helper()
out, err := runGit(repo.root, args...)
if err != nil {
t.Fatalf("git %s: %v", strings.Join(args, " "), err)
}
return string(out)
}
func assertDiffError(t *testing.T, err error, want string) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("check-diff error = %v, want substring %q", err, want)
}
}