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
412 lines
14 KiB
Go
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
|
|
}
|