264 lines
7.6 KiB
Go
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
|
|
}
|