diff --git a/checks/words/go.mod b/checks/words/go.mod new file mode 100644 index 0000000..2489c12 --- /dev/null +++ b/checks/words/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/words + +go 1.22 diff --git a/checks/words/main.go b/checks/words/main.go new file mode 100644 index 0000000..3cc58c9 --- /dev/null +++ b/checks/words/main.go @@ -0,0 +1,327 @@ +// Command words holds what the catalogue's modules say to an agent to the glossary's words (novox/hq ADR +// 0244): no word the glossary retired for the tools' descriptions — the copy in retired-words at the +// catalogue's root — in any text a module's tools can show. That text is every string literal in a module's +// own code that is not a test (a tool's description, the notes and errors it answers with) and every +// description in its manifest. Comments are not read: an agent never sees them. +// +// A word is matched whole and in any case, a space in it matching any run of white space. A vendor word is +// never on the list (the glossary does not retire one for the tools), so a wrapped program's own objects — +// an identity provider's users, a media manager's releases — are never findings. +// +// go run . +// +// Exits 1 on a finding, 2 when the list cannot be read. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "go/scanner" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// text is one piece of text an agent can be shown, and where it starts. +type text struct { + file string + line int + s string +} + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + words, err := readList(filepath.Join(root, "retired-words")) + if err != nil { + fmt.Fprintln(os.Stderr, "words:", err) + os.Exit(2) + } + if len(words) == 0 { + fmt.Fprintln(os.Stderr, "words: retired-words lists no word — the check would pass on anything") + os.Exit(2) + } + var texts []text + files := 0 + err = filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case "node_modules", "dist", "vendor", "test", "tests", "testdata", ".git": + return filepath.SkipDir + } + return nil + } + rel, _ := filepath.Rel(root, path) + name := d.Name() + var found []text + switch { + case strings.HasSuffix(name, "_test.go"), strings.Contains(name, ".test."), strings.Contains(name, ".spec."): + return nil + case strings.HasSuffix(name, ".go"): + found, err = goStrings(path, rel) + case strings.HasSuffix(name, ".ts"), strings.HasSuffix(name, ".js"), strings.HasSuffix(name, ".mjs"): + found, err = scriptStrings(path, rel) + case name == "module.json": + found, err = jsonStrings(path, rel) + default: + return nil + } + if err != nil { + return fmt.Errorf("%s: %w", rel, err) + } + files++ + texts = append(texts, found...) + return nil + }) + if err != nil { + fmt.Fprintln(os.Stderr, "words:", err) + os.Exit(2) + } + findings := check(words, texts) + for _, f := range findings { + fmt.Println(f) + } + if len(findings) > 0 { + fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings)) + os.Exit(1) + } + fmt.Printf("words: %d retired words, none in the %d strings of %d files\n", len(words), len(texts), files) +} + +// readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words. +func readList(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var words []string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + words = append(words, line) + } + return words, sc.Err() +} + +func pattern(word string) *regexp.Regexp { + parts := strings.Fields(word) + for i, p := range parts { + parts[i] = regexp.QuoteMeta(p) + } + return regexp.MustCompile(`(?i)(?:^|[^\w-])(` + strings.Join(parts, `\s+`) + `)(?:$|[^\w-])`) +} + +func check(words []string, texts []text) []string { + var out []string + for _, w := range words { + rx := pattern(w) + for _, t := range texts { + if m := rx.FindStringSubmatchIndex(t.s); m != nil { + line := t.line + if line > 0 { + line += strings.Count(t.s[:m[2]], "\n") + } + out = append(out, fmt.Sprintf("%s:%d: %q is retired — in %q", t.file, line, w, excerpt(t.s, m[2], m[3]))) + } + } + } + sort.Strings(out) + return out +} + +func excerpt(s string, from, to int) string { + a, b := from-50, to+50 + if a < 0 { + a = 0 + } + if b > len(s) { + b = len(s) + } + return strings.Join(strings.Fields(s[a:b]), " ") +} + +// goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them. +func goStrings(path, rel string) ([]text, error) { + src, err := os.ReadFile(path) + if err != nil { + return nil, err + } + fset := token.NewFileSet() + file := fset.AddFile(rel, -1, len(src)) + var s scanner.Scanner + var bad error + s.Init(file, src, func(pos token.Position, msg string) { bad = fmt.Errorf("%s: %s", pos, msg) }, 0) + var out []text + var cur *text + joining := false + for { + pos, tok, lit := s.Scan() + if tok == token.EOF { + break + } + switch { + case tok == token.STRING: + v, err := strconv.Unquote(lit) + if err != nil { + v = lit + } + if cur != nil && joining { + cur.s += v + } else { + out = append(out, text{file: rel, line: fset.Position(pos).Line, s: v}) + cur = &out[len(out)-1] + } + joining = false + case tok == token.ADD && cur != nil: + joining = true + default: + cur, joining = nil, false + } + } + return out, bad +} + +// scriptStrings returns a TypeScript or JavaScript file's string literals, comments skipped, adjacent ones +// joined across `+`. A template literal's `${…}` parts are left out of the text around them. +func scriptStrings(path, rel string) ([]text, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + src := string(b) + var out []text + line := 1 + lastWasString, joining := false, false + for i := 0; i < len(src); i++ { + c := src[i] + switch { + case c == '\n': + line++ + case c == '/' && i+1 < len(src) && src[i+1] == '/': + for i < len(src) && src[i] != '\n' { + i++ + } + line++ + case c == '/' && i+1 < len(src) && src[i+1] == '*': + end := strings.Index(src[i+2:], "*/") + if end < 0 { + end = len(src) - i - 2 + } + line += strings.Count(src[i:i+2+end], "\n") + i += end + 3 + case c == '"' || c == '\'' || c == '`': + start := line + var sb strings.Builder + depth := 0 + j := i + 1 + for ; j < len(src); j++ { + d := src[j] + if d == '\n' { + line++ + } + if depth > 0 { + if d == '\n' { + sb.WriteByte('\n') + } + if d == '{' { + depth++ + } else if d == '}' { + depth-- + } + continue + } + if d == '\\' && j+1 < len(src) { + j++ + sb.WriteByte(src[j]) + continue + } + if c == '`' && d == '$' && j+1 < len(src) && src[j+1] == '{' { + depth = 1 + j++ + sb.WriteByte(' ') + continue + } + if d == c || (c != '`' && d == '\n') { + break + } + sb.WriteByte(d) + } + if lastWasString && joining && len(out) > 0 { + out[len(out)-1].s += sb.String() + } else { + out = append(out, text{file: rel, line: start, s: sb.String()}) + } + lastWasString, joining = true, false + i = j + continue + case c == '+' && lastWasString: + joining = true + continue + case c == ' ' || c == '\t' || c == '\r': + continue + default: + lastWasString, joining = false, false + } + if c == '\n' { + continue + } + } + return out, nil +} + +// jsonStrings returns the descriptions in a manifest — every string under a key named "description", at any +// depth. The rest of a manifest is names, paths and the contents of files it places, none of which a tool +// shows an agent. +func jsonStrings(path, rel string) ([]text, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var v any + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + var out []text + var walk func(any, bool) + walk = func(v any, described bool) { + switch x := v.(type) { + case string: + if described { + out = append(out, text{file: rel, line: lineOf(string(b), x), s: x}) + } + case []any: + for _, e := range x { + walk(e, described) + } + case map[string]any: + for k, e := range x { + walk(e, k == "description") + } + } + } + walk(v, false) + return out, nil +} + +func lineOf(src, s string) int { + q, _ := json.Marshal(s) + if i := strings.Index(src, string(q)); i >= 0 { + return strings.Count(src[:i], "\n") + 1 + } + return 0 +} diff --git a/merge-check.sh b/merge-check.sh index f9c7500..1a1b713 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -14,9 +14,15 @@ # long-running resources without `health` held to the number in health-undeclared, which only goes down; # 2. the Go tests of every module the change touches that has them, under the race detector when the # toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is -# written in TypeScript, is said as not tested, never passed silently. +# written in TypeScript, is said as not tested, never passed silently; +# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in +# retired-words, in any text a module's tools can show an agent — every string in its own code that is +# not a test, and every description in its manifest. Run over every module, not only the touched ones, +# so a word newly retired is found everywhere it stands. set -eu +(cd checks/words && go run . ../..) + if [ -n "${MESH_GATE:-}" ]; then checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; } # **The count only goes down** (novox/hq ADR 0240 rule 8): the long-running resources that do not say how diff --git a/modules/docker/cmd/docker-tools/docker.go b/modules/docker/cmd/docker-tools/docker.go index 2fd3db0..9872778 100644 --- a/modules/docker/cmd/docker-tools/docker.go +++ b/modules/docker/cmd/docker-tools/docker.go @@ -527,7 +527,7 @@ func (c *Client) Act(ctx context.Context, verb, ref string) (map[string]any, err answer := map[string]any{"container": s.Name, "verb": verb, "ok": true, "state": s.State, "mesh_held": s.MeshHeld} if s.MeshHeld { answer["held_by"] = s.HeldBy - answer["note"] = fmt.Sprintf("the mesh holds this container (%s): the host restores what its declaration says at its next apply", s.HeldBy) + answer["note"] = fmt.Sprintf("the mesh holds this container (%s): the node-engine restores what its declaration says at its next apply", s.HeldBy) } return answer, nil } diff --git a/modules/docker/cmd/docker-tools/docker_test.go b/modules/docker/cmd/docker-tools/docker_test.go index 4adfced..22774e8 100644 --- a/modules/docker/cmd/docker-tools/docker_test.go +++ b/modules/docker/cmd/docker-tools/docker_test.go @@ -188,7 +188,7 @@ func TestActingOnAMeshContainerSaysTheHostRestoresIt(t *testing.T) { if err != nil { t.Fatal(err) } - if !f.ran("docker stop --time 10 mesh-web") || got["mesh_held"] != true || !strings.Contains(got["note"].(string), "host restores") { + if !f.ran("docker stop --time 10 mesh-web") || got["mesh_held"] != true || !strings.Contains(got["note"].(string), "node-engine restores") { t.Fatalf("%v %+v", got, f.calls) } got, _ = client(f, 1000).Act(context.Background(), "start", "dev-db") diff --git a/modules/docker/cmd/docker-tools/main.go b/modules/docker/cmd/docker-tools/main.go index a38c687..a622820 100644 --- a/modules/docker/cmd/docker-tools/main.go +++ b/modules/docker/cmd/docker-tools/main.go @@ -140,8 +140,8 @@ func tools(c *Client) []stdio.Tool { return map[string]any{"count": len(stats), "containers": stats}, nil }, }, - act("start", "Start one container. A container the mesh holds is started too, and the answer says the host restores what its declaration says at its next apply."), - act("stop", "Stop one container (ten seconds, then killed). For a container the mesh holds, the answer says the host will start it again at its next apply if its declaration says running."), + act("start", "Start one container. A container the mesh holds is started too, and the answer says the node-engine restores what its declaration says at its next apply."), + act("stop", "Stop one container (ten seconds, then killed). For a container the mesh holds, the answer says the node-engine will start it again at its next apply if its declaration says running."), act("restart", "Restart one container (ten seconds to stop, then killed); the answer says whether the mesh holds it."), { Name: "docker_top", diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index 09f02ae..c5b001d 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -68,9 +68,9 @@ function builds(plan?: ChangePlan): boolean { return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0); } -/** A change plan as a person reads it on the pull request. */ +/** A delivery plan (the controller's ChangePlan) as a person reads it on the pull request. */ export function planText(plan: ChangePlan): string { - const lines = [`**Change plan** — ${plan.summary}`]; + const lines = [`**Delivery plan** — ${plan.summary}`]; (plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`)); for (const m of plan.machines ?? []) { const parts: string[] = []; diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index 88399da..91736eb 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -137,7 +137,7 @@ test("a change plan is the gate's result: said on the status and, when it builds summary: "every machine composes", gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea"] }, plan }; assert.equal(statusFor(c).description, "pass: builds gitea → anchor; no bus step; every machine composes"); const said = commentFor(c) ?? ""; - assert.match(said, /Change plan\*\* — builds gitea → anchor/); + assert.match(said, /Delivery plan\*\* — builds gitea → anchor/); assert.match(said, /- anchor: receives gitea/); // A plan that builds nothing, passing: the statuses say it, no comment. const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } }; diff --git a/modules/lab/tools/runs.ts b/modules/lab/tools/runs.ts index 3fe9bbe..4cd7cd4 100644 --- a/modules/lab/tools/runs.ts +++ b/modules/lab/tools/runs.ts @@ -100,7 +100,7 @@ node -e ${shellQuote( `const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`, )} ${setState("building")} -# The @novox scope resolves from the mesh's own package registry on the forge, as the build machine +# The @novox scope resolves from the mesh's own package registry on the forge, as the builder # resolves it; nothing else is asked of it. printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))} export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))} diff --git a/modules/nftables/client.ts b/modules/nftables/client.ts index 6f3593f..9b93139 100644 --- a/modules/nftables/client.ts +++ b/modules/nftables/client.ts @@ -186,7 +186,7 @@ export class FirewallClient { } return { where, did }; } - throw new Error(`${JSON.stringify(where)} is not a rule set as the host reports one: ` + + throw new Error(`${JSON.stringify(where)} is not a rule set as the node-engine reports one: ` + "`chain X (iptables-legacy)` or `table , chain X`"); } diff --git a/modules/nftables/test/remove.test.ts b/modules/nftables/test/remove.test.ts index 054ac2f..8c705c9 100644 --- a/modules/nftables/test/remove.test.ts +++ b/modules/nftables/test/remove.test.ts @@ -63,7 +63,7 @@ test("what is not the operator's to remove is refused by name", async () => { await assert.rejects(c.remove("chain DOCKER (iptables-legacy)"), /container runtime's own/); await assert.rejects(c.remove("chain FORWARD (iptables-legacy)"), /built in/); await assert.rejects(c.remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"), /found firewall, which is in force/); - await assert.rejects(c.remove("something else"), /not a rule set as the host reports one/); + await assert.rejects(c.remove("something else"), /not a rule set as the node-engine reports one/); // Retired, a front end's leftover is nobody's and goes. const retired = await new FirewallClient(fake(false).run, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"); assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny")); diff --git a/modules/slack/cmd/slack-tools/slack.go b/modules/slack/cmd/slack-tools/slack.go index 13ec92d..70ad967 100644 --- a/modules/slack/cmd/slack-tools/slack.go +++ b/modules/slack/cmd/slack-tools/slack.go @@ -594,7 +594,7 @@ func (m *Machine) Check() (CheckAnswer, error) { return a, err } if v == "" { - add("Slack ("+packageFor+") is not installed", "install it from the AUR: the module does not install it, because the host installs packages from the official repositories only") + add("Slack ("+packageFor+") is not installed", "install it from the AUR: the module does not install it, because the node-engine installs packages from the official repositories only") } mine := 0 for _, st := range m.starts() { diff --git a/modules/systemd/cmd/systemd-tools/client.go b/modules/systemd/cmd/systemd-tools/client.go index 2145ee5..5fad4a7 100644 --- a/modules/systemd/cmd/systemd-tools/client.go +++ b/modules/systemd/cmd/systemd-tools/client.go @@ -283,7 +283,7 @@ func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) { answer := map[string]any{"unit": unit, "scope": string(scope), "verb": verb, "ok": true, "active": after["ActiveState"], "boot": after["UnitFileState"], "mesh_declared": after["mesh_declared"]} if after["mesh_declared"] == true { - answer["note"] = "the mesh declares this unit: the host restores its declared state at its next apply" + answer["note"] = "the mesh declares this unit: the node-engine restores its declared state at its next apply" } return answer, nil } diff --git a/modules/systemd/cmd/systemd-tools/client_test.go b/modules/systemd/cmd/systemd-tools/client_test.go index 6e5007f..34f9cb4 100644 --- a/modules/systemd/cmd/systemd-tools/client_test.go +++ b/modules/systemd/cmd/systemd-tools/client_test.go @@ -155,7 +155,7 @@ func TestTheRestoreNoteIsOnlyOnAUnitTheMeshDeclares(t *testing.T) { return Ran{} }, nil)) r, _ := m.Act(System, "stop", "showcase.service") - if r["mesh_declared"] != true || !strings.Contains(r["note"].(string), "host restores its declared state") { + if r["mesh_declared"] != true || !strings.Contains(r["note"].(string), "node-engine restores its declared state") { t.Fatalf("%v", r) } } diff --git a/modules/systemd/cmd/systemd-tools/main.go b/modules/systemd/cmd/systemd-tools/main.go index 237df05..ff71937 100644 --- a/modules/systemd/cmd/systemd-tools/main.go +++ b/modules/systemd/cmd/systemd-tools/main.go @@ -92,8 +92,8 @@ func tools(m *Manager) []stdio.Tool { } return m.Status(scope, unit) }}, - act("start", "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at its next apply."), - act("stop", "Stop one unit; for a unit the mesh declares, the answer says the host will restore its declared state."), + act("start", "Start one unit. For a unit the mesh declares, the answer says the node-engine will restore what its declaration says at its next apply."), + act("stop", "Stop one unit; for a unit the mesh declares, the answer says the node-engine will restore its declared state."), act("restart", "Restart one unit."), act("enable", "Make one unit start at boot (or at the account's login, in user scope)."), act("disable", "Stop one unit starting at boot (or at login, in user scope)."), diff --git a/retired-words b/retired-words new file mode 100644 index 0000000..870b2e2 --- /dev/null +++ b/retired-words @@ -0,0 +1,15 @@ +# The words the glossary retired for the descriptions of the mesh's tools (novox/hq ADR 0244): its +# *Not:* words with no scope or with (tools). A copy, so a catalogue merge needs nothing else; novox/hq's +# words.py compares it with the glossary. Regenerate with: python3 00-META/checks/words.py --list tools +build machine +change plan +control plane +flavor +host agent +mesh-console +node host +node tools +release plan +substrate +tool bridge +tools-sdk