Declare preferences with a type and a predefined key, and lay mesh and node config between default and node (hq issue 630)
A preference may fall back to one reviewed mesh-wide or node-wide value instead of a module layer for the whole mesh; like a default, config fills ${setting:} only.
This commit is contained in:
@@ -530,6 +530,12 @@ type Manifest struct {
|
||||
// stays refused by name until a layer sets it. The mesh's layer, then the node's, override it.
|
||||
Settings map[string]SettingDeclaration `json:"settings,omitempty"`
|
||||
|
||||
// Preferences are the values this module reads that a machine may tune (novox/hq issue 630): each
|
||||
// key's type, default and why, and the predefined key it falls back to, if any. The value resolves
|
||||
// from the default, then mesh config and node config for the predefined key, then this module's own
|
||||
// value on the node. `settings` entries of kind preference are read as preferences while they move.
|
||||
Preferences map[string]Preference `json:"preferences,omitempty"`
|
||||
|
||||
// unknown is the first key this manifest has that this controller does not know, at any depth, when
|
||||
// it was read from the store (novox/hq ADR 0262): a manifest a newer controller registered.
|
||||
// ParseManifest refuses it; the store's catalogue still loads, and the module is left out of every
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A config value resolves from four sources, the last set winning (novox/hq issue 630):
|
||||
//
|
||||
// 1. the default in the module's manifest;
|
||||
// 2. mesh config: a mesh-wide value for a predefined key, such as `font-size`;
|
||||
// 3. node config: a value for a predefined key for one node;
|
||||
// 4. module-assignment config: that module's own value on that node, the most specific.
|
||||
//
|
||||
// An assignment is one module on one node: there is no module layer for the whole mesh. Mesh and node
|
||||
// config hold values only for the keys on a fixed, reviewable list — the catalogue's `predefined.json` —
|
||||
// that any module may fall back to, by naming one as its preference's `predefined`. A preference without
|
||||
// `predefined` reads only its default and its module-assignment config. A preference is never trusted.
|
||||
|
||||
// Preference is one value a module reads that a machine may tune, as the manifest declares it under
|
||||
// `preferences`.
|
||||
type Preference struct {
|
||||
// Type is "number", "string" or "boolean".
|
||||
Type string `json:"type"`
|
||||
// Default is the value when no source sets it, of Type.
|
||||
Default any `json:"default"`
|
||||
// Predefined names the key on the catalogue's list this preference falls back to: mesh config,
|
||||
// then node config, for that key. Empty reads only the default and the module-assignment config.
|
||||
Predefined string `json:"predefined,omitempty"`
|
||||
// Why this default: one sentence for whoever wonders whether to change it.
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// The types a preference and a predefined key may have.
|
||||
const (
|
||||
TypeNumber = "number"
|
||||
TypeString = "string"
|
||||
TypeBoolean = "boolean"
|
||||
)
|
||||
|
||||
// PredefinedFile is the catalogue repository's root file holding the predefined keys.
|
||||
const PredefinedFile = "predefined.json"
|
||||
|
||||
// PredefinedKey is one key mesh and node config may hold a value for.
|
||||
type PredefinedKey struct {
|
||||
Type string `json:"type"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// Predefined is the list of predefined keys, by key.
|
||||
type Predefined map[string]PredefinedKey
|
||||
|
||||
// Keys is the list's keys, sorted.
|
||||
func (p Predefined) Keys() []string {
|
||||
keys := make([]string, 0, len(p))
|
||||
for k := range p {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// predefinedName is what a predefined key looks like: the shape of a setting key.
|
||||
var predefinedName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
||||
|
||||
// ParsePredefined reads `predefined.json` strictly, and refuses a key whose name, type or why is wrong.
|
||||
func ParsePredefined(raw []byte) (Predefined, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var list Predefined
|
||||
if err := decoder.Decode(&list); err != nil {
|
||||
return nil, fmt.Errorf("%s is not a list of predefined keys ({\"<key>\": {\"type\": …, \"why\": …}}): %w",
|
||||
PredefinedFile, err)
|
||||
}
|
||||
var problems []string
|
||||
for _, key := range list.Keys() {
|
||||
k := list[key]
|
||||
if !predefinedName.MatchString(key) {
|
||||
problems = append(problems, fmt.Sprintf("%q is not a usable key: lower-case letters, digits, dots, dashes and underscores", key))
|
||||
}
|
||||
if meshWords[key] {
|
||||
problems = append(problems, fmt.Sprintf("%q is a key the mesh reads itself", key))
|
||||
}
|
||||
if !knownType(k.Type) {
|
||||
problems = append(problems, fmt.Sprintf("%q has the type %q; a type is %q, %q or %q", key, k.Type,
|
||||
TypeNumber, TypeString, TypeBoolean))
|
||||
}
|
||||
if strings.TrimSpace(k.Why) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%q has no why: say in one sentence what the key is for", key))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, fmt.Errorf("%s: %s", PredefinedFile, strings.Join(problems, "; "))
|
||||
}
|
||||
if list == nil {
|
||||
list = Predefined{}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func knownType(t string) bool {
|
||||
return t == TypeNumber || t == TypeString || t == TypeBoolean
|
||||
}
|
||||
|
||||
// TypeOf is the type a JSON value has, as a preference names it, or "" for one that is none of them.
|
||||
func TypeOf(v any) string {
|
||||
switch v.(type) {
|
||||
case float64, int, int64, json.Number:
|
||||
return TypeNumber
|
||||
case string:
|
||||
return TypeString
|
||||
case bool:
|
||||
return TypeBoolean
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PreferencesOf is every preference a module declares: those under `preferences`, and — while the
|
||||
// catalogue moves to them — every `settings` entry of kind preference, its type read from its default.
|
||||
func PreferencesOf(m Manifest) map[string]Preference {
|
||||
if len(m.Preferences) == 0 && len(m.Settings) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]Preference, len(m.Preferences)+len(m.Settings))
|
||||
for key, d := range m.Settings {
|
||||
out[key] = Preference{Type: TypeOf(d.Default), Default: d.Default, Why: d.Why}
|
||||
}
|
||||
for key, p := range m.Preferences {
|
||||
out[key] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeclaresPreference is whether a module declares a preference by this key.
|
||||
func DeclaresPreference(m Manifest, key string) bool {
|
||||
_, has := m.Preferences[key]
|
||||
if !has {
|
||||
_, has = m.Settings[key]
|
||||
}
|
||||
return has
|
||||
}
|
||||
|
||||
// PredefinedProblems is every way a module's preferences disagree with the list of predefined keys: a
|
||||
// `predefined` not on it, or a type other than the list's.
|
||||
func PredefinedProblems(m Manifest, list Predefined) []string {
|
||||
var problems []string
|
||||
keys := make([]string, 0, len(m.Preferences))
|
||||
for k := range m.Preferences {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
p := m.Preferences[key]
|
||||
if p.Predefined == "" {
|
||||
continue
|
||||
}
|
||||
k, on := list[p.Predefined]
|
||||
if !on {
|
||||
listed := "the list holds none"
|
||||
if len(list) > 0 {
|
||||
listed = "the list holds " + strings.Join(list.Keys(), ", ")
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s's preference %q falls back to the predefined key %q, which "+
|
||||
"is not on the catalogue's list of predefined keys (%s; %s)", m.Module, key, p.Predefined, PredefinedFile,
|
||||
listed))
|
||||
continue
|
||||
}
|
||||
if k.Type != p.Type {
|
||||
problems = append(problems, fmt.Sprintf("%s's preference %q is a %s, and the predefined key %q it falls "+
|
||||
"back to is a %s", m.Module, key, p.Type, p.Predefined, k.Type))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// Where a value from mesh or node config is said to come from.
|
||||
const MeshConfigLayer = "mesh config"
|
||||
|
||||
// NodeConfigLayer is what the node config of one node is called where a value's source is said.
|
||||
func NodeConfigLayer(node string) string { return "node config (" + node + ")" }
|
||||
|
||||
// ConfigLayers are the layers mesh config and node config make for one module: each of its preferences
|
||||
// that names a predefined key, given that key's value. Laid between the module's defaults and its
|
||||
// module-assignment config; like the defaults they fill `${setting:<key>}` only.
|
||||
func ConfigLayers(m Manifest, mesh, node map[string]any, nodeName string) []Layer {
|
||||
var out []Layer
|
||||
for _, source := range []struct {
|
||||
from string
|
||||
values map[string]any
|
||||
}{{MeshConfigLayer, mesh}, {NodeConfigLayer(nodeName), node}} {
|
||||
values := map[string]any{}
|
||||
for key, p := range m.Preferences {
|
||||
if p.Predefined == "" {
|
||||
continue
|
||||
}
|
||||
if v, set := source.values[p.Predefined]; set {
|
||||
values[key] = v
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
out = append(out, Layer{From: source.from, Values: values, Config: true})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ConfigValueProblem refuses a value of the wrong type for a predefined key or a preference.
|
||||
func ConfigValueProblem(what string, want string, v any) error {
|
||||
if got := TypeOf(v); got != want {
|
||||
shown, _ := json.Marshal(v)
|
||||
return fmt.Errorf("%s is a %s, and %s is %s", what, want, shown, aOrNothing(got))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func aOrNothing(t string) string {
|
||||
if t == "" {
|
||||
return "none of a number, a string or a boolean"
|
||||
}
|
||||
return "a " + t
|
||||
}
|
||||
|
||||
// preferenceProblems is every way a module's `preferences` are wrong in themselves.
|
||||
func preferenceProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
keys := make([]string, 0, len(m.Preferences))
|
||||
for k := range m.Preferences {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
p := m.Preferences[key]
|
||||
say := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf("%s's preference %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
||||
}
|
||||
if _, twice := m.Settings[key]; twice {
|
||||
say("declared under settings too; declare it once, under preferences")
|
||||
}
|
||||
if !knownType(p.Type) {
|
||||
say("the type is %q; a type is %q, %q or %q", p.Type, TypeNumber, TypeString, TypeBoolean)
|
||||
} else if p.Default == nil {
|
||||
say("no default: a preference has one")
|
||||
} else if got := TypeOf(p.Default); got != p.Type {
|
||||
shown, _ := json.Marshal(p.Default)
|
||||
say("it is a %s, and its default %s is %s", p.Type, shown, aOrNothing(got))
|
||||
}
|
||||
if p.Predefined != "" && !predefinedName.MatchString(p.Predefined) {
|
||||
say("%q is not a predefined key's name", p.Predefined)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PredefinedNamed is every predefined key a module's preferences fall back to, sorted.
|
||||
func PredefinedNamed(m Manifest) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, p := range m.Preferences {
|
||||
if p.Predefined != "" && !seen[p.Predefined] {
|
||||
seen[p.Predefined] = true
|
||||
out = append(out, p.Predefined)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -114,15 +114,23 @@ func operatorsWord(key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// SettingProblems is every way a definition's setting defaults are wrong, in its own words.
|
||||
// SettingProblems is every way a definition's setting defaults and preferences are wrong, in its own
|
||||
// words. The list of predefined keys is not needed here; PredefinedProblems judges against it.
|
||||
func SettingProblems(m Manifest) []string {
|
||||
if len(m.Settings) == 0 {
|
||||
declared := PreferencesOf(m)
|
||||
if len(declared) == 0 {
|
||||
return nil
|
||||
}
|
||||
used := settingKeysUsedBy(m)
|
||||
var problems []string
|
||||
for _, key := range sortedSettingKeys(m.Settings) {
|
||||
d := m.Settings[key]
|
||||
problems := preferenceProblems(m)
|
||||
keys := make([]string, 0, len(declared))
|
||||
for k := range declared {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
d := declared[key]
|
||||
_, old := m.Settings[key]
|
||||
say := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -134,23 +142,27 @@ func SettingProblems(m Manifest) []string {
|
||||
say("the mesh reads %q itself, and a module gives it no default", key)
|
||||
continue
|
||||
}
|
||||
if d.Kind != KindPreference {
|
||||
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, KindPreference)
|
||||
if old && m.Settings[key].Kind != KindPreference {
|
||||
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", m.Settings[key].Kind, KindPreference)
|
||||
}
|
||||
if word := operatorsWord(key); word != "" {
|
||||
say("a key naming %q is the operator's value, and has no default — it is the "+
|
||||
"assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word)
|
||||
}
|
||||
switch v := d.Default.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
say("an empty default is no default; give the value, or declare nothing")
|
||||
if old {
|
||||
switch v := d.Default.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
say("an empty default is no default; give the value, or declare nothing")
|
||||
}
|
||||
case float64, bool:
|
||||
case nil:
|
||||
say("no default: a key without one is the operator's, and is not declared here")
|
||||
default:
|
||||
say("a default is a string, a number or a boolean, and this is %T", d.Default)
|
||||
}
|
||||
case float64, bool:
|
||||
case nil:
|
||||
say("no default: a key without one is the operator's, and is not declared here")
|
||||
default:
|
||||
say("a default is a string, a number or a boolean, and this is %T", d.Default)
|
||||
} else if v, isString := d.Default.(string); isString && strings.TrimSpace(v) == "" {
|
||||
say("an empty default is no default; give the value, or declare nothing")
|
||||
}
|
||||
if strings.TrimSpace(d.Why) == "" {
|
||||
say("no why: say in one sentence why this default")
|
||||
@@ -164,11 +176,12 @@ func SettingProblems(m Manifest) []string {
|
||||
|
||||
// Defaults is the layer a module's own defaults make, or nothing when it gives none.
|
||||
func Defaults(m Manifest) (Layer, bool) {
|
||||
if len(m.Settings) == 0 {
|
||||
declared := PreferencesOf(m)
|
||||
if len(declared) == 0 {
|
||||
return Layer{}, false
|
||||
}
|
||||
values := map[string]any{}
|
||||
for key, d := range m.Settings {
|
||||
for key, d := range declared {
|
||||
if d.Default != nil {
|
||||
values[key] = d.Default
|
||||
}
|
||||
@@ -189,7 +202,7 @@ func WithDefaults(m Manifest, layers []Layer) []Layer {
|
||||
type SettingSource struct {
|
||||
Key string
|
||||
Value any
|
||||
// From is DefaultLayer, MeshWideLayer or the node's name.
|
||||
// From is DefaultLayer, MeshConfigLayer, NodeConfigLayer(node), MeshWideLayer or the node's name.
|
||||
From string
|
||||
// FromDefault is whether the value is the module's default, whatever From reads.
|
||||
FromDefault bool
|
||||
@@ -199,10 +212,11 @@ type SettingSource struct {
|
||||
}
|
||||
|
||||
// Effective is every key a module gives a default or a layer sets, with its value and where it came
|
||||
// from: the default, then the mesh's layer, then the node's — later wins.
|
||||
// from: the default, then mesh config, then node config, then the module's own layer on the node —
|
||||
// later wins (novox/hq issue 630).
|
||||
func Effective(m Manifest, layers []Layer) []SettingSource {
|
||||
byKey := map[string]*SettingSource{}
|
||||
for key, d := range m.Settings {
|
||||
for key, d := range PreferencesOf(m) {
|
||||
byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true,
|
||||
Default: d.Default, HasDefault: true}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func wholeID(v string) (uint64, bool) {
|
||||
func OwnerSettingProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
asked := settingKeysAskedOutsideOwners(m)
|
||||
for k := range m.Settings {
|
||||
for k := range PreferencesOf(m) {
|
||||
asked[k] = true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
|
||||
@@ -43,7 +43,7 @@ func onlyTaken(layers []Layer, taken map[string]bool) []Layer {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
|
||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default, Config: layer.Config})
|
||||
}
|
||||
return kept
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TakenSettingsProblems(m Manifest) []string {
|
||||
// Said only when none of the module's mergeable files names its keys: when one does, §2 already
|
||||
// names every file that does not, and a file is not reported twice.
|
||||
func WrittenSettingsProblems(m Manifest) []string {
|
||||
if len(m.Settings) == 0 {
|
||||
if len(PreferencesOf(m)) == 0 {
|
||||
return nil
|
||||
}
|
||||
var silent []string
|
||||
|
||||
@@ -37,6 +37,9 @@ type Layer struct {
|
||||
// Default marks the layer a module's own defaults make (novox/hq ADR 0262). Marked rather than
|
||||
// recognised by From, which is a node's name for a node's layer, and a node may be called anything.
|
||||
Default bool
|
||||
// Config marks a layer of mesh config or node config (novox/hq issue 630): values for predefined keys,
|
||||
// given to the preferences that fall back to them. Like the defaults, it fills `${setting:<key>}` only.
|
||||
Config bool
|
||||
}
|
||||
|
||||
// ApplySettings produces a resource's final content from the module's own and the layers over it.
|
||||
@@ -136,7 +139,7 @@ func overridden(base map[string]any, layers []Layer, what string) (map[string]an
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
|
||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default, Config: layer.Config})
|
||||
}
|
||||
merged, err := settle(base, kept, nil, what)
|
||||
if err != nil {
|
||||
@@ -173,7 +176,7 @@ func settle(base map[string]any, layers []Layer, protected map[string]bool, what
|
||||
map[string]any, error) {
|
||||
merged := deepCopy(base)
|
||||
for _, layer := range layers {
|
||||
if layer.Default {
|
||||
if layer.Default || layer.Config {
|
||||
// A module's own defaults fill ${setting:<key>} only (novox/hq ADR 0262). A mergeable
|
||||
// file's content already is its defaults, and a contribution or served fact declares its
|
||||
// own; laid on here, a default would reach every mergeable file of its module (issue 168).
|
||||
@@ -363,7 +366,7 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
}
|
||||
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
||||
// overrides the default, which is how a running module gains a setting without a gap between.
|
||||
for key := range m.Settings {
|
||||
for key := range PreferencesOf(m) {
|
||||
lands[key] = true
|
||||
}
|
||||
for _, values := range m.Contributes {
|
||||
|
||||
Reference in New Issue
Block a user