Files
mesh-catalog/checks/particulars/main.go
T
jochen fab57411c5 Take novox/hq in any form and a declared hardware word as no finding in the particulars check (issue 1838)
novox/hq is the name of the software's own record, whatever follows it; asus-g14 is the laptop's name in
its vendor trigger file, declared in particulars as hardware asus-g14 (1838/AC2).
2026-10-22 22:31:05 +02:00

297 lines
10 KiB
Go

// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
// fixture of any module names a node of this one.
//
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
// they may be written. Every directory under modules/ is read, with no list of covered modules, so a new module
// is covered from its first commit. A name is matched whole and in any case; a hyphen, an underscore or a
// CamelCase boundary parts one, so a longer word holding a name is another word.
// Some text holds a node name and is not one, so it is blanked before a line is read, each form exact: the
// software's module path (git.novox.be/novox/<repo>), the name of the software's record (novox/hq, followed by
// anything), the hardware model (asus-zephyrus-g14, ROG Zephyrus G14), and the software's own
// name and npm scope (Novox Mesh, novox-mesh, @novox/mesh-sdk with nothing before the @). Any other novox/<x>,
// the forge's host alone and a node after a word's @ (database@novox/postgres) are findings.
//
// A module may declare its hardware model's word in `particulars`, as a line `model <module> <word>`: the word,
// one of the names, is then no finding in that module's directory and still one in every other
// (asus-zephyrus-g14's adopted paths such as /etc/modprobe.d/g14-nvidia-power.conf). A declaration naming no
// module or no listed name makes the list unreadable.
// A hardware's own name holding a name is declared as a line `hardware <word>` (asus-g14): that exact word, in
// any case and not part of a longer hyphened word, is no finding in any module.
//
// go run . <catalogue root>
//
// Exits 1 naming file:line and the word, 2 when a list cannot be read.
package main
import (
"bufio"
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
)
// forge is the software's module path on its forge and the name of its record (novox/hq, in any form: a citation,
// a review, a URL path), blanked before a line is read; any other novox/<x> (a repository in a fixture,
// testdata/novox/x) is not blanked, nor novox/hqx.
var forge = regexp.MustCompile(`(?i:git\.novox\.be/novox/[\w.-]+)|novox/hq(?:$|[^\w-])`)
// ownNames are the hardware model's and the software's own names, blanked before a line is read: the model
// in any separator and case (asus-zephyrus-g14, ASUS_ZEPHYRUS_G14, ROG Zephyrus G14), the software as two
// words or hyphened, whole (Novox Mesh, novox mesh, novox-mesh), so novox-mesh-vault stays a node name joined;
// and the software's npm scope (@novox/mesh-sdk, @novox:registry=), its package path as the Go module path is,
// where no word stands before the @, so a session's who@node (fix-1@novox) is still a node name.
var ownNames = regexp.MustCompile(`(?i)(?:asus[-_ ])?zephyrus[-_ ]g14|(?:^|[^\w-])novox[- ]mesh(?:$|[^\w-])|(?:^|[^\w.-])@novox(?:$|[^\w-])`)
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
os.Exit(check(root))
}
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
func check(root string) int {
entries, err := readList(filepath.Join(root, "particulars"))
names, models, hardware := splitModels(entries)
if err == nil && len(names) == 0 {
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
}
var modules []string
if err == nil {
modules, err = moduleDirs(root)
if err == nil && len(modules) == 0 {
err = fmt.Errorf("modules/ holds no module — the check would pass on anything")
}
}
if err == nil {
err = checkModels(models, names, modules)
}
var blanks *regexp.Regexp
if err == nil {
blanks, err = hardwareWords(hardware)
}
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
}
findings, err := run(root, names, modules, models, blanks)
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
}
for _, f := range findings {
fmt.Println(f)
}
if len(findings) > 0 {
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
return 1
}
fmt.Printf("particulars: none of %d node names in the %d modules\n", len(names), len(modules))
return 0
}
// readList reads a list: one entry a line; blank lines and lines starting with # are not entries.
func readList(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return out, sc.Err()
}
// splitModels parts the list's entries into the names, the models' words by module (`model <module> <word>`)
// and the hardware's words (`hardware <word>`, kept whole to be checked).
func splitModels(entries []string) (names []string, models map[string][]string, hardware []string) {
models = map[string][]string{}
for _, e := range entries {
f := strings.Fields(e)
if len(f) > 0 && f[0] == "hardware" {
hardware = append(hardware, e)
continue
}
if len(f) > 0 && f[0] == "model" {
if len(f) != 3 {
models[""] = append(models[""], e)
continue
}
models[f[1]] = append(models[f[1]], f[2])
continue
}
names = append(names, e)
}
return names, models, hardware
}
// hardwareWords is the matcher of the declared hardware words, nil when none is declared; a line that is not
// `hardware <word>` makes the list unreadable.
func hardwareWords(lines []string) (*regexp.Regexp, error) {
var words []string
for _, l := range lines {
f := strings.Fields(l)
if len(f) != 2 {
return nil, fmt.Errorf("particulars: %q is not `hardware <word>`", l)
}
words = append(words, regexp.QuoteMeta(f[1]))
}
if len(words) == 0 {
return nil, nil
}
return regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9-])(?:` + strings.Join(words, "|") + `)(?:$|[^A-Za-z0-9-])`), nil
}
// blankWith blanks what rx matches in s, keeping every other byte where it was.
func blankWith(rx *regexp.Regexp, s string) string {
if rx == nil {
return s
}
return rx.ReplaceAllStringFunc(s, func(m string) string { return strings.Repeat(" ", len(m)) })
}
// checkModels refuses a model declaration that is malformed, names no module or names a word that is no name.
func checkModels(models map[string][]string, names, modules []string) error {
for m, words := range models {
if m == "" {
return fmt.Errorf("particulars: %q is not `model <module> <word>`", words[0])
}
if !slices.Contains(modules, m) {
return fmt.Errorf("particulars: model %s %s names no module under modules/", m, words[0])
}
for _, w := range words {
if !slices.ContainsFunc(names, func(n string) bool { return strings.EqualFold(n, w) }) {
return fmt.Errorf("particulars: model %s %s: %q is not a listed name", m, w, w)
}
}
}
return nil
}
// without returns the names but the words, in any case.
func without(names, words []string) []string {
var out []string
for _, n := range names {
if !slices.ContainsFunc(words, func(w string) bool { return strings.EqualFold(n, w) }) {
out = append(out, n)
}
}
return out
}
// moduleDirs returns every directory under the catalogue's modules/, sorted.
func moduleDirs(root string) ([]string, error) {
entries, err := os.ReadDir(filepath.Join(root, "modules"))
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
// run reads every text file of each module named and returns the findings, sorted; a module's model words are
// not read as names in it.
func run(root string, all, modules []string, models map[string][]string, hardware *regexp.Regexp) ([]string, error) {
var out []string
for _, m := range modules {
names := without(all, models[m])
dir := filepath.Join(root, "modules", m)
err := filepath.WalkDir(dir, 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", ".git":
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(root, path)
// A path is read too: a testdata directory named after a node is a finding.
for _, n := range hits(blankWith(hardware, filepath.ToSlash(rel)), names) {
out = append(out, fmt.Sprintf("%s:0: node name %q in the path", filepath.ToSlash(rel), n))
}
if d.Name() == "go.sum" {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
if bytes.IndexByte(b, 0) >= 0 {
return nil
}
out = append(out, scan(filepath.ToSlash(rel), blankWith(hardware, string(b)), names)...)
return nil
})
if err != nil {
return nil, err
}
}
sort.Strings(out)
return out, nil
}
// scan returns a finding for each node name in src, whole-word and in any case.
func scan(file, src string, names []string) []string {
var out []string
for i, line := range strings.Split(src, "\n") {
for _, n := range hits(line, names) {
out = append(out, fmt.Sprintf("%s:%d: node name %q — %s", file, i+1, n, strings.Join(strings.Fields(line), " ")))
}
}
return out
}
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path and the hardware's and the
// software's own names blanked first.
func hits(line string, names []string) []string {
blank := func(s string) string { return strings.Repeat(" ", len(s)) }
line = forge.ReplaceAllStringFunc(line, blank)
line = ownNames.ReplaceAllStringFunc(line, blank)
var out []string
for _, n := range names {
if matcher(n).MatchString(line) {
out = append(out, strings.ToLower(n))
}
}
return out
}
// matchers holds each name's matcher, compiled once: the check reads every line of every module.
var matchers = map[string]*regexp.Regexp{}
// matcher is the matcher of one name: as a word, or as a part of a CamelCase identifier.
func matcher(n string) *regexp.Regexp {
if rx, ok := matchers[n]; ok {
return rx
}
q := regexp.QuoteMeta(n)
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
matchers[n] = rx
return rx
}