757 lines
27 KiB
Go
757 lines
27 KiB
Go
package checks
|
||
|
||
import (
|
||
"fmt"
|
||
"io/fs"
|
||
"os"
|
||
"path"
|
||
"regexp"
|
||
"runtime"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"testing/fstest"
|
||
"time"
|
||
"unicode"
|
||
)
|
||
|
||
// One word per thing, checked (words.py until ADR 0527; ADR 0244). The glossary (00-META/glossary.md) is the
|
||
// authority on the mesh's words. It names the words it retired on one fixed kind of line, so a program can read them:
|
||
//
|
||
// *Not:* ~~control plane~~, ~~overlay~~ (hq)
|
||
//
|
||
// A struck word with no scope is retired everywhere; `(hq)` retires it in this repository's prose only; `(tools)`
|
||
// in the descriptions of the mesh's tools only. And it names code that still carries an old name until it is renamed:
|
||
//
|
||
// *Identifier until renamed:* `mesh-host` - the repository, binary and service unit
|
||
//
|
||
// An identifier is allowed inside a code span and nowhere else.
|
||
//
|
||
// What is enforced:
|
||
//
|
||
// retired no retired word of scope hq (or none), and no identifier, in running prose of a checked document.
|
||
// Running prose is what is left once code blocks, code spans, block quotes, text in quotation marks,
|
||
// struck-through text, link targets, HTML comments and frontmatter are taken out. A word's plural is the word.
|
||
// unique no head word heads two entries, and none is also struck through on a *Not:* line.
|
||
// lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09): the operator types `nox`.
|
||
// copy with MESH_CATALOG_DIR set to a checkout of the catalogue, its copies of the retired words and the
|
||
// daemons' lines equal this glossary's and this list (skipped, with its reason, when unset).
|
||
//
|
||
// Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; an issue opened on or after
|
||
// wordsFrom (on or after linesFrom for the lines). A document that cannot be reworded in the change that found it is
|
||
// named, with a date and a reason, in words-allowed.md beside this file; an entry past its date fails like the word
|
||
// itself, and so does an entry for a document that no longer needs it.
|
||
|
||
const (
|
||
glossaryFile = "00-META/glossary.md"
|
||
allowedFile = "00-META/checks/words-allowed.md"
|
||
// The day the rule began (ADR 0244): issues opened from it on are held to it.
|
||
wordsFrom = "2026-10-07"
|
||
// The day the daemons' lines rule began.
|
||
linesFrom = "2026-10-09"
|
||
)
|
||
|
||
var wordsAlways = []string{"00-META/", "03-DESIGN/", "AGENTS.md", "README.md"}
|
||
|
||
// daemonLines are the daemons' lines (ADR 0272): each daemon's commands. "alone" is a command that is a line by
|
||
// itself; "with an argument" one that is a line only when written as one ("the mesh-controller module" is prose;
|
||
// `mesh-controller module check ...` is a line). The node-engine's install-time subcommands are not lines here.
|
||
var daemonLines = []struct {
|
||
daemon, alone, withArgument string
|
||
}{
|
||
{"mesh-controller",
|
||
"adopt assign broker cancel cleanup collect conditions converge doctor durations hand-act hand-acts healers identity kill licence " +
|
||
"migrate operator pause pin plan prepare push rebuild rehearse replay resume retire rollout rotate secret settings status token " +
|
||
"unassign unpin upgrade",
|
||
"artifacts ask builds bus collection data delivery images mirrors module node overlay plans queue seat seats take version"},
|
||
{"mesh-host", "apply bundle enrol enroll hand-over help inventory key overlay owned profile reconcile run version", ""},
|
||
{"nox-mesh-host", "hand-over", ""},
|
||
}
|
||
|
||
var (
|
||
notLineRx = regexp.MustCompile(`(?m)^\s*(?:[-*]\s+)?\*Not:\*(.*)$`)
|
||
struckRx = regexp.MustCompile(`~~([^~]+)~~(?:\s*\((hq|tools)\))?`)
|
||
identLineRx = regexp.MustCompile(`(?m)^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$`)
|
||
spanRx = regexp.MustCompile("`([^`]+)`")
|
||
headRx = regexp.MustCompile(`(?m)^\s*-\s+\*\*([^*]+)\*\*`)
|
||
openedRx = regexp.MustCompile(`(?m)^opened:\s*(\S+)`)
|
||
)
|
||
|
||
type retiredWord struct{ word, scope string }
|
||
|
||
// parseGlossary reads the retired words with their scope, the identifiers and the head words from the glossary.
|
||
func parseGlossary(text string) (retired []retiredWord, identifiers, heads []string) {
|
||
for _, m := range notLineRx.FindAllStringSubmatch(text, -1) {
|
||
for _, s := range struckRx.FindAllStringSubmatch(m[1], -1) {
|
||
scope := s[2]
|
||
if scope == "" {
|
||
scope = "all"
|
||
}
|
||
for _, one := range strings.Split(s[1], " / ") {
|
||
retired = append(retired, retiredWord{strings.TrimSpace(one), scope})
|
||
}
|
||
}
|
||
}
|
||
for _, m := range identLineRx.FindAllStringSubmatch(text, -1) {
|
||
for _, s := range spanRx.FindAllStringSubmatch(m[1], -1) {
|
||
identifiers = append(identifiers, strings.TrimSpace(s[1]))
|
||
}
|
||
}
|
||
for _, m := range headRx.FindAllStringSubmatch(text, -1) {
|
||
for _, h := range strings.Split(m[1], " / ") {
|
||
heads = append(heads, strings.TrimSpace(h))
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
func isWordRune(r rune) bool {
|
||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-'
|
||
}
|
||
|
||
// findBounded returns the start offsets of the matches of rx that are whole words: no word character or hyphen
|
||
// before or after (the lookarounds of the Python check, which RE2 has not).
|
||
func findBounded(rx *regexp.Regexp, text string) []int {
|
||
var out []int
|
||
pos := 0
|
||
for pos <= len(text) {
|
||
loc := rx.FindStringIndex(text[pos:])
|
||
if loc == nil {
|
||
break
|
||
}
|
||
start, end := pos+loc[0], pos+loc[1]
|
||
before, after := true, true
|
||
if start > 0 {
|
||
r := []rune(text[max(0, start-4):start])
|
||
before = !isWordRune(r[len(r)-1])
|
||
}
|
||
if end < len(text) {
|
||
r := []rune(text[end:min(len(text), end+4)])
|
||
after = !isWordRune(r[0])
|
||
}
|
||
if before && after {
|
||
out = append(out, start)
|
||
pos = end
|
||
if end == start {
|
||
pos++
|
||
}
|
||
} else {
|
||
pos = start + 1
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// wordRegexp matches a word whole, case-insensitively; a space in the word matches a space, a line break or a hyphen.
|
||
// The plural is the word too: its last part may end in s or es ("alerts", "control planes").
|
||
func wordRegexp(word string) *regexp.Regexp {
|
||
var parts []string
|
||
for _, p := range strings.Fields(word) {
|
||
parts = append(parts, regexp.QuoteMeta(p))
|
||
}
|
||
return regexp.MustCompile(`(?i)` + strings.Join(parts, `[\s-]+`) + `(?:e?s)?`)
|
||
}
|
||
|
||
// blank replaces every byte of s but the line breaks, so offsets and line numbers hold.
|
||
func blank(s string) string {
|
||
b := []byte(s)
|
||
for i, c := range b {
|
||
if c != '\n' {
|
||
b[i] = ' '
|
||
}
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func blankFrontmatter(text string) string {
|
||
if strings.HasPrefix(text, "---\n") {
|
||
if end := strings.Index(text[4:], "\n---"); end != -1 {
|
||
end += 4
|
||
return blank(text[:end+4]) + text[end+4:]
|
||
}
|
||
}
|
||
return text
|
||
}
|
||
|
||
func blankAll(text string, steps []*regexp.Regexp) string {
|
||
for _, rx := range steps {
|
||
text = rx.ReplaceAllStringFunc(text, blank)
|
||
}
|
||
return text
|
||
}
|
||
|
||
func compileAll(patterns ...string) []*regexp.Regexp {
|
||
var out []*regexp.Regexp
|
||
for _, p := range patterns {
|
||
out = append(out, regexp.MustCompile(p))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// What is not the document's own words, in the order it is taken out.
|
||
const (
|
||
stepComment = `(?s)<!--.*?-->`
|
||
stepBlockQuote = `(?m)^\s*>.*$`
|
||
stepLinkTarget = `\]\([^)\s]*\)`
|
||
stepStruck = `~~[^~\n]+~~`
|
||
stepQuoted = `"[^"\n]*"`
|
||
stepCurlyDouble = `\x{201c}[^\x{201d}]*\x{201d}`
|
||
stepCurlySingle = `\x{2018}[^\x{2019}\n]*\x{2019}`
|
||
)
|
||
|
||
var (
|
||
proseSteps = compileAll("(?ms)^\\s*```.*?^\\s*```", stepComment, stepBlockQuote, "`[^`\\n]+`", stepLinkTarget, stepStruck,
|
||
stepQuoted, stepCurlyDouble, stepCurlySingle)
|
||
// In the glossary the *Not:* and *Identifier until renamed:* lines name the retired words, so they are taken out first.
|
||
glossaryProseSteps = append(compileAll(notLineRx.String(), identLineRx.String()), proseSteps...)
|
||
// The daemons' lines are looked for in the document's own words: a code block and a code span are kept, since a
|
||
// line to type is written in one.
|
||
ownWordsSteps = compileAll(stepComment, stepBlockQuote, stepLinkTarget, stepStruck, stepQuoted, stepCurlyDouble, stepCurlySingle)
|
||
)
|
||
|
||
// prose is the text with everything that is not running prose blanked, keeping offsets and line numbers.
|
||
func prose(text string, glossaryDoc bool) string {
|
||
text = blankFrontmatter(text)
|
||
if glossaryDoc {
|
||
return blankAll(text, glossaryProseSteps)
|
||
}
|
||
return blankAll(text, proseSteps)
|
||
}
|
||
|
||
func ownWords(text string) string { return blankAll(blankFrontmatter(text), ownWordsSteps) }
|
||
|
||
// issueDocuments lists the issue reports' files opened on or after since.
|
||
func issueDocuments(fsys fs.FS, since string) []string {
|
||
var out []string
|
||
entries, _ := fs.ReadDir(fsys, "04-ISSUES")
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
head := readFile(fsys, path.Join("04-ISSUES", e.Name(), "00-report.md"))
|
||
front := ""
|
||
if strings.HasPrefix(head, "---\n") {
|
||
if end := strings.Index(head[4:], "\n---"); end != -1 {
|
||
if m := openedRx.FindStringSubmatch(head[4 : 4+end]); m != nil {
|
||
front = m[1]
|
||
}
|
||
}
|
||
}
|
||
if front == "" || front < since {
|
||
continue
|
||
}
|
||
files, _ := fs.ReadDir(fsys, path.Join("04-ISSUES", e.Name()))
|
||
for _, f := range files {
|
||
if strings.HasSuffix(f.Name(), ".md") {
|
||
out = append(out, path.Join("04-ISSUES", e.Name(), f.Name()))
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// perDocument runs fn over the documents on all processors (the unit level is short) and joins the problems in
|
||
// the documents' order.
|
||
func perDocument(docs []string, fn func(doc string) []string) []string {
|
||
results := make([][]string, len(docs))
|
||
var wg sync.WaitGroup
|
||
sem := make(chan struct{}, runtime.NumCPU())
|
||
for i, d := range docs {
|
||
wg.Add(1)
|
||
sem <- struct{}{}
|
||
go func() {
|
||
defer wg.Done()
|
||
results[i] = fn(d)
|
||
<-sem
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
var out []string
|
||
for _, r := range results {
|
||
out = append(out, r...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func checkedDocuments(fsys fs.FS, since string) []string {
|
||
var docs []string
|
||
for _, entry := range wordsAlways {
|
||
if strings.HasSuffix(entry, "/") {
|
||
_ = fs.WalkDir(fsys, strings.TrimSuffix(entry, "/"), func(p string, d fs.DirEntry, err error) error {
|
||
if err == nil && !d.IsDir() && strings.HasSuffix(p, ".md") {
|
||
docs = append(docs, p)
|
||
}
|
||
return nil
|
||
})
|
||
} else if _, err := fs.Stat(fsys, entry); err == nil {
|
||
docs = append(docs, entry)
|
||
}
|
||
}
|
||
return append(docs, issueDocuments(fsys, since)...)
|
||
}
|
||
|
||
var dateRx = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||
|
||
// parseAllowed reads words-allowed.md: one row per document, | `document` | until | why |. Until is a date, after
|
||
// which the allowance fails like the words themselves. The clock is the caller's: today is YYYY-MM-DD.
|
||
func parseAllowed(text, today string) (allowed map[string]string, problems []string) {
|
||
allowed = map[string]string{}
|
||
for _, line := range strings.Split(text, "\n") {
|
||
cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
|
||
if len(cells) != 3 {
|
||
continue
|
||
}
|
||
for i := range cells {
|
||
cells[i] = strings.TrimSpace(cells[i])
|
||
}
|
||
if !strings.HasPrefix(cells[0], "`") {
|
||
continue
|
||
}
|
||
doc, until, why := strings.Trim(cells[0], "`"), cells[1], cells[2]
|
||
if why == "" {
|
||
problems = append(problems, fmt.Sprintf("%s: the allowance for %s gives no reason", allowedFile, doc))
|
||
}
|
||
if !dateRx.MatchString(until) {
|
||
problems = append(problems, fmt.Sprintf("%s: the allowance for %s has no date", allowedFile, doc))
|
||
continue
|
||
}
|
||
if until < today {
|
||
problems = append(problems, fmt.Sprintf("%s: the allowance for %s ran out on %s — reword it", allowedFile, doc, until))
|
||
continue
|
||
}
|
||
allowed[doc] = until
|
||
}
|
||
return
|
||
}
|
||
|
||
func checkRetired(fsys fs.FS, retired []retiredWord, identifiers []string, today string) []string {
|
||
allowance, out := parseAllowed(readFile(fsys, allowedFile), today)
|
||
type matcher struct {
|
||
word string
|
||
rx *regexp.Regexp
|
||
why string
|
||
}
|
||
var words []matcher
|
||
for _, r := range retired {
|
||
if r.scope == "all" || r.scope == "hq" {
|
||
words = append(words, matcher{r.word, wordRegexp(r.word), "retired"})
|
||
}
|
||
}
|
||
for _, i := range identifiers {
|
||
words = append(words, matcher{i, wordRegexp(i), "an identifier, allowed only in a code span"})
|
||
}
|
||
var mu sync.Mutex
|
||
used := map[string]bool{}
|
||
out = append(out, perDocument(checkedDocuments(fsys, wordsFrom), func(d string) []string {
|
||
var found []string
|
||
text := prose(readFile(fsys, d), d == glossaryFile)
|
||
lower := strings.ToLower(text)
|
||
for _, w := range words {
|
||
// Every match holds the word's first part literally, so a text without it is not searched (the unit level is short).
|
||
if !strings.Contains(lower, strings.ToLower(strings.Fields(w.word)[0])) {
|
||
continue
|
||
}
|
||
for _, start := range findBounded(w.rx, text) {
|
||
if _, ok := allowance[d]; ok {
|
||
mu.Lock()
|
||
used[d] = true
|
||
mu.Unlock()
|
||
continue
|
||
}
|
||
m := w.rx.FindString(text[start:])
|
||
found = append(found, fmt.Sprintf("%s:%d: %q is %s (glossary)", d, lineOf(text, start), strings.ReplaceAll(m, "\n", " "), w.why))
|
||
}
|
||
}
|
||
return found
|
||
})...)
|
||
var unused []string
|
||
for d := range allowance {
|
||
if !used[d] {
|
||
unused = append(unused, d)
|
||
}
|
||
}
|
||
sort.Strings(unused)
|
||
for _, d := range unused {
|
||
out = append(out, fmt.Sprintf("%s: %s no longer uses a retired word — remove its allowance", allowedFile, d))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func checkUnique(retired []retiredWord, heads []string) []string {
|
||
var out []string
|
||
seen := map[string]bool{}
|
||
for _, h := range heads {
|
||
k := strings.ToLower(h)
|
||
if seen[k] {
|
||
out = append(out, fmt.Sprintf("%s: %q heads two entries", glossaryFile, h))
|
||
}
|
||
seen[k] = true
|
||
}
|
||
for _, r := range retired {
|
||
if seen[strings.ToLower(r.word)] {
|
||
out = append(out, fmt.Sprintf("%s: %q is a head word and also retired", glossaryFile, r.word))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// linesList is the daemons' lines as the catalogue's copy lists them: "<daemon> <command>", with " …" when an
|
||
// argument must follow.
|
||
func linesList() []string {
|
||
var out []string
|
||
for _, d := range daemonLines {
|
||
for _, c := range strings.Fields(d.alone) {
|
||
out = append(out, d.daemon+" "+c)
|
||
}
|
||
for _, c := range strings.Fields(d.withArgument) {
|
||
out = append(out, d.daemon+" "+c+" …")
|
||
}
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
func checkLines(fsys fs.FS) []string {
|
||
type pat struct {
|
||
daemon, command string
|
||
rx *regexp.Regexp
|
||
bounded bool
|
||
}
|
||
var pats []pat
|
||
for _, d := range daemonLines {
|
||
dq := regexp.QuoteMeta(d.daemon)
|
||
for _, c := range strings.Fields(d.alone) {
|
||
pats = append(pats, pat{d.daemon, c, regexp.MustCompile(dq + `\s+` + regexp.QuoteMeta(c)), true})
|
||
}
|
||
for _, c := range strings.Fields(d.withArgument) {
|
||
// A line only when written as one: at the start of a line or a code span, a prompt allowed, an argument after.
|
||
pats = append(pats, pat{d.daemon, c, regexp.MustCompile("(?m)(?:^|`)[ \\t]*(?:\\$[ \\t]*)?(" + dq + `\s+` + regexp.QuoteMeta(c) + "\\s+[^\\s`])"), false})
|
||
}
|
||
}
|
||
return perDocument(checkedDocuments(fsys, linesFrom), func(d string) []string {
|
||
var out []string
|
||
text := ownWords(readFile(fsys, d))
|
||
for _, p := range pats {
|
||
if !strings.Contains(text, p.daemon) {
|
||
continue
|
||
}
|
||
var starts []int
|
||
if p.bounded {
|
||
starts = findBounded(p.rx, text)
|
||
} else {
|
||
for _, loc := range p.rx.FindAllStringSubmatchIndex(text, -1) {
|
||
starts = append(starts, loc[2])
|
||
}
|
||
}
|
||
for _, s := range starts {
|
||
out = append(out, fmt.Sprintf("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (ADR 0272)", d, lineOf(text, s), p.daemon, p.command))
|
||
}
|
||
}
|
||
return out
|
||
})
|
||
}
|
||
|
||
func toolsList(retired []retiredWord) []string {
|
||
seen := map[string]bool{}
|
||
var out []string
|
||
for _, r := range retired {
|
||
if (r.scope == "all" || r.scope == "tools") && !seen[r.word] {
|
||
seen[r.word] = true
|
||
out = append(out, r.word)
|
||
}
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) })
|
||
return out
|
||
}
|
||
|
||
func readList(file string) ([]string, error) {
|
||
b, err := os.ReadFile(file)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out []string
|
||
for _, l := range strings.Split(string(b), "\n") {
|
||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
|
||
out = append(out, l)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// checkCopy compares the catalogue's copies of the retired words and the daemons' lines with this glossary's. A
|
||
// difference names what the copy should list, so regenerating it is copying that from the failure.
|
||
func checkCopy(catalogue string, retired []retiredWord) []string {
|
||
var out []string
|
||
copyWords, err := readList(path.Join(catalogue, "retired-words"))
|
||
if err != nil {
|
||
return []string{"the catalogue's copy of the retired words cannot be read: " + err.Error()}
|
||
}
|
||
sort.Slice(copyWords, func(i, j int) bool { return strings.ToLower(copyWords[i]) < strings.ToLower(copyWords[j]) })
|
||
want := toolsList(retired)
|
||
if strings.Join(copyWords, "\n") != strings.Join(want, "\n") {
|
||
out = append(out, "the catalogue's retired-words differs from the glossary: it should list "+strings.Join(want, ", "))
|
||
}
|
||
copyLines, err := readList(path.Join(catalogue, "retired-lines"))
|
||
if err != nil {
|
||
return append(out, "the catalogue's copy of the daemons' lines cannot be read: "+err.Error())
|
||
}
|
||
sort.Strings(copyLines)
|
||
if strings.Join(copyLines, "\n") != strings.Join(linesList(), "\n") {
|
||
out = append(out, "the catalogue's retired-lines differs from this check's list: it should list, one per line:\n"+strings.Join(linesList(), "\n"))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// The real tree.
|
||
|
||
func today() string { return time.Now().Format("2006-01-02") }
|
||
|
||
func TestWordsGlossaryNamesRetiredWords(t *testing.T) {
|
||
retired, identifiers, heads := parseGlossary(readFile(rootFS(), glossaryFile))
|
||
if len(retired) == 0 {
|
||
t.Fatal("the glossary names no retired word on a *Not:* line — the check would pass on anything")
|
||
}
|
||
if len(identifiers) == 0 || len(heads) == 0 {
|
||
t.Errorf("the glossary names %d identifiers and %d head words; both are expected", len(identifiers), len(heads))
|
||
}
|
||
}
|
||
|
||
func TestWordsEachHeadWordHeadsOneEntry(t *testing.T) {
|
||
retired, _, heads := parseGlossary(readFile(rootFS(), glossaryFile))
|
||
expectNone(t, checkUnique(retired, heads))
|
||
}
|
||
|
||
func TestWordsNoRetiredWordInRunningProse(t *testing.T) {
|
||
retired, identifiers, _ := parseGlossary(readFile(rootFS(), glossaryFile))
|
||
expectNone(t, checkRetired(rootFS(), retired, identifiers, today()))
|
||
}
|
||
|
||
func TestWordsNoDaemonLineWritten(t *testing.T) { expectNone(t, checkLines(rootFS())) }
|
||
|
||
func TestWordsCatalogueCopyAgrees(t *testing.T) {
|
||
catalogue := os.Getenv("MESH_CATALOG_DIR")
|
||
if catalogue == "" {
|
||
t.Skip("NOT COMPARED: MESH_CATALOG_DIR is not set, so the catalogue's copy of the list was not read")
|
||
}
|
||
retired, _, _ := parseGlossary(readFile(rootFS(), glossaryFile))
|
||
expectNone(t, checkCopy(catalogue, retired))
|
||
}
|
||
|
||
// The checks themselves, against broken trees (H5a: the glossary).
|
||
|
||
const glossaryFixture = `# Glossary
|
||
|
||
- **control plane** — the retired one's replacement is below.
|
||
*Not:* ~~control plane~~, ~~overlay~~ (hq), ~~alert~~ (tools), ~~a / b~~
|
||
- **controller** — decides.
|
||
*Not:* ~~control plane~~
|
||
- **Controller** — again.
|
||
- *Identifier until renamed:* ` + "`mesh-host`, `nox-old`" + ` — the repository
|
||
`
|
||
|
||
func TestGlossaryReadsNotLinesScopesIdentifiersAndHeads(t *testing.T) {
|
||
retired, identifiers, heads := parseGlossary(glossaryFixture)
|
||
want := []retiredWord{{"control plane", "all"}, {"overlay", "hq"}, {"alert", "tools"}, {"a", "all"}, {"b", "all"}, {"control plane", "all"}}
|
||
if fmt.Sprint(retired) != fmt.Sprint(want) {
|
||
t.Errorf("retired = %v, want %v", retired, want)
|
||
}
|
||
if fmt.Sprint(identifiers) != "[mesh-host nox-old]" {
|
||
t.Errorf("identifiers = %v", identifiers)
|
||
}
|
||
if fmt.Sprint(heads) != "[control plane controller Controller]" {
|
||
t.Errorf("heads = %v", heads)
|
||
}
|
||
got := checkUnique(retired, heads)
|
||
expectProblem(t, got, `"Controller" heads two entries`)
|
||
expectProblem(t, got, `"control plane" is a head word and also retired`)
|
||
}
|
||
|
||
func TestToolsListHoldsTheWordsOfScopeAllAndTools(t *testing.T) {
|
||
retired, _, _ := parseGlossary(glossaryFixture)
|
||
if got := fmt.Sprint(toolsList(retired)); got != "[a alert b control plane]" {
|
||
t.Errorf("toolsList = %s", got)
|
||
}
|
||
}
|
||
|
||
// H5b: prose blanking and matching.
|
||
|
||
func TestProseBlanksWhatIsNotRunningProse(t *testing.T) {
|
||
text := "---\nfm: overlay\n---\n" +
|
||
"bare overlay one\n" +
|
||
"```\noverlay in a block\n```\n" +
|
||
"span `overlay` x\n" +
|
||
"> quoted overlay\n" +
|
||
"say \"overlay\" and “overlay” and ‘overlay’\n" +
|
||
"~~overlay~~ struck\n" +
|
||
"[link](overlay.md)\n" +
|
||
"<!-- overlay\nover lines -->\n" +
|
||
"bare overlay two\n"
|
||
got := prose(text, false)
|
||
if strings.Count(got, "overlay") != 2 {
|
||
t.Errorf("only the two bare uses stay: %q", got)
|
||
}
|
||
if strings.Count(got, "\n") != strings.Count(text, "\n") || len(got) != len(text) {
|
||
t.Error("blanking keeps offsets and line numbers")
|
||
}
|
||
}
|
||
|
||
func TestProseOfTheGlossaryBlanksNotAndIdentifierLines(t *testing.T) {
|
||
got := prose("- **x** — y\n *Not:* control plane, plain\n- *Identifier until renamed:* mesh-host\nthe control plane\n", true)
|
||
if strings.Count(got, "control plane") != 1 || strings.Contains(got, "mesh-host") {
|
||
t.Errorf("only the running use stays: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestWordMatchesWholeWordsPluralsAndLineBreaks(t *testing.T) {
|
||
rx := wordRegexp("control plane")
|
||
count := func(s string) int { return len(findBounded(rx, s)) }
|
||
for s, want := range map[string]int{
|
||
"the control plane runs": 1,
|
||
"Control Planes": 1,
|
||
"control-plane and control\nplane": 2,
|
||
"uncontrol plane": 0,
|
||
"control planet": 0,
|
||
"control plane-x": 0,
|
||
"x-control plane": 0,
|
||
"control planexs": 0,
|
||
} {
|
||
if got := count(s); got != want {
|
||
t.Errorf("%q: %d matches, want %d", s, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRetiredCatchesAWordInProseAndNamesDocumentAndLine(t *testing.T) {
|
||
retired := []retiredWord{{"control plane", "all"}, {"overlay", "hq"}, {"alert", "tools"}}
|
||
broken := mapOf(map[string]string{
|
||
"AGENTS.md": "fine\nthe control plane here\n",
|
||
"03-DESIGN/01-to-be/a.md": "an overlay and \"a control plane quoted\" and `overlay` alert\n",
|
||
"README.md": "mesh-host runs; `mesh-host` too\n",
|
||
})
|
||
got := checkRetired(broken, retired, []string{"mesh-host"}, "2026-10-23")
|
||
expectProblem(t, got, `AGENTS.md:2: "control plane" is retired`)
|
||
expectProblem(t, got, `03-DESIGN/01-to-be/a.md:1: "overlay" is retired`)
|
||
expectProblem(t, got, `README.md:1: "mesh-host" is an identifier, allowed only in a code span`)
|
||
if len(got) != 3 {
|
||
t.Errorf("a quotation, a code span and a word of scope tools are not problems: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestIssuesAreHeldToTheRuleFromItsDay(t *testing.T) {
|
||
retired := []retiredWord{{"overlay", "all"}}
|
||
fsys := mapOf(map[string]string{
|
||
"04-ISSUES/1-old/00-report.md": "---\nopened: 2026-10-06\n---\noverlay\n",
|
||
"04-ISSUES/2-new/00-report.md": "---\nopened: 2026-10-07\n---\nfine\n",
|
||
"04-ISSUES/2-new/01-notes.md": "an overlay\n",
|
||
"04-ISSUES/3-nodate/00-report.md": "overlay\n",
|
||
})
|
||
got := checkRetired(fsys, retired, nil, "2026-10-23")
|
||
expectProblem(t, got, "04-ISSUES/2-new/01-notes.md:1")
|
||
if len(got) != 1 {
|
||
t.Errorf("only the issue opened from 2026-10-07 on is read: %q", got)
|
||
}
|
||
}
|
||
|
||
// H5c: allowances, with an injected clock.
|
||
|
||
const allowedFixture = `# Words allowed
|
||
|
||
| Document | Until | Why |
|
||
|---|---|---|
|
||
| ` + "`a.md`" + ` | 2026-12-01 | quotes what was |
|
||
| ` + "`b.md`" + ` | 2026-10-01 | ran out |
|
||
| ` + "`c.md`" + ` | soon | no date |
|
||
| ` + "`d.md`" + ` | 2026-12-01 | |
|
||
`
|
||
|
||
func TestAllowedReadsRowsAndTheInjectedClock(t *testing.T) {
|
||
allowed, problems := parseAllowed(allowedFixture, "2026-10-23")
|
||
if _, ok := allowed["a.md"]; !ok || len(allowed) != 2 {
|
||
t.Errorf("a.md and d.md are in date: %v", allowed)
|
||
}
|
||
expectProblem(t, problems, "the allowance for b.md ran out on 2026-10-01")
|
||
expectProblem(t, problems, "the allowance for c.md has no date")
|
||
expectProblem(t, problems, "the allowance for d.md gives no reason")
|
||
// The same text on an earlier day: b.md is still in date.
|
||
if early, _ := parseAllowed(allowedFixture, "2026-09-30"); len(early) != 3 {
|
||
t.Errorf("on 2026-09-30 a, b and d are in date: %v", early)
|
||
}
|
||
}
|
||
|
||
func TestAllowanceHoldsAWordBackUntilItsDateAndShrinks(t *testing.T) {
|
||
retired := []retiredWord{{"overlay", "all"}}
|
||
allowedRow := "| `AGENTS.md` | 2026-12-01 | quotes what was |\n"
|
||
tree := func(agents, allowed string) map[string]string {
|
||
return map[string]string{"AGENTS.md": agents, allowedFile: allowed}
|
||
}
|
||
expectNone(t, checkRetired(mapOf(tree("an overlay\n", allowedRow)), retired, nil, "2026-10-23"))
|
||
expectProblem(t, checkRetired(mapOf(tree("an overlay\n", allowedRow)), retired, nil, "2026-12-02"), "ran out on 2026-12-01")
|
||
expectProblem(t, checkRetired(mapOf(tree("clean\n", allowedRow)), retired, nil, "2026-10-23"), "AGENTS.md no longer uses a retired word")
|
||
expectProblem(t, checkRetired(mapOf(tree("an overlay\n", "")), retired, nil, "2026-10-23"), `AGENTS.md:1: "overlay" is retired`)
|
||
}
|
||
|
||
// H5d: the daemons' lines and the catalogue comparison.
|
||
|
||
func TestLinesCatchesADaemonsLineButNotProse(t *testing.T) {
|
||
fsys := mapOf(map[string]string{
|
||
"AGENTS.md": "the mesh-controller module is prose\n" +
|
||
"run `mesh-controller module check x` now\n" +
|
||
"or:\n```\n$ mesh-controller push\n```\n" +
|
||
"a quotation \"mesh-controller status\" stays\n" +
|
||
"> mesh-controller doctor in a block quote\n" +
|
||
"a mesh-controller pushed is another word\n",
|
||
"04-ISSUES/1/00-report.md": "---\nopened: 2026-10-08\n---\nmesh-host apply\n",
|
||
"04-ISSUES/2/00-report.md": "---\nopened: 2026-10-09\n---\nmesh-host apply\n",
|
||
})
|
||
got := checkLines(fsys)
|
||
expectProblem(t, got, "AGENTS.md:2: `mesh-controller module` is a daemon's line")
|
||
expectProblem(t, got, "AGENTS.md:5: `mesh-controller push` is a daemon's line")
|
||
expectProblem(t, got, "04-ISSUES/2/00-report.md:4: `mesh-host apply`")
|
||
if len(got) != 3 {
|
||
t.Errorf("prose, quotations, block quotes and issues before 2026-10-09 are not problems: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestLinesListNamesEachLineOnce(t *testing.T) {
|
||
list := linesList()
|
||
seen := map[string]bool{}
|
||
for _, l := range list {
|
||
if seen[l] {
|
||
t.Errorf("%q listed twice", l)
|
||
}
|
||
seen[l] = true
|
||
}
|
||
if !seen["mesh-controller push"] || !seen["mesh-controller module …"] || !seen["nox-mesh-host hand-over"] {
|
||
t.Errorf("the list lacks a known line: %v", list[:5])
|
||
}
|
||
}
|
||
|
||
func TestCatalogueCopyDifferenceIsCaughtAndNamesTheList(t *testing.T) {
|
||
retired := []retiredWord{{"overlay", "all"}, {"alert", "tools"}, {"hq only", "hq"}}
|
||
dir := t.TempDir()
|
||
write := func(name, content string) {
|
||
if err := os.WriteFile(path.Join(dir, name), []byte(content), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
write("retired-words", "# copy\nalert\noverlay\n")
|
||
write("retired-lines", strings.Join(linesList(), "\n")+"\n")
|
||
expectNone(t, checkCopy(dir, retired))
|
||
|
||
write("retired-words", "alert\n")
|
||
write("retired-lines", "mesh-controller push\n")
|
||
got := checkCopy(dir, retired)
|
||
expectProblem(t, got, "retired-words differs from the glossary: it should list alert, overlay")
|
||
expectProblem(t, got, "retired-lines differs from this check's list")
|
||
expectProblem(t, checkCopy(t.TempDir(), retired), "cannot be read")
|
||
}
|
||
|
||
func mapOf(files map[string]string) fs.FS {
|
||
m := fstest.MapFS{}
|
||
for k, v := range files {
|
||
m[k] = doc(v)
|
||
}
|
||
return m
|
||
}
|