Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/config
go 1.22
+201
View File
@@ -0,0 +1,201 @@
// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348
// clauses 1 and 11, design 57 package K6, issue 665).
//
// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it
// takes under `takes-config`, and a file asks for a value with `${config:<key>}`. A mergeable file naming no keys at
// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config
// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`,
// `takes-settings` and `${setting:<key>}` — stay accepted by the controller as aliases until every module has
// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this
// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its
// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a
// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks;
// while the trunk holds no copy, the modules in seed are the trunk's.
// When it is empty, the controller refuses the old names itself (design 57 package R1).
//
// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and
// rewording it would change what a node is sent.
//
// go run . [-base <the trunk's unmoved>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a
// string value is never followed by one.
var (
settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`)
takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`)
oldPlaceholder = "${setting:…}"
oldTakes = "takes-settings"
oldDeclaration = "settings"
wholeLayer = "taking its whole layer"
listFile = "unmoved"
listPathInCatalog = "checks/config/" + listFile
)
// Check answers, per module that says any, the settings form's names its manifest and its template files say.
func Check(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
out := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Settings json.RawMessage `json:"settings"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
var said []string
if len(m.Settings) > 0 && string(m.Settings) != "null" {
said = append(said, oldDeclaration)
}
if takesSettingsKey.Match(raw) {
said = append(said, oldTakes)
}
if settingRef.Match(raw) {
said = append(said, oldPlaceholder)
}
for _, r := range m.Resources {
// A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them.
if how, _ := r["merge"].(string); how != "" {
_, oldList := r["takes-settings"]
_, newList := r["takes-config"]
if !oldList && !newList {
said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer))
}
}
name, _ := r["template"].(string)
if name == "" {
continue
}
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
if err != nil {
return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err)
}
if settingRef.Match(text) {
said = append(said, oldPlaceholder+" in "+name)
}
}
if len(said) > 0 {
sort.Strings(said)
out[m.Module] = said
}
}
return out, nil
}
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
// The modules taking a whole layer were found after the list was first written and before the trunk held a copy
// (issue 735), so they stand in it too.
var seed = map[string]bool{
"betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
"baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true,
"icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true,
}
// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules.
func Unmoved(path string) (map[string]bool, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
out := map[string]bool{}
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[line] = true
}
return out, s.Err()
}
// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that
// say none (stale), and the modules on the list that the trunk's list does not hold (added).
func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) {
for m := range found {
if !unmoved[m] {
refused = append(refused, m)
}
}
for m := range unmoved {
if _, says := found[m]; !says {
stale = append(stale, m)
}
if !base[m] {
added = append(added, m)
}
}
sort.Strings(refused)
sort.Strings(stale)
sort.Strings(added)
return refused, stale, added
}
func main() {
basePath := flag.String("base", "", "the trunk's copy of unmoved (the merge base's); seed when absent")
flag.Parse()
root := "."
if flag.NArg() > 0 {
root = flag.Arg(0)
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
unmoved, err := Unmoved(listFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
base := seed
if *basePath != "" {
if base, err = Unmoved(*basePath); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
refused, stale, added := Judge(found, unmoved, base)
for _, m := range refused {
fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+
"${config:<key>} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog)
}
for _, m := range stale {
fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m)
}
for _, m := range added {
fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+
"config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m)
}
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
os.Exit(1)
}
fmt.Printf("config: no moved module says settings, takes-settings or ${setting:<key>}, or takes a whole layer "+
"(%d not yet moved)\n",
len(unmoved))
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func module(t *testing.T, root, name, body string, files map[string]string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
for path, text := range files {
full := filepath.Join(dir, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
}
// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a
// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1).
func TestEveryOldNameIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+
`"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil)
module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+
`"takes-settings":["a"],"content":"{}"}]}`, nil)
module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil)
module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+
`"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`,
map[string]string{"files/conf": "size = ${setting:size}\n"})
module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+
`"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+
`"takes-config":[],"content":"{\"settings\": 1}"}]}`,
map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"})
module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{
"asks": {"${setting:…}"},
"declares": {"${setting:…}", "settings"},
"takes": {"takes-settings"},
"templated": {"${setting:…} in files/conf"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's
// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11).
func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) {
found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}}
unmoved := map[string]bool{"old": true, "done": true, "new": true}
base := map[string]bool{"old": true, "done": true, "forgotten": true}
refused, stale, added := Judge(found, unmoved, base)
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
!reflect.DeepEqual(added, []string{"new"}) {
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
}
}
func TestTheListIsReadWithoutItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "unmoved")
if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Unmoved(path)
if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) {
t.Fatalf("%v %v", got, err)
}
}
// The catalogue itself: every moved module says config alone, and the list names exactly the modules that
// still say settings.
func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) {
found, err := Check(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
unmoved, err := Unmoved("unmoved")
if err != nil {
t.Fatal(err)
}
refused, stale, added := Judge(found, unmoved, seed)
for _, m := range refused {
t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", "))
}
for _, m := range stale {
t.Errorf("checks/config/unmoved names %s, which says no old name", m)
}
for _, m := range added {
t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m)
}
}
// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings
// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It
// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list
// included, is not.
func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+
`"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"})
module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+
`"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil)
module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+
`"takes-config":[],"content":"{}"}]}`, nil)
module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{"whole": {"runtime-config taking its whole layer"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
+5
View File
@@ -0,0 +1,5 @@
# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:<key>}`, or a mergeable
# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to
# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no
# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller
# refuses the old names (design 57 package R1).
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/mailstore
go 1.22
+81
View File
@@ -0,0 +1,81 @@
// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378
// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second
// claimant would be a second path to the same mail that ADR 0378 never accounted for.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// MailStoreSeat is the seat only mesh-mail may claim.
const MailStoreSeat = "mail-store"
// MailStoreModule is the one module allowed to claim it.
const MailStoreModule = "mesh-mail"
// Finding is one manifest refused: a module other than mesh-mail claiming mail-store.
type Finding struct {
Module string
}
// Check reads every manifest under root/modules and answers which ones it refuses.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if m.Module == MailStoreModule {
continue
}
for _, c := range m.Claims {
if c.Name == MailStoreSeat {
out = append(out, Finding{Module: m.Module})
break
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("mail-store: claimed only by mesh-mail")
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func manifest(t *testing.T, root, module, claims string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"module":"` + module + `","claims":[` + claims + `]}`
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`)
manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`)
manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 || found[0].Module != "betterbird" {
t.Fatalf("refused %+v", found)
}
}
func TestTheCatalogueHasNone(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(found) != 0 {
t.Fatalf("%+v", found)
}
}
+11 -1
View File
@@ -7,7 +7,8 @@
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it.
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. A file whose text is a template
// in files/ is judged by the template's text.
//
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
@@ -64,6 +65,15 @@ func Check(root string) ([]Finding, error) {
}
id, _ := r["id"].(string)
content, _ := r["content"].(string)
// A file whose text is a template in its module's files/ (novox/hq ADR 0349 §2) is judged by that text,
// exactly as it would be inline.
if name, _ := r["template"].(string); name != "" {
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
if err != nil {
return nil, fmt.Errorf("%s: %s names the template %s: %w", m.Module, id, name, err)
}
content = string(text)
}
var doc map[string]any
if strings.TrimSpace(content) == "" {
continue
+29
View File
@@ -51,6 +51,35 @@ func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
}
}
// A mergeable file whose text is a template in files/ (novox/hq ADR 0349 §2) is judged by the template's text,
// exactly as the same text inline; a template the module's directory lacks is an error, never a silent pass.
func TestAMergeableFilesTemplateIsJudgedAsItsText(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","template":"files/cfg.json"}`)
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],"template":"files/cfg.json"}`)
for _, m := range []string{"a", "c"} {
dir := filepath.Join(root, "modules", m, "files")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "cfg.json"), []byte(`{"password": "${secret:admin}"}`), 0o644); err != nil {
t.Fatal(err)
}
}
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 || found[0].String() != "a cfg password" {
t.Fatalf("found %v, want [a cfg password]", found)
}
manifest(t, root, "g", `{"id":"cfg","type":"file","merge":"json","template":"files/gone.json"}`)
if _, err := Check(root); err == nil {
t.Fatal("a template the module's directory lacks passed the check")
}
}
func TestTheKnownListOnlyShrinks(t *testing.T) {
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}}
refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/templates
go 1.22
+9
View File
@@ -0,0 +1,9 @@
# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of
# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the
# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their
# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy
# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5,
# issue 658).
#
# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template;
# it moves with the collections (design 57 package L2, issue 681) and is not judged here.
+178
View File
@@ -0,0 +1,178 @@
// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq
// ADR 0349 §2, design 57 package T5, issue 658).
//
// A file resource names its text as a template in its module's templates directory (`"template": "<dir>/<name>"`;
// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an
// archive; the controller reads each template back into the file's content, so the
// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is
// listed in the file `inline` beside this check, one per line. Every other module's file resources say no
// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge
// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the
// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text.
//
// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the
// collections (design 57 package L2, issue 681).
//
// go run . [-base <the trunk's inline>] <catalogue root>
//
// Every refusal names the refused module's own templates directory. Exits 1 on a finding.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
const (
listFile = "inline"
listPathInCatalog = "checks/templates/" + listFile
)
// Check answers, per module that keeps any, the ids of its file resources that say their text inline.
func Check(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
out := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline && r["type"] == "file" {
out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"]))
}
}
}
return out, nil
}
// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's
// `templates`, files/ when the manifest says none or cannot be read here.
func TemplatesDir(root, module string) string {
dir := "files/"
raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json"))
if err != nil {
return dir
}
var m struct {
Templates string `json:"templates"`
}
if json.Unmarshal(raw, &m) == nil && m.Templates != "" {
dir = strings.TrimSuffix(m.Templates, "/") + "/"
}
return dir
}
// Refusal is the finding for a module that keeps file text inline and is not on the list.
func Refusal(module string, ids []string, dir string) string {
return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+
"the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)",
module, strings.Join(ids, ", "), listPathInCatalog, dir, dir)
}
// Gained is the finding for a module the list gains against the trunk's copy.
func Gained(module, dir string) string {
return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+
"to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir)
}
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true}
// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules.
func Listed(path string) (map[string]bool, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
out := map[string]bool{}
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[line] = true
}
return out, s.Err()
}
// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list
// that keep none (stale), and the modules on the list that the trunk's list does not hold (added).
func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) {
for m := range found {
if !listed[m] {
refused = append(refused, m)
}
}
for m := range listed {
if _, keeps := found[m]; !keeps {
stale = append(stale, m)
}
if !base[m] {
added = append(added, m)
}
}
sort.Strings(refused)
sort.Strings(stale)
sort.Strings(added)
return refused, stale, added
}
func main() {
basePath := flag.String("base", "", "the trunk's copy of inline (the merge base's); seed when absent")
flag.Parse()
root := "."
if flag.NArg() > 0 {
root = flag.Arg(0)
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
listed, err := Listed(listFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
base := seed
if *basePath != "" {
if base, err = Listed(*basePath); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
refused, stale, added := Judge(found, listed, base)
for _, m := range refused {
fmt.Println(Refusal(m, found[m], TemplatesDir(root, m)))
}
for _, m := range stale {
fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m)
}
for _, m := range added {
fmt.Println(Gained(m, TemplatesDir(root, m)))
}
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
os.Exit(1)
}
fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed))
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func module(t *testing.T, root, name, body string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// A file resource that says its text inline is found, by module and resource; one naming its template, a
// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2).
func TestEveryInlineFileTextIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+
`{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`)
module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+
`{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{"inline": {"a", "c"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the
// trunk's list does not hold is refused, so the list only shrinks until it is empty.
func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) {
found := map[string][]string{"old": {"a"}, "forgotten": {"b"}}
inline := map[string]bool{"old": true, "done": true, "new": true}
base := map[string]bool{"old": true, "done": true, "forgotten": true}
refused, stale, added := Judge(found, inline, base)
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
!reflect.DeepEqual(added, []string{"new"}) {
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
}
}
func TestTheListIsReadWithoutItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "inline")
if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Listed(path)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) {
t.Fatalf("read %v", got)
}
}
// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none
// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved).
func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
inline, err := Listed(listFile)
if err != nil {
t.Fatal(err)
}
refused, stale, _ := Judge(found, inline, inline)
if len(refused) > 0 || len(stale) > 0 {
t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale)
}
}
// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809).
func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) {
root := t.TempDir()
module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`)
module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`)
module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`)
for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} {
if got := TemplatesDir(root, module); got != dir {
t.Fatalf("%s: directory %q, want %q", module, got, dir)
}
}
msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own"))
if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
t.Fatalf("refusal %q", msg)
}
if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
t.Fatalf("gained %q", msg)
}
}