Files
mesh-delivery/sdk/graph/graph.go
T
jschoubben 1387b10e78 Take a missing graph state as no graph yet and a Publisher's directory as the nearest module above it
The indexer's recompute and merge failed on listing a graph bucket not yet on the bus, and a package published from inside a module's directory had no publisher, so the dependents of a build module missed what imports its packages (review findings 3 and 4 of L3).

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 17:21:01 +01:00

412 lines
14 KiB
Go

package graph
import (
"encoding/json"
"errors"
"fmt"
"maps"
"reflect"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Version is the version of a node's value this package writes and the only one it reads.
const Version = 1
// ErrUnknownVersion is what Decode returns for a value whose version is missing or not Version.
var ErrUnknownVersion = errors.New("graph: unknown value version")
func decode(data []byte, into *Node) error {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("graph: value does not fit its shape: %w", err)
}
if probe.V == nil || *probe.V != Version {
return fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version)
}
if err := json.Unmarshal(data, into); err != nil {
return fmt.Errorf("graph: value does not fit its shape: %w", err)
}
return nil
}
// checkName refuses a module name a key cannot hold (delivery.ModulesKey's rule).
func checkName(kind, name string) error {
if _, err := delivery.ModulesKey(name); err != nil {
return fmt.Errorf("graph: the %s name %q: %w", kind, name, err)
}
return nil
}
// checkRepository refuses a repository that is not <owner>/<name>.
func checkRepository(repository string) error {
owner, name, ok := strings.Cut(repository, "/")
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
return fmt.Errorf("graph: the repository %q is not <owner>/<name>", repository)
}
return nil
}
// The module graph (novox/hq ADR 0603 §2, ADR 0604 §1): the indexer keeps, as its state `graph`, one key per module it
// holds the inputs of, written at every recompute and deleted with the module. A key holds the module's build edges
// (its bases, the mesh packages its fingerprint takes, and per language the build module, toolchain version and recipe
// version its fingerprint names, ADR 0530 §1) and its provision edges (what its manifest provides, requires, and of what
// it requires what a want of it waits for). Nothing stores an order: a reader works it out from the edges (ADR 0603
// §1, §6). The indexer writes it and works the edges out (EdgesOf), the delivery view, the checker and the indexer's
// answers of the seat mesh-catalog read it; all three are in this repository, which uses this package by path (the
// operator, 2026-11-03: no graph-related code in mesh-sdk, only contracts). It names no module.
const (
// State is the name of the indexer's state of the module graph, as its manifest declares it, and Bucket its
// bucket: one key per module (Key).
State = "graph"
Bucket = delivery.IndexerModule + "_" + State
// Read is the line a reader's manifest names in `reads` to read the graph.
Read = delivery.IndexerModule + "." + State
// History is the revisions the graph state keeps per key (ADR 0603 §2).
History = 5
)
// Key is the key, in Bucket, of a module: `<module>`, as ModulesKey.
func Key(module string) (string, error) { return delivery.ModulesKey(module) }
// ParseKey is the inverse of Key.
func ParseKey(key string) (string, error) {
module, err := Key(key)
if err != nil {
return "", fmt.Errorf("graph: %q is not a graph key: %w", key, err)
}
return module, nil
}
// Node is the value of a graph key: one module's edges as its last recompute computed them. FP is its new-form
// fingerprint then, or None why it has none (exactly one is set); Repository and Path say where it is (as Module), so a
// package edge is resolved to the module that publishes it (GraphPublisher). Bases are the modules it is built on;
// Packages the mesh packages (Go module paths) its fingerprint took (Module.Packages); Toolchains, by language, the
// build module and the toolchain it was computed with. Provides are the provisions its manifest offers (its own name is
// provided too and is not listed), Requires what it requires and does not require optionally, and Awaits those of
// Requires a want of it waits for (catalogue.Awaited, ADR 0570 §4): the provision edges that order a source revision.
// Every list is sorted and each entry once; an encode sorts them.
type Node struct {
V int `json:"v"`
FP string `json:"fp,omitempty"`
None string `json:"none,omitempty"`
Repository string `json:"repository"`
Path string `json:"path,omitempty"`
Bases []string `json:"bases,omitempty"`
Packages []string `json:"packages,omitempty"`
Toolchains map[string]Toolchain `json:"toolchains,omitempty"`
Provides []string `json:"provides,omitempty"`
Requires []string `json:"requires,omitempty"`
Awaits []string `json:"awaits,omitempty"`
}
// Toolchain is a build edge of one language: the build module that builds it (Module) and the toolchain version
// and recipe version its fingerprint names.
type Toolchain struct {
Module string `json:"module"`
Version string `json:"version"`
Recipe string `json:"recipe"`
}
func (g Node) check() error {
switch {
case (g.FP == "") == (g.None == ""):
return fmt.Errorf("graph: a graph node has either a fingerprint or a reason for none, not both or neither")
case g.FP != "" && !strings.HasPrefix(g.FP, delivery.SourcePrefix2):
return fmt.Errorf("graph: the graph node's fingerprint %q does not start with %s", g.FP, delivery.SourcePrefix2)
}
if err := checkRepository(g.Repository); err != nil {
return err
}
for _, b := range g.Bases {
if err := checkName("base", b); err != nil {
return err
}
}
for _, p := range g.Packages {
if _, _, ok := delivery.PackageRepository(p); !ok {
return fmt.Errorf("graph: the graph node's package %q is no mesh package's path", p)
}
}
for language, t := range g.Toolchains {
if err := checkName("build module", t.Module); err != nil {
return fmt.Errorf("graph: the graph node's toolchain of %q: %w", language, err)
}
if language == "" || t.Version == "" || t.Recipe == "" {
return fmt.Errorf("graph: the graph node's toolchain of %q needs its language, version and recipe version", language)
}
}
for _, list := range [][]string{g.Provides, g.Requires, g.Awaits} {
if slices.Contains(list, "") {
return fmt.Errorf("graph: a graph node names an empty provision")
}
}
for _, a := range g.Awaits {
if !slices.Contains(g.Requires, a) {
return fmt.Errorf("graph: the graph node awaits %q, which it does not require", a)
}
}
return nil
}
func sortedOnce(in []string) []string {
if len(in) == 0 {
return nil
}
out := slices.Clone(in)
slices.Sort(out)
return slices.Compact(out)
}
func (g Node) normal() Node {
g.Bases, g.Packages = sortedOnce(g.Bases), sortedOnce(g.Packages)
g.Provides, g.Requires, g.Awaits = sortedOnce(g.Provides), sortedOnce(g.Requires), sortedOnce(g.Awaits)
if len(g.Toolchains) == 0 {
g.Toolchains = nil
}
return g
}
// Encode is g as JSON at Version, its lists sorted; it refuses what Node's contract does not hold.
func Encode(g Node) ([]byte, error) {
g = g.normal()
if err := g.check(); err != nil {
return nil, err
}
g.V = Version
return json.Marshal(g)
}
// Decode reads a graph node; it refuses an unknown version (ErrUnknownVersion) and what Encode refuses.
func Decode(data []byte) (Node, error) {
var g Node
if err := decode(data, &g); err != nil {
return Node{}, err
}
if err := g.check(); err != nil {
return Node{}, err
}
return g.normal(), nil
}
// Same says whether two graph nodes are the same value once sorted, so a writer writes only when they differ.
func Same(a, b Node) bool {
a, b = a.normal(), b.normal()
a.V, b.V = 0, 0
return reflect.DeepEqual(a, b)
}
// Graph is the module graph as a reader holds it: every graph node, by module.
type Graph map[string]Node
// Providers are the modules that provide provision: each whose Provides names it, and the module of that name (a module
// provides its own name), sorted.
func (g Graph) Providers(provision string) []string {
var out []string
for name, n := range g {
if name == provision || slices.Contains(n.Provides, provision) {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// Requirers are the modules that require provision, not optionally, sorted.
func (g Graph) Requirers(provision string) []string {
var out []string
for name, n := range g {
if slices.Contains(n.Requires, provision) {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// Publisher is the module of the graph whose repository and directory publish the mesh package pkg (PackageRepository):
// the module whose directory is the package's directory or the nearest one above it (the longest match, a package
// published from inside a module's directory belonging to that module), or "" when the graph holds none.
func (g Graph) Publisher(pkg string) string {
repository, dir, ok := delivery.PackageRepository(pkg)
if !ok {
return ""
}
if dir == "" {
dir = delivery.RootPath
}
best, bestLen := "", -1
for _, name := range slices.Sorted(maps.Keys(g)) {
n := g[name]
if n.Repository != repository {
continue
}
switch {
case n.Path == dir:
return name
case n.Path == delivery.RootPath:
if bestLen < 0 {
best, bestLen = name, 0
}
case strings.HasPrefix(dir, n.Path+"/") && len(n.Path) > bestLen:
best, bestLen = name, len(n.Path)
}
}
return best
}
// buildEdges are the modules n's build edges reach: its bases, the publisher of each of its packages, and the build
// module of each language.
func (g Graph) buildEdges(n Node) []string {
out := slices.Clone(n.Bases)
for _, p := range n.Packages {
if m := g.Publisher(p); m != "" {
out = append(out, m)
}
}
for _, t := range n.Toolchains {
out = append(out, t.Module)
}
return sortedOnce(out)
}
// Dependents are the modules whose build edges reach module, directly or through other modules' (a base of a base, a
// module built by a build module that stands on module), sorted and without module itself (ADR 0604 §1): what a
// change of module builds again.
func (g Graph) Dependents(module string) []string {
reverse := map[string][]string{}
for _, name := range slices.Sorted(maps.Keys(g)) {
for _, to := range g.buildEdges(g[name]) {
reverse[to] = append(reverse[to], name)
}
}
seen := map[string]bool{module: true}
queue := []string{module}
var out []string
for len(queue) > 0 {
at := queue[0]
queue = queue[1:]
for _, from := range reverse[at] {
if !seen[from] {
seen[from] = true
out = append(out, from)
queue = append(queue, from)
}
}
}
slices.Sort(out)
return out
}
// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases and, for each provision it
// awaits, every other module that provides it (ADR 0603 §1). A toolchain or package edge orders nothing (§3).
func (g Graph) Before(name string) []string {
n := g[name]
out := slices.Clone(n.Bases)
for _, a := range n.Awaits {
for _, p := range g.Providers(a) {
if p != name {
out = append(out, p)
}
}
}
return sortedOnce(out)
}
// Cycle is the first cycle of the graph's ordering edges, as the modules it passes through with the first named again
// at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges are a
// module's bases and, for each provision it awaits, every other module that provides it: modules ordered on each other
// would each wait for the other for good. A toolchain edge is no ordering edge (ADR 0603 §3: nothing waits on a build
// module), and neither is a package edge. The modules are visited in sorted order, so the same graph names the same
// cycle.
func (g Graph) Cycle() []string {
edges := g.Before
const (
unseen = iota
onPath
done
)
state := map[string]int{}
var path []string
var visit func(string) []string
visit = func(name string) []string {
state[name] = onPath
path = append(path, name)
for _, to := range edges(name) {
if _, ours := g[to]; !ours {
continue
}
switch state[to] {
case onPath:
i := slices.Index(path, to)
return append(slices.Clone(path[i:]), to)
case unseen:
if c := visit(to); c != nil {
return c
}
}
}
path = path[:len(path)-1]
state[name] = done
return nil
}
for _, name := range slices.Sorted(maps.Keys(g)) {
if state[name] == unseen {
if c := visit(name); c != nil {
return c
}
}
}
return nil
}
// EdgesOf is the graph node of manifest's own edges: its bases (each module its build stands on), what it provides,
// what it requires and does not require optionally, and of those what a want of it waits for (catalogue.Awaited, as the
// assigner judges it), providers answering the manifests that provide a provision. The fingerprint, place, packages and
// toolchains are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way.
func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []catalogue.Manifest) Node {
var n Node
if manifest.Build != nil {
for _, on := range manifest.Build.On {
if on.Module != "" {
n.Bases = append(n.Bases, on.Module)
}
}
}
for _, o := range manifest.Provides {
n.Provides = append(n.Provides, o.Name)
}
for _, r := range manifest.Requires {
if !manifest.OptionalRequires[r] {
n.Requires = append(n.Requires, r)
}
}
n.Awaits = catalogue.Awaited(manifest, providers)
return n.normal()
}
// OfManifests is the graph of these manifests' own edges (EdgesOf), by module, each provision's providers being
// the manifests among them that offer it: what the check works out for a change to find a cycle (ADR 0603 §2).
func OfManifests(manifests map[string]catalogue.Manifest) Graph {
providers := func(provision string) []catalogue.Manifest {
var out []catalogue.Manifest
for _, name := range slices.Sorted(maps.Keys(manifests)) {
if slices.Contains(manifests[name].Offers(), provision) {
out = append(out, manifests[name])
}
}
return out
}
g := Graph{}
for name, m := range manifests {
g[name] = EdgesOf(m, providers)
}
return g
}