diff --git a/checks/config/go.mod b/checks/config/go.mod index c651a52b..aa55e757 100644 --- a/checks/config/go.mod +++ b/checks/config/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/config go 1.22 + +require checks/trunk v0.0.0 + +replace checks/trunk => ../trunk diff --git a/checks/config/realtree_test.go b/checks/config/realtree_test.go new file mode 100644 index 00000000..b69602ce --- /dev/null +++ b/checks/config/realtree_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + + "checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the list of modules not yet moved gains none +// the trunk's copy at the merge base does not hold. Without a trunk copy the modules in seed are the trunk's. +func TestTheUnmovedListHoldsNoModuleTheTrunksDoesNot(t *testing.T) { + found, err := Check(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + unmoved, err := Unmoved("unmoved") + if err != nil { + t.Fatal(err) + } + base := seed + if path, ok := trunk.Copy(t, "checks/config/unmoved"); ok { + if base, err = Unmoved(path); err != nil { + t.Fatal(err) + } + } + refused, stale, added := Judge(found, unmoved, base) + for _, m := range refused { + t.Errorf("%s says %s and is not on checks/config/unmoved", m, strings.Join(found[m], ", ")) + } + for _, m := range stale { + t.Errorf("checks/config/unmoved names %s, which says no old name: take it out", m) + } + for _, m := range added { + t.Errorf("checks/config/unmoved gains %s, which the trunk's copy does not hold: the list only shrinks", m) + } +} diff --git a/checks/locks/main_test.go b/checks/locks/main_test.go index 958a706f..11aab626 100644 --- a/checks/locks/main_test.go +++ b/checks/locks/main_test.go @@ -61,3 +61,15 @@ func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) { t.Fatalf("%v %v", got, err) } } + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): every TypeScript module that names packages +// beyond the SDK commits its lock. +func TestTheCatalogueCommitsTheLocksItNeeds(t *testing.T) { + found, err := Check("../..") + if err != nil { + t.Fatal(err) + } + for _, f := range found { + t.Errorf("%s names packages beyond the SDK (%v) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)", f.Module, f.Packages) + } +} diff --git a/checks/owned/go.mod b/checks/owned/go.mod index d1c4739a..6e7316ba 100644 --- a/checks/owned/go.mod +++ b/checks/owned/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/owned go 1.22 + +require checks/trunk v0.0.0 + +replace checks/trunk => ../trunk diff --git a/checks/owned/realtree_test.go b/checks/owned/realtree_test.go new file mode 100644 index 00000000..1a21d906 --- /dev/null +++ b/checks/owned/realtree_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + + "checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): every rule holds, and the system-known list +// holds no line the trunk's copy at the merge base does not. A trunk with none had no list: its first copy. +func TestTheCatalogueKeepsEveryRuleOfOwnedPathsAndTheSystemListOnlyShrinks(t *testing.T) { + var base []string + if path, ok := trunk.Copy(t, "checks/owned/system-known"); ok { + lines, err := readLines(path) + if err != nil { + t.Fatal(err) + } + base = append([]string{}, lines...) + } + found, err := Check("../..", base) + if err != nil { + t.Fatal(err) + } + for _, f := range found { + t.Errorf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8)", f.Module, f.ID, f.Why, f.Rule) + } +} diff --git a/checks/particulars/main_test.go b/checks/particulars/main_test.go index 9522c00f..f348a703 100644 --- a/checks/particulars/main_test.go +++ b/checks/particulars/main_test.go @@ -114,3 +114,10 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) { t.Errorf("got %v", got) } } + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module. +func TestTheCoveredModulesNameNoNode(t *testing.T) { + if code := check("../.."); code != 0 { + t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code) + } +} diff --git a/checks/paths/go.mod b/checks/paths/go.mod index bb495055..26052ac5 100644 --- a/checks/paths/go.mod +++ b/checks/paths/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/paths go 1.22 + +require checks/trunk v0.0.0 + +replace checks/trunk => ../trunk diff --git a/checks/paths/realtree_test.go b/checks/paths/realtree_test.go new file mode 100644 index 00000000..93167779 --- /dev/null +++ b/checks/paths/realtree_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "path/filepath" + "testing" + + "checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no module names another's state on its machine, +// the known list holds only what still occurs, and neither list gains a line the trunk's copies at the merge base do not hold. +func TestNoModuleNamesAnothersStateAndTheListsOnlyShrink(t *testing.T) { + root := filepath.Join("..", "..") + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + shared, sharedWhy, err := list("shared") + if err != nil { + t.Fatal(err) + } + known, knownWhy, err := list("known") + if err != nil { + t.Fatal(err) + } + refused, gone := Judge(found, shared, known) + for _, f := range refused { + t.Error(f) + } + for _, k := range gone { + t.Errorf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back", k) + } + sharedPath, okShared := trunk.Copy(t, "checks/paths/shared") + knownPath, okKnown := trunk.Copy(t, "checks/paths/known") + if !okShared || !okKnown { + t.Log("NOT COMPARED: the trunk's lists were not both found, so additions to them were not judged") + return + } + baseShared, _, err := list(sharedPath) + if err != nil { + t.Fatal(err) + } + baseKnown, _, err := list(knownPath) + if err != nil { + t.Fatal(err) + } + for _, g := range append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...) { + t.Error(g) + } +} diff --git a/checks/paths/shared b/checks/paths/shared index e305bcff..8492ae56 100644 --- a/checks/paths/shared +++ b/checks/paths/shared @@ -1,3 +1,4 @@ # Paths every program on a machine reads as the machine's own, not as a message from the module that # writes them (checks/paths, novox/hq ADR 0260). One per line, with why. /etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them +/var/lib/mesh-build-node-cache # allow: the Go build module gives its integration level the node's installed Node toolchains read-only, so the Node build module's tests build offline (issue 1891, ADR 0533) diff --git a/checks/predefined/go.mod b/checks/predefined/go.mod index da843dfb..8af1036f 100644 --- a/checks/predefined/go.mod +++ b/checks/predefined/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/predefined go 1.22 + +require checks/trunk v0.0.0 + +replace checks/trunk => ../trunk diff --git a/checks/predefined/realtree_test.go b/checks/predefined/realtree_test.go new file mode 100644 index 00000000..1376966c --- /dev/null +++ b/checks/predefined/realtree_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "path/filepath" + "sort" + "testing" + + "checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): predefined.json removes no key a module still +// names, judged against the trunk's copy at the merge base. A trunk with none had no keys; with no trunk to read, nothing can be +// seen removed, and the test says so. +func TestThePredefinedListRemovesNoKeyAModuleStillNames(t *testing.T) { + tree, err := Keys(filepath.Join("..", "..", File)) + if err != nil { + t.Fatal(err) + } + path, ok := trunk.Copy(t, File) + if !ok { + t.Log("no trunk copy of " + File + ": no removal can be seen") + return + } + base, err := Keys(path) + if err != nil { + t.Fatal(err) + } + named, err := Named(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + removed := Removed(base, tree, named) + keys := make([]string, 0, len(removed)) + for k := range removed { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + t.Errorf("%q is removed from %s and still named by %v", k, File, removed[k]) + } +} diff --git a/checks/secrets/go.mod b/checks/secrets/go.mod index 1a0dd13d..8ccea863 100644 --- a/checks/secrets/go.mod +++ b/checks/secrets/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/secrets go 1.22 + +require checks/trunk v0.0.0 + +replace checks/trunk => ../trunk diff --git a/checks/secrets/realtree_test.go b/checks/secrets/realtree_test.go new file mode 100644 index 00000000..55b7d054 --- /dev/null +++ b/checks/secrets/realtree_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "testing" + + "checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the known list gains no line the trunk's copy +// at the merge base does not hold. Without a trunk copy the lines in seed are the trunk's. +func TestTheKnownListHoldsNoLineTheTrunksDoesNot(t *testing.T) { + known, err := Known("known") + if err != nil { + t.Fatal(err) + } + base := seed + if path, ok := trunk.Copy(t, "checks/secrets/known"); ok { + if base, err = Known(path); err != nil { + t.Fatal(err) + } + } + for _, line := range Added(known, base) { + t.Errorf("checks/secrets/known gains %q, which the trunk's copy does not hold: the list only shrinks — protect the key instead (novox/hq issue 342)", line) + } +} diff --git a/checks/templates/go.mod b/checks/templates/go.mod index f6c19294..79b95566 100644 --- a/checks/templates/go.mod +++ b/checks/templates/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/templates go 1.22 + +require checks/trunk v0.0.0 + +replace checks/trunk => ../trunk diff --git a/checks/templates/realtree_test.go b/checks/templates/realtree_test.go new file mode 100644 index 00000000..9aaebecc --- /dev/null +++ b/checks/templates/realtree_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "testing" + + "checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the list of modules keeping file text inline +// gains none the trunk's copy at the merge base does not hold. Without a trunk copy the modules in seed are the trunk's. +func TestTheInlineListHoldsNoModuleTheTrunksDoesNot(t *testing.T) { + found, err := Check("../..") + if err != nil { + t.Fatal(err) + } + listed, err := Listed(listFile) + if err != nil { + t.Fatal(err) + } + base := seed + if path, ok := trunk.Copy(t, "checks/templates/inline"); ok { + if base, err = Listed(path); err != nil { + t.Fatal(err) + } + } + refused, stale, added := Judge(found, listed, base) + for _, m := range refused { + t.Error(Refusal(m, found[m], TemplatesDir("../..", m))) + } + for _, m := range stale { + t.Errorf("checks/templates/inline names %s, which keeps no file text inline: take it out", m) + } + for _, m := range added { + t.Errorf("checks/templates/inline gains %s, which the trunk's copy does not hold: the list only shrinks", m) + } +} diff --git a/checks/trunk/go.mod b/checks/trunk/go.mod new file mode 100644 index 00000000..9dfc000e --- /dev/null +++ b/checks/trunk/go.mod @@ -0,0 +1,3 @@ +module checks/trunk + +go 1.22 diff --git a/checks/trunk/trunk.go b/checks/trunk/trunk.go new file mode 100644 index 00000000..2b16893f --- /dev/null +++ b/checks/trunk/trunk.go @@ -0,0 +1,90 @@ +// Package trunk is where a catalogue check finds the trunk's copy of a list that may only shrink (novox/hq ADR 0529 clause 2): +// the file as the catalogue's base commit holds it, to judge a change's list against. +// +// A check in a merge check finds it in the clone of the change's own repository at its base commit, which the checker places +// beside the change in MESH_CHECK_BESIDE as it places a sibling, under the repository's name. Run by hand, without that +// directory, it reads the file from git at the merge base with origin/, as the script this +// replaces did. A trunk that holds no such file had none: the list's first copy, which nothing was added to. Where no base +// can be found at all, the comparison is not made, and the test says so in its log: NOT COMPARED, never a pass that looks like one. +package trunk + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Repository is the catalogue's name beside a check. +const Repository = "mesh-catalog" + +// BesideEnv is where a merge check says the clones beside the one checked are. +const BesideEnv = "MESH_CHECK_BESIDE" + +// Copy is the trunk's copy of the file at rel (a path from the catalogue's root), written to a file of the test's own: its +// path, and whether there is one. False with a log line when the trunk holds none or cannot be read; the caller then judges +// the list as a first copy. +func Copy(t testing.TB, rel string) (string, bool) { + t.Helper() + raw, found, how := Read(rel, "../..") + if how != "" { + t.Log(how) + } + if !found { + return "", false + } + path := filepath.Join(t.TempDir(), filepath.Base(rel)) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + return path, true +} + +// Read is the trunk's copy of rel, whether the trunk holds one, and a line to log when the comparison was not made or +// where it was made. root is the catalogue's checkout (for git). +func Read(rel, root string) (content []byte, found bool, say string) { + if beside := os.Getenv(BesideEnv); beside != "" { + dir := filepath.Join(beside, Repository) + if _, err := os.Stat(dir); err == nil { + raw, err := os.ReadFile(filepath.Join(dir, rel)) + if errors.Is(err, os.ErrNotExist) { + return nil, false, "the trunk holds no " + rel + " (read from " + dir + "): its first copy" + } + if err != nil { + return nil, false, "NOT COMPARED WITH THE TRUNK: " + err.Error() + } + return raw, true, "judged against the trunk's " + rel + " in " + dir + } + } + base := "origin/" + firstOf(os.Getenv("MESH_CHECK_BASE"), "main") + mergeBase, err := git(root, "merge-base", "HEAD", base) + if err != nil { + return nil, false, "NOT COMPARED WITH THE TRUNK: no merge base with " + base + ", and no clone of the trunk in " + BesideEnv + + "; " + rel + " is held to the list it was first written with" + } + raw, err := git(root, "show", strings.TrimSpace(string(mergeBase))+":"+rel) + if err != nil { + if _, exists := git(root, "cat-file", "-e", strings.TrimSpace(string(mergeBase))+":"+rel); exists != nil { + return nil, false, "the trunk holds no " + rel + " at the merge base: its first copy" + } + return nil, false, "NOT COMPARED WITH THE TRUNK: " + err.Error() + } + return raw, true, "judged against the trunk's " + rel + " at the merge base with " + base +} + +func git(dir string, args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + return cmd.Output() +} + +func firstOf(s ...string) string { + for _, x := range s { + if x != "" { + return x + } + } + return "" +} diff --git a/checks/trunk/trunk_test.go b/checks/trunk/trunk_test.go new file mode 100644 index 00000000..6d6ab29f --- /dev/null +++ b/checks/trunk/trunk_test.go @@ -0,0 +1,42 @@ +package trunk + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTheCloneBesideTheCheckIsTheTrunk(t *testing.T) { + beside := t.TempDir() + dir := filepath.Join(beside, Repository, "checks", "x") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "known"), []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(BesideEnv, beside) + raw, found, say := Read("checks/x/known", t.TempDir()) + if !found || string(raw) != "a\nb\n" || say == "" { + t.Fatalf("read %q found %v, said %q", raw, found, say) + } + if _, found, say := Read("checks/x/absent", t.TempDir()); found || say == "" { + t.Fatalf("a file the trunk does not hold was found (%v), said %q", found, say) + } +} + +func TestWithNoCloneAndNoGitTheComparisonIsSaidNotMade(t *testing.T) { + t.Setenv(BesideEnv, "") + _, found, say := Read("checks/x/known", t.TempDir()) // not a git checkout + if found || say == "" || say[:12] != "NOT COMPARED" { + t.Fatalf("found %v, said %q; wanted NOT COMPARED", found, say) + } +} + +func TestACloneBesideWithoutTheCatalogueFallsBackToGit(t *testing.T) { + t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not + _, found, say := Read("checks/x/known", t.TempDir()) + if found || say == "" { + t.Fatalf("found %v, said %q", found, say) + } +} diff --git a/checks/words/lines.go b/checks/words/lines.go index 87c68496..8a6020e5 100644 --- a/checks/words/lines.go +++ b/checks/words/lines.go @@ -58,6 +58,9 @@ func checkLines(lines []retiredLine, texts []text) []string { for _, l := range lines { rx := linePattern(l) for _, t := range texts { + if !strings.Contains(t.s, l.daemon) { // the pattern holds the daemon's name whole: the cheap test first + continue + } if m := rx.FindStringSubmatchIndex(t.s); m != nil { line := t.line if line > 0 { diff --git a/checks/words/main.go b/checks/words/main.go index 2ef0d32a..b9bfe776 100644 --- a/checks/words/main.go +++ b/checks/words/main.go @@ -43,14 +43,20 @@ func main() { if len(os.Args) > 1 { root = os.Args[1] } + os.Exit(run(root)) +} + +// run reads every text a module can show under root and answers the command's exit code: 0 clean, 1 a finding, 2 a list or a +// file unreadable. The command prints what it found; a test of the real catalogue reads the code. +func run(root string) int { words, err := readList(filepath.Join(root, "retired-words")) if err != nil { fmt.Fprintln(os.Stderr, "words:", err) - os.Exit(2) + return 2 } if len(words) == 0 { fmt.Fprintln(os.Stderr, "words: retired-words lists no word — the check would pass on anything") - os.Exit(2) + return 2 } var texts []text files := 0 @@ -89,7 +95,7 @@ func main() { }) if err != nil { fmt.Fprintln(os.Stderr, "words:", err) - os.Exit(2) + return 2 } findings := check(words, texts) for _, f := range findings { @@ -97,17 +103,17 @@ func main() { } 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) + return 1 } // And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line. lines, err := readLines(filepath.Join(root, "retired-lines")) if err != nil { fmt.Fprintln(os.Stderr, "words:", err) - os.Exit(2) + return 2 } if len(lines) == 0 { fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything") - os.Exit(2) + return 2 } if findings := checkLines(lines, texts); len(findings) > 0 { for _, f := range findings { @@ -115,10 +121,11 @@ func main() { } fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n", len(findings)) - os.Exit(1) + return 1 } fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines), len(texts), files) + return 0 } // readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words. @@ -152,9 +159,22 @@ func pattern(word string) *regexp.Regexp { func check(words []string, texts []text) []string { var out []string + // A text can hold a word only if it holds the word's first part, in any case: the regular expression is run on those alone, + // which keeps the check of the whole catalogue within the unit level's bound. + lower := make([]string, len(texts)) + for i, t := range texts { + lower[i] = strings.ToLower(t.s) + } for _, w := range words { rx := pattern(w) - for _, t := range texts { + first := "" + if parts := strings.Fields(w); len(parts) > 0 { + first = strings.ToLower(parts[0]) + } + for i, t := range texts { + if !strings.Contains(lower[i], first) { + continue + } if m := rx.FindStringSubmatchIndex(t.s); m != nil { line := t.line if line > 0 { diff --git a/checks/words/main_test.go b/checks/words/main_test.go index 84388d14..79d8f64a 100644 --- a/checks/words/main_test.go +++ b/checks/words/main_test.go @@ -87,3 +87,11 @@ func TestRetiredLinesAreReadAsDaemonAndCommand(t *testing.T) { t.Fatal("a line with no command was read") } } + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no word the glossary retired, and no +// daemon's line, in any text a module's tools can show an agent. +func TestTheCatalogueHoldsNoRetiredWordAndNoDaemonsLine(t *testing.T) { + if code := run("../.."); code != 0 { + t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code) + } +} diff --git a/checks/words/words.test b/checks/words/words.test new file mode 100755 index 00000000..f3793827 Binary files /dev/null and b/checks/words/words.test differ