Files

264 lines
7.6 KiB
Go

package main
import (
"encoding/json"
"fmt"
"go/parser"
"go/token"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
)
type Registry struct {
RegistryVersion int `json:"registry_version"`
SemanticGeneration int `json:"semantic_generation"`
ArchitectureGeneration int `json:"architecture_generation"`
Modules map[string]struct {
ID string `json:"id"`
Owns []string `json:"owns"`
DatabaseSchema string `json:"database_schema"`
BoundaryClass string `json:"boundary_class"`
MayDependOn []string `json:"may_depend_on"`
Forbidden []string `json:"forbidden"`
Privileged bool `json:"privileged"`
} `json:"modules"`
ProtectedPaths []string `json:"protected_paths"`
}
type TaskPacket struct {
TaskID string `json:"task_id"`
TaskRevision int `json:"task_revision"`
ArchitectureRevision string `json:"architecture_revision"`
BaseRepoRevision string `json:"base_repo_revision"`
RiskTier string `json:"risk_tier"`
Intent string `json:"intent"`
RequirementIDs []string `json:"requirement_ids"`
AllowedChangeScope struct {
Modules []string `json:"modules"`
Paths []string `json:"paths"`
} `json:"allowed_change_scope"`
ForbiddenChangeScope struct {
Paths []string `json:"paths"`
} `json:"forbidden_change_scope"`
AllowedArchitectureDelta map[string]int `json:"allowed_architecture_delta"`
Contracts struct {
Consume []string `json:"consume"`
MayChange []string `json:"may_change"`
} `json:"contracts"`
SemanticOwner string `json:"semantic_owner"`
CanonicalState []string `json:"canonical_state"`
StateTransitions []string `json:"state_transitions"`
SideEffectsAndPrivileges []string `json:"side_effects_and_privileges"`
PersistenceAndMigrations []string `json:"persistence_and_migrations"`
FailureRetryRestart []string `json:"failure_retry_restart"`
Assumptions []string `json:"assumptions"`
AcceptanceScenarios []string `json:"acceptance_scenarios"`
RequiredTests []string `json:"required_tests"`
TerminalGapStates []string `json:"terminal_gap_states"`
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "doctor":
err = doctor()
case "check-architecture":
err = checkArchitecture()
case "show-registry":
err = showRegistry()
case "preflight":
err = preflight(os.Args[2:])
case "check-diff":
err = checkDiff(os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "devctl:", err)
os.Exit(1)
}
}
func usage() {
fmt.Println("devctl doctor | check-architecture | show-registry | preflight (-active <json> | -task <json>) | check-diff [-active <json> | -task <json>]")
}
func loadRegistry() (Registry, error) {
b, err := os.ReadFile("architecture/architecture-registry.json")
if err != nil {
return Registry{}, err
}
var r Registry
if err = json.Unmarshal(b, &r); err != nil {
return Registry{}, err
}
return r, nil
}
func doctor() error {
r, err := loadRegistry()
if err != nil {
return err
}
fmt.Printf("architecture generation=%d semantic generation=%d modules=%d\n", r.ArchitectureGeneration, r.SemanticGeneration, len(r.Modules))
for _, tool := range []string{"git", "go", "docker"} {
p, e := exec.LookPath(tool)
if e != nil {
fmt.Printf("WARN: %s not found\n", tool)
} else {
fmt.Printf("OK: %s -> %s\n", tool, p)
}
}
cmd := exec.Command("git", "remote", "get-url", "origin")
out, e := cmd.CombinedOutput()
if e == nil {
fmt.Printf("git origin: %s", out)
} else {
fmt.Println("WARN: git origin not configured; run scripts/bootstrap.ps1")
}
return nil
}
func showRegistry() error {
r, e := loadRegistry()
if e != nil {
return e
}
b, _ := json.MarshalIndent(r, "", " ")
fmt.Println(string(b))
return nil
}
func checkArchitecture() error {
return checkArchitectureAt(".")
}
func checkArchitectureAt(root string) error {
b, err := os.ReadFile(filepath.Join(root, "architecture", "architecture-registry.json"))
if err != nil {
return err
}
var r Registry
if err := json.Unmarshal(b, &r); err != nil {
return err
}
var violations []string
err = filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if len(parts) < 3 {
return nil
}
owner := parts[1]
module, ok := r.Modules[owner]
if !ok {
violations = append(violations, relative+": unknown module owner "+owner)
return nil
}
parsed, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if parseErr != nil {
return parseErr
}
allowed := map[string]bool{}
for _, dependency := range module.MayDependOn {
allowed[dependency] = true
}
for _, imported := range parsed.Imports {
importPath := strings.Trim(imported.Path.Value, "\"")
if strings.Contains(filepath.ToSlash(relative), "/domain/") {
for _, bad := range []string{"/adapters/", "/runtime/", "/cmd/"} {
if strings.Contains(importPath, "git.cin.su/m/Assistant"+bad) {
violations = append(violations, relative+": domain imports forbidden "+bad)
}
}
}
modulePrefix := "git.cin.su/m/Assistant/modules/"
if strings.HasPrefix(importPath, modulePrefix) {
other := strings.Split(strings.TrimPrefix(importPath, modulePrefix), "/")[0]
if other != owner {
violations = append(violations, relative+": cross-module implementation import "+other)
}
}
contractPrefix := "git.cin.su/m/Assistant/contracts/"
if strings.HasPrefix(importPath, contractPrefix) {
contractOwner := strings.Split(strings.TrimPrefix(importPath, contractPrefix), "/")[0]
if contractOwner != owner && !allowed["contracts."+contractOwner] {
violations = append(violations, relative+": undeclared contract dependency contracts."+contractOwner)
}
}
}
return nil
})
if err != nil {
return err
}
if len(violations) > 0 {
for _, v := range violations {
fmt.Println("VIOLATION:", v)
}
return fmt.Errorf("%d architecture violation(s): %s", len(violations), strings.Join(violations, "; "))
}
fmt.Println("architecture checks: OK")
return nil
}
func preflight(args []string) error {
selection, err := parseTaskSelection(args, false)
if err != nil {
return err
}
root, err := gitRepositoryRoot(".")
if err != nil {
return err
}
taskPacket, taskPath, _, err := loadSelectedTask(root, selection)
if err != nil {
return err
}
if err := validateTaskPacket(root, taskPacket, taskPath); err != nil {
return err
}
registry, err := loadRegistry()
if err != nil {
return err
}
if err := validateTaskScope(taskPacket, registry); err != nil {
return err
}
fmt.Printf("task preflight loaded: %s tier=%s\n", taskPacket.TaskID, taskPacket.RiskTier)
return checkArchitecture()
}
func validateTaskScope(taskPacket TaskPacket, registry Registry) error {
if taskPacket.RiskTier == "E" {
return nil
}
for _, allowed := range taskPacket.AllowedChangeScope.Paths {
allowed = filepath.ToSlash(allowed)
for _, protected := range registry.ProtectedPaths {
prefix := strings.TrimSuffix(filepath.ToSlash(protected), "/**")
if allowed == prefix || strings.HasPrefix(allowed, prefix+"/") {
return fmt.Errorf("task %s tier %s may not write protected path %s", taskPacket.TaskID, taskPacket.RiskTier, allowed)
}
}
}
return nil
}