package catalogue import ( "fmt" "strings" "unicode" ) // A mergeable file names the setting keys it takes (novox/hq ADR 0326, issue 168). // // Before this, every key of a module's layers merged into every one of its mergeable files: the // mesh's own words, a key meant for a `${setting:}` in another file, a key for a second // configuration file. So a module could not own two configurable files, and every receiver had to // ignore what it did not know. ADR 0164 §11 decided that a setting names the files that read it; this // is that relation, stated from the file's side, so the operator's own values stay declared nowhere // (ADR 0262 §2). // TakesSettingsField is the key a mergeable file carries to name the top-level setting keys it takes. const TakesSettingsField = "takes-settings" // **A taken value may be laid under the file's own name** (issue 751). An entry of takes-config is a key, laid // under that key, or {"key": , "as": }: the key stays a config key, // lower-case, and the file is written in its software's spelling, `atlassian-url` as ATLASSIAN_URL. Like every // taken value it is laid only when a layer sets it, never from a default, so an unset one leaves the file's bytes // and the code's own fallback (its environment) as they were; for that reason it is never `required` // (ConfigProblems). The settings form has no such entry: it is the config form's alone. // Fields of a renamed takes-config entry. const ( takenKeyField = "key" takenAsField = "as" ) // takenEntry is one value a mergeable file takes: its key and the name the file has for it. type takenEntry struct { key, as string renamed bool } // takenEntries is the entries of a takes-settings list in their order, a usable entry only: a string is a key // under its own name, an object with exactly key and as a key under another (TakenSettingsProblems says the rest). func takenEntries(raw any) []takenEntry { list, _ := raw.([]any) var out []takenEntry for _, item := range list { if e, ok := asTakenEntry(item); ok { out = append(out, e) } } return out } // asTakenEntry reads one item of a takes-settings list, and whether it is shaped as an entry at all. func asTakenEntry(item any) (takenEntry, bool) { switch v := item.(type) { case string: return takenEntry{key: v, as: v}, true case map[string]any: key, keyOK := v[takenKeyField].(string) as, asOK := v[takenAsField].(string) if !keyOK || !asOK || len(v) != 2 { return takenEntry{}, false } return takenEntry{key: key, as: as, renamed: true}, true } return takenEntry{}, false } // takenKeyList is the keys a resource takes, in order: the config keys, never the file's names for them. func takenKeyList(resource map[string]any) []string { var keys []string for _, e := range takenEntries(resource[TakesSettingsField]) { keys = append(keys, e.key) } return keys } // takenKeys is the keys a resource names in takes-settings, each with the name the file has for it, and whether // it names any list at all. An empty list is a list: that file takes no setting. func takenKeys(resource map[string]any) (map[string]string, bool) { raw, said := resource[TakesSettingsField] if !said { return nil, false } keys := map[string]string{} for _, e := range takenEntries(raw) { keys[e.key] = e.as } return keys, true } // onlyTaken is the layers with every key the file does not name left out, and each key it takes under the // file's name for it. The file's own defaults and what the mesh reads itself are not touched here: settle // already keeps both out of a file. // // **A layer said before its module moved still reaches the file** (issue 751): such a layer names a renamed value // by the file's own spelling (nodered's timeZone on one machine), and until K5 moves the layer and renames the key // that spelling is taken as the value's, so moving the module changes no machine's file. Where one layer says // both, the config key wins. func onlyTaken(layers []Layer, taken map[string]string) []Layer { oldSpelling := map[string]string{} for key, as := range taken { if as != key { oldSpelling[as] = key } } kept := make([]Layer, 0, len(layers)) for _, layer := range layers { values := map[string]any{} for key, value := range layer.Values { if as, takes := taken[key]; takes { values[as] = value continue } if newKey, old := oldSpelling[key]; old { if _, said := layer.Values[newKey]; !said { values[key] = value } } } kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default}) } return kept } // takenNames is every key a layer may say for the values a resource takes: each config key and, for a renamed // one, the file's own spelling a layer said before its module moved (onlyTaken). func takenNames(resource map[string]any) []string { var names []string for _, e := range takenEntries(resource[TakesSettingsField]) { names = append(names, e.key) if e.renamed { names = append(names, e.as) } } return names } // namesItsKeys says whether a module's mergeable files name the keys they take (ADR 0326 §2): true // only when it has one and every one carries takes-settings. A module with a silent mergeable file // keeps today's form (§3), where every key reaches every mergeable file. func namesItsKeys(m Manifest) bool { merging := 0 for _, r := range m.Resources { if how, _ := r["merge"].(string); how == "" { continue } merging++ if _, said := r[TakesSettingsField]; !said { return false } } return merging > 0 } // TakenSettingsProblems is every way a definition's takes-settings is wrong (ADR 0326 §2 and §4), in // its own words: on a file that does not merge, not a list of keys, a key named twice, one the mesh // reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another // that names none. func TakenSettingsProblems(m Manifest) []string { return takenSettingsProblems(m) } // WrittenSettingsProblems is ADR 0326 §3, judged only where a definition is written — the module // check, `module add`, a build taken in, the builder and the merge gate's registration — and never // where a stored one is read again (ParseManifest, a build's manifest at a commit, a module put back): // a module that declares a setting names the keys of every mergeable file it owns (novox/hq issue // 574, ADR 0164 §11 and §15). A build stored before the rule, such as betterbird's before its accounts // file named its keys, must still compose and still be put back; only its next change is held to it. // // 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 || m.DeclaresConfig() { // A module in the config form is held to this where it is read, in its own words (ConfigProblems). return nil } var silent []string for _, r := range m.Resources { how, _ := r["merge"].(string) if how == "" { continue } if _, said := r[TakesSettingsField]; said { return nil } silent = append(silent, fmt.Sprint(r["id"])) } var problems []string for _, id := range silent { problems = append(problems, fmt.Sprintf("%s declares settings, and its mergeable %s names no %s: "+ "a module that declares a setting names the keys each mergeable file takes, an empty list "+ "for none (novox/hq ADR 0326 §3, ADR 0164 §11)", m.Module, id, TakesSettingsField)) } return problems } // takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and // where a stored one is composed. func takenSettingsProblems(m Manifest) []string { var problems []string var naming, silent []string for _, r := range m.Resources { id := fmt.Sprint(r["id"]) how, _ := r["merge"].(string) raw, said := r[TakesSettingsField] if how == "" { if said { problems = append(problems, fmt.Sprintf("%s's %s says %s and does not merge: only a mergeable "+ "file takes settings by key (novox/hq ADR 0326)", m.Module, id, TakesSettingsField)) } continue } if !said { silent = append(silent, id) continue } naming = append(naming, id) list, isList := raw.([]any) if !isList { problems = append(problems, fmt.Sprintf("%s's %s: %s is a list of setting keys, and this is %T", m.Module, id, TakesSettingsField, raw)) continue } protected := map[string]bool{} for _, k := range stringsOf(r["protected"]) { protected[k] = true } seen, landed := map[string]bool{}, map[string]bool{} for _, item := range list { if _, isObject := item.(map[string]any); isObject { e, shaped := asTakenEntry(item) switch { case !m.DeclaresConfig(): problems = append(problems, fmt.Sprintf("%s's %s: %v in %s lays a value under another name, "+ "which only the config form says: write the key alone (novox/hq issue 751)", m.Module, id, item, TakesSettingsField)) continue case !shaped: problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is a key alone, or an object "+ "saying exactly %q and %q: the config key and the file's name for it (novox/hq issue 751)", m.Module, id, item, TakesSettingsField, takenKeyField, takenAsField)) continue case !settingRef.MatchString("${setting:" + e.key + "}"): problems = append(problems, fmt.Sprintf("%s's %s: %q in %s is not a usable key: a config "+ "key is lower-case letters, digits, dots, dashes and underscores; the file's own spelling "+ "is its %q", m.Module, id, e.key, TakesSettingsField, takenAsField)) continue case !usableKey(e.as): problems = append(problems, fmt.Sprintf("%s's %s: %q, the name %s takes %q as, is not a "+ "usable name in a file", m.Module, id, e.as, TakesSettingsField, e.key)) continue } problems = append(problems, takenEntryProblems(m.Module, id, e, seen, landed, protected)...) continue } key, isString := item.(string) if !isString || !usableKey(key) { problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is not a usable setting key", m.Module, id, item, TakesSettingsField)) continue } problems = append(problems, takenEntryProblems(m.Module, id, takenEntry{key: key, as: key}, seen, landed, protected)...) } } if len(naming) > 0 { for _, id := range silent { problems = append(problems, fmt.Sprintf("%s's %s merges and names no %s while %s does: once one "+ "mergeable file of a module names the keys it takes, every one does, an empty list for none "+ "(novox/hq ADR 0326)", m.Module, id, TakesSettingsField, strings.Join(naming, ", "))) } } return problems } // takenEntryProblems is what is wrong with one usable entry of a file's takes-settings, against those before it: // a key taken twice, or two values laid under one name in the file; a word the mesh reads itself, as the key or as // the file's name; a name the file keeps as protected. seen and landed are the keys and the names so far, added to. func takenEntryProblems(module, id string, e takenEntry, seen, landed, protected map[string]bool) []string { var problems []string switch { case seen[e.key]: problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s", module, id, e.key, TakesSettingsField)) case landed[e.as]: problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s, as a key or as the name a key is "+ "laid under: one name in a file holds one value", module, id, e.as, TakesSettingsField)) case meshWords[e.key] || meshWords[e.as]: word := e.key if !meshWords[word] { word = e.as } problems = append(problems, fmt.Sprintf("%s's %s names %q in %s, and the mesh reads %q itself "+ "(novox/hq ADR 0164 §14)", module, id, word, TakesSettingsField, word)) case protected[e.as]: problems = append(problems, fmt.Sprintf("%s's %s names %q in %s and keeps it as protected: a "+ "key is the file's or the setting's, never both", module, id, e.as, TakesSettingsField)) } seen[e.key], landed[e.as] = true, true return problems } // usableKey is a key a file can be said to take: not empty, and no space, line end or control // character. Not the lower-case rule of a `${setting:}`: a file's own keys are its software's // spelling, such as `timeZone`. func usableKey(key string) bool { if key == "" { return false } for _, r := range key { if unicode.IsSpace(r) || unicode.IsControl(r) || !unicode.IsPrint(r) { return false } } return true } // takenSaid is what a stray key's message adds for a module whose files name their keys: which keys // each file takes, so the person sees where the key would have had to be named. func takenSaid(m Manifest) string { if !namesItsKeys(m) { return "" } var files []string for _, r := range m.Resources { if how, _ := r["merge"].(string); how == "" { continue } keys := takenKeyList(r) if len(keys) == 0 { files = append(files, fmt.Sprintf("%v takes none", r["id"])) continue } files = append(files, fmt.Sprintf("%v takes %s", r["id"], strings.Join(keys, ", "))) } return " (" + strings.Join(files, "; ") + ")" }