Merge pull request 'Hold what the tools say to the glossary's retired words (hq ADR 0244)' (#111) from feat/tool-descriptions-use-the-glossarys-words into main
This commit was merged in pull request #111.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/words
|
||||
|
||||
go 1.22
|
||||
@@ -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 . <catalogue root>
|
||||
//
|
||||
// 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
|
||||
}
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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" } };
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
@@ -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 <family> <name>, chain X`");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."),
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user