Files
mesh-host/internal/compose/resolution/collections.go
T
jschoubben d68e241724 Take the catalogue's composition rules into the node-engine
ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk
carries no composition rule. The catalogue functions only the
node-engine's composition reaches (Resolve, Resolution's Compose,
Declaration, DataItems, LeftOut, Rules and the rest, the settings,
placeholder and template refusals, the rendering helpers) are copied
with their tests into internal/compose/resolution; a method of a
mesh-sdk shape becomes a function taking it first. The composition
calls its own copies, and the captured catalogue its tests read lives
here now, shared by mesh-compose's tests.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 19:15:55 +01:00

313 lines
12 KiB
Go

package resolution
import (
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) }
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
func itemOrder(it mcat.Item) int {
if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 {
return n
}
return mcat.OrderDefault
}
// matches is whether the item's attributes carry every pair of the selector.
func itemMatches(it mcat.Item, where map[string]string) bool {
for k, v := range where {
if got, has := it.Attributes[k]; !has || got != v {
return false
}
}
return true
}
// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
func itemsInOrder(items []mcat.Item) []mcat.Item {
out := append([]mcat.Item(nil), items...)
sort.SliceStable(out, func(a, b int) bool {
if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob {
return oa < ob
}
return out[a].Name < out[b].Name
})
return out
}
// oldCollectionNamespaces are the namespaces read as collections of their own name.
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
// before the first one.
func splitSelector(name string) (collection, selector string) {
eq := strings.Index(name, "=")
if eq < 0 {
return name, ""
}
colon := strings.LastIndex(name[:eq], ":")
if colon < 0 {
return name, ""
}
return name[:colon], name[colon+1:]
}
// selectorOf is a selector's pairs, or why it is none.
func selectorOf(selector string) (map[string]string, error) {
out := map[string]string{}
if selector == "" {
return out, nil
}
for _, pair := range strings.Split(selector, ",") {
k, v, ok := strings.Cut(pair, "=")
switch {
case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
case out[k] != "":
return nil, fmt.Errorf("%q names %s twice", selector, k)
}
out[k] = v
}
return out, nil
}
// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
func collectionOfPlaceholder(namespace, key string) (string, bool) {
key, _ = splitSelector(key)
switch {
case namespace == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"):
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
return mcat.EnvironmentCollection, true
case namespace == mcat.CollectedNamespace:
return key, true
case namespace == "environment":
return mcat.EnvironmentCollection, true
case slices.Contains(oldCollectionNamespaces, namespace):
return namespace + ":" + key, true
}
return "", false
}
// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
// ok is false for a name that is not an old namespace's.
//
// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
namespace, key, _ := strings.Cut(name, ":")
if !slices.Contains(oldCollectionNamespaces, namespace) {
return "", false, nil
}
switch namespace {
case "shell":
target, slot, two := strings.Cut(key, ":")
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
return "", true, fmt.Errorf("%s is no slot: shell code is shell:<shell>:<slot>, the shell one of %s, "+
"the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
strings.Join(knownSlots, ", "))
}
// shellCode's line before each module's code.
return "#", true, nil
case "environment":
if key == "" {
// The collection itself, which an item is contributed to; a placeholder names a rendering.
return "#", true, nil
}
if key != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd {
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
name, mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd)
}
return "#", true, nil
}
seat, kind, _ := strings.Cut(key, ":")
s, r, ok := receivable(seat, kind)
switch {
case !ok && s.Name == "":
return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
case !ok:
return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
case r.Shape != nil && (forItem || collectableAsText(r)):
return r.Comment, true, nil
case r.Shape != nil:
return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
"(novox/hq ADR 0255)", name, s.Name)
case r.Dirs:
return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
"the controller fills; it is not yet a collection (design 57 L1)", name)
case s.Name != canonicalSeat(seat):
return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
}
return r.Comment, true, nil
}
// isOld is whether a collection's name is an old namespace's.
func isOld(name string) bool {
_, old, _ := oldCollection(name, true)
return old
}
// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
// and naming a collection the module declares it collects.
func collectedPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
var problems []string
for _, field := range sortedKeys(r) {
v, ok := r[field].(string)
if !ok {
continue
}
for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
base, selector := splitSelector(f[1])
if _, err := selectorOf(selector); err != nil {
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
continue
}
switch {
case field != "content":
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
"only in a file's content", m.Module, r["id"], f[0], field))
case isOld(base):
if _, _, wrong := oldCollection(base, false); wrong != nil {
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
}
case !slices.ContainsFunc(m.Collections, func(c mcat.Collection) bool { return c.Name == base }):
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
"declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
}
}
}
return problems
}
// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
func collectionComment(m mcat.Manifest, name string) (string, error) {
if comment, old, wrong := oldCollection(name, false); old {
return comment, wrong
}
for _, c := range m.Collections {
if c.Name == name {
return c.Comment, nil
}
}
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
}
// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
// wanted here.
func itemsLeftOutFor(modules []mcat.Manifest, holder mcat.Manifest, collection string, where map[string]string,
facts map[string]string, caps map[string]bool) []string {
var out []string
for _, m := range inModuleOrder(modules) {
for _, it := range itemsInOrder(m.Items) {
if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
continue
}
if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
continue
}
out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
"machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
m.Module, itemKey(it), holder.Module))
}
}
return out
}
// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
func oldFormsLeftOut(modules []mcat.Manifest, holder mcat.Manifest, collections []mcat.PlanCollection) []string {
var out []string
for _, c := range collections {
namespace, key, _ := strings.Cut(c.Name, ":")
for _, m := range inModuleOrder(modules) {
old := false
switch namespace {
case "shell":
target, slot, _ := strings.Cut(key, ":")
old = slices.ContainsFunc(m.Shell, func(s mcat.ShellCode) bool { return s.For == target && s.Slot == slot })
case "environment":
old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
case "contribution":
seat, kind, _ := strings.Cut(key, ":")
old = slices.ContainsFunc(manifestAllContributions(m), func(s mcat.SeatContribution) bool {
return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
})
}
if old {
out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
"this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
holder.Module, m.Module))
}
}
}
return out
}
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
func renderedOldWay(collection string) bool {
namespace, _, _ := strings.Cut(collection, ":")
return namespace == "shell" || namespace == "contribution" || namespace == mcat.EnvironmentCollection
}
// heldItem is one item of a module rendered the old way.
type heldItem struct {
item mcat.Item
text string
}
// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
func heldItems(m mcat.Manifest, collection string, where map[string]string, facts map[string]string,
caps map[string]bool) []heldItem {
if !renderedOldWay(collection) {
return nil
}
var out []heldItem
for _, it := range itemsInOrder(m.Items) {
if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
continue
}
if text, ok := heldText(it, facts); ok {
out = append(out, heldItem{item: it, text: text})
}
}
return out
}
// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
// does not hold or which names anything but a machine fact this machine has.
func heldText(it mcat.Item, facts map[string]string) (string, bool) {
if it.Content == "" {
return "", false
}
text := it.Content
for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
value, has := facts[f[2]]
if f[1] != "machine" || !has {
return "", false
}
text = strings.ReplaceAll(text, f[0], value)
}
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
return text, true
}