216 lines
7.9 KiB
Go
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, ¤t); 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
|
|
}
|