Files
Assistant/cmd/devctl/task_validation.go
T

216 lines
7.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
var requiredPacketKeys = []string{
"task_id", "task_revision", "architecture_revision", "base_repo_revision", "risk_tier", "intent",
"requirement_ids", "allowed_change_scope", "forbidden_change_scope", "allowed_architecture_delta",
"contracts", "semantic_owner", "canonical_state", "state_transitions", "side_effects_and_privileges",
"persistence_and_migrations", "failure_retry_restart", "assumptions", "acceptance_scenarios",
"required_tests", "terminal_gap_states",
}
var requiredDeltaKeys = []string{
"new_cross_module_dependencies", "new_canonical_owners", "new_privileges",
"new_third_party_dependencies", "new_database_owner_roles",
}
func validateTaskPacket(root string, task TaskPacket, taskPath string) error {
if task.TaskID == "" || task.TaskRevision < 1 || task.ArchitectureRevision == "" || task.BaseRepoRevision == "" || task.RiskTier == "" || task.Intent == "" {
return fmt.Errorf("task %s is incomplete: identity, revision, architecture_revision, base_repo_revision, risk_tier and intent are required", task.TaskID)
}
if taskPath == "" || !matchesAny(taskPath, mustCompile([]string{"dev/tasks/*.json"})) {
return fmt.Errorf("task %s must be selected from dev/tasks", task.TaskID)
}
if task.RiskTier < "A" || task.RiskTier > "E" {
return fmt.Errorf("task %s has invalid risk_tier %q", task.TaskID, task.RiskTier)
}
if len(task.RequirementIDs) == 0 || len(task.AcceptanceScenarios) == 0 || len(task.RequiredTests) == 0 || len(task.TerminalGapStates) == 0 {
return fmt.Errorf("task %s must declare requirements, acceptance_scenarios, required_tests and terminal_gap_states", task.TaskID)
}
if len(task.AllowedChangeScope.Paths) == 0 {
return fmt.Errorf("task %s must declare allowed_change_scope.paths", task.TaskID)
}
if err := validateScopeOverlap(task.AllowedChangeScope.Paths, task.ForbiddenChangeScope.Paths); err != nil {
return fmt.Errorf("task %s scope: %w", task.TaskID, err)
}
if err := validateDelta(task.AllowedArchitectureDelta); err != nil {
return fmt.Errorf("task %s architecture delta: %w", task.TaskID, err)
}
if err := validateRequiredTests(task.RequiredTests); err != nil {
return fmt.Errorf("task %s required_tests: %w", task.TaskID, err)
}
if err := validateArchitectureRevision(root, task.ArchitectureRevision); err != nil {
return fmt.Errorf("task %s architecture_revision: %w", task.TaskID, err)
}
if _, err := resolveBaseCommit(root, task.BaseRepoRevision); err != nil {
return fmt.Errorf("task %s base_repo_revision: %w", task.TaskID, err)
}
if err := validateObservedDelta(root, task); err != nil {
return fmt.Errorf("task %s observed architecture delta: %w", task.TaskID, err)
}
return nil
}
func validateRequiredTests(tests []string) error {
joined := strings.Join(tests, "\n")
for _, required := range []string{"go test", "go vet", "preflight", "check-diff", "git diff --check", "independent"} {
if !strings.Contains(joined, required) {
return fmt.Errorf("missing required governance evidence %q", required)
}
}
return nil
}
func validateObservedDelta(root string, task TaskPacket) error {
changed, err := changedPaths(root, task.BaseRepoRevision)
if err != nil {
return err
}
if err := validateRegistryDelta(root, task); err != nil {
return err
}
for _, changedPath := range changed {
if (strings.HasPrefix(changedPath, "security/") || strings.HasPrefix(changedPath, "deploy/")) && task.AllowedArchitectureDelta["new_privileges"] == 0 {
return fmt.Errorf("privileged surface changed with new_privileges=0: %s", changedPath)
}
if strings.HasPrefix(changedPath, "database/") && task.AllowedArchitectureDelta["new_database_owner_roles"] == 0 {
return fmt.Errorf("database surface changed with new_database_owner_roles=0: %s", changedPath)
}
if (changedPath == "go.mod" || changedPath == "go.sum") && task.AllowedArchitectureDelta["new_third_party_dependencies"] == 0 {
return fmt.Errorf("dependency manifest changed with new_third_party_dependencies=0: %s", changedPath)
}
if strings.HasPrefix(changedPath, "contracts/") && len(task.Contracts.MayChange) == 0 {
return fmt.Errorf("contract surface changed but contracts.may_change is empty: %s", changedPath)
}
}
return nil
}
func validateRegistryDelta(root string, task TaskPacket) error {
baseBytes, err := runGit(root, "show", task.BaseRepoRevision+":architecture/architecture-registry.json")
if err != nil {
return fmt.Errorf("read base architecture registry: %w", err)
}
currentBytes, err := os.ReadFile(filepath.Join(root, "architecture", "architecture-registry.json"))
if err != nil {
return err
}
var base, current Registry
if err := json.Unmarshal(baseBytes, &base); err != nil {
return fmt.Errorf("parse base architecture registry: %w", err)
}
if err := json.Unmarshal(currentBytes, &current); err != nil {
return fmt.Errorf("parse current architecture registry: %w", err)
}
if task.AllowedArchitectureDelta["new_canonical_owners"] == 0 {
for name := range current.Modules {
if _, ok := base.Modules[name]; !ok {
return fmt.Errorf("new canonical module owner %q with new_canonical_owners=0", name)
}
}
}
if task.AllowedArchitectureDelta["new_cross_module_dependencies"] == 0 {
for name, module := range current.Modules {
previous, ok := base.Modules[name]
if !ok {
continue
}
old := map[string]bool{}
for _, dep := range previous.MayDependOn {
old[dep] = true
}
for _, dep := range module.MayDependOn {
if !old[dep] {
return fmt.Errorf("new dependency %q in module %q with new_cross_module_dependencies=0", dep, name)
}
}
}
}
if task.AllowedArchitectureDelta["new_privileges"] == 0 {
for name, module := range current.Modules {
if previous, ok := base.Modules[name]; ok && module.Privileged && !previous.Privileged {
return fmt.Errorf("new privilege in module %q with new_privileges=0", name)
}
}
}
return nil
}
func validateDelta(delta map[string]int) error {
if len(delta) != len(requiredDeltaKeys) {
return fmt.Errorf("must contain exactly %s", strings.Join(requiredDeltaKeys, ", "))
}
for _, key := range requiredDeltaKeys {
value, ok := delta[key]
if !ok || value < 0 {
return fmt.Errorf("%s must be a non-negative integer", key)
}
}
return nil
}
func validateArchitectureRevision(root, revision string) error {
if strings.HasPrefix(revision, "ADR-") {
matches, err := filepath.Glob(filepath.Join(root, "architecture", "adrs", revision+"-*.md"))
if err != nil || len(matches) != 1 {
return fmt.Errorf("accepted ADR %q is missing or ambiguous", revision)
}
return nil
}
if strings.HasPrefix(revision, "ARCH-") {
matches, err := filepath.Glob(filepath.Join(root, "dev", "tasks", revision+"*.json"))
if err != nil || len(matches) == 0 {
return fmt.Errorf("architecture task %q is missing", revision)
}
return nil
}
return fmt.Errorf("must reference an accepted ADR-NNNN or architecture task")
}
func validateScopeOverlap(allowed, forbidden []string) error {
allowedPatterns, err := compileScopePatterns(allowed)
if err != nil {
return err
}
forbiddenPatterns, err := compileScopePatterns(forbidden)
if err != nil {
return fmt.Errorf("forbidden_change_scope: %w", err)
}
for _, a := range allowedPatterns {
for _, f := range forbiddenPatterns {
if scopePatternsOverlap(a, f) {
return fmt.Errorf("allowed path %q overlaps forbidden path %q", a.raw, f.raw)
}
}
}
return nil
}
func scopePatternsOverlap(a, b scopePattern) bool {
if a.subtree != "" && b.subtree != "" {
return a.subtree == b.subtree || strings.HasPrefix(a.subtree, b.subtree+"/") || strings.HasPrefix(b.subtree, a.subtree+"/")
}
if a.subtree != "" {
return b.raw == a.subtree || strings.HasPrefix(b.raw, a.subtree+"/")
}
if b.subtree != "" {
return a.raw == b.subtree || strings.HasPrefix(a.raw, b.subtree+"/")
}
return a.raw == b.raw
}
func mustCompile(values []string) []scopePattern {
patterns, err := compileScopePatterns(values)
if err != nil {
panic(err)
}
return patterns
}