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
313 lines
12 KiB
Go
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
|
|
}
|