From dd45f313075da06897c938676f4589fc57192adf Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:06:25 +0200 Subject: [PATCH] Give hq a Go module and the checks' shared reading, so the four checks can become Go tests (issue 1296) --- 00-META/checks/walk_check_test.go | 39 ++++++++ 00-META/checks/walk_test.go | 151 ++++++++++++++++++++++++++++++ AGENTS.md | 6 +- go.mod | 3 + 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 00-META/checks/walk_check_test.go create mode 100644 00-META/checks/walk_test.go create mode 100644 go.mod diff --git a/00-META/checks/walk_check_test.go b/00-META/checks/walk_check_test.go new file mode 100644 index 00000000..09fd7368 --- /dev/null +++ b/00-META/checks/walk_check_test.go @@ -0,0 +1,39 @@ +package checks + +import ( + "reflect" + "testing" + "testing/fstest" +) + +func TestMarkdownFilesSkipsToolFolders(t *testing.T) { + fsys := fstest.MapFS{ + "a.md": {Data: []byte("x")}, + "b/c.md": {Data: []byte("x")}, + "b/c.txt": {Data: []byte("x")}, + ".git/d.md": {Data: []byte("x")}, + ".claude/e.md": {Data: []byte("x")}, + ".work/f/g.md": {Data: []byte("x")}, + } + got := markdownFiles(fsys) + want := []string{"a.md", "b/c.md"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("markdownFiles = %q, want %q", got, want) + } +} + +func TestFrontmatterReadsScalarsAndLists(t *testing.T) { + f := frontmatter("---\nlayer: to-be\ndecisions:\n - ADR 0001\n - ADR 0002\ncode: []\ncodes: [a, b]\n---\nbody\n") + if f.scalar("layer") != "to-be" { + t.Errorf("layer = %q", f.scalar("layer")) + } + if !reflect.DeepEqual(f.list("decisions"), []string{"ADR 0001", "ADR 0002"}) { + t.Errorf("decisions = %q", f.list("decisions")) + } + if len(f.list("code")) != 0 || !reflect.DeepEqual(f.list("codes"), []string{"a", "b"}) { + t.Errorf("code = %q, codes = %q", f.list("code"), f.list("codes")) + } + if frontmatter("no frontmatter") != nil || frontmatter("---\nunclosed") != nil { + t.Error("a document without a closed block has no frontmatter") + } +} diff --git a/00-META/checks/walk_test.go b/00-META/checks/walk_test.go new file mode 100644 index 00000000..af987431 --- /dev/null +++ b/00-META/checks/walk_test.go @@ -0,0 +1,151 @@ +// Package checks holds hq's four checks as Go tests (ADR 0527): records, index, cycle and words. +// +// The package has tests only. Each check is a function over an fs.FS that returns its problems, so +// the test of the real tree reads the repository's root, and the test of the check itself reads a +// deliberately broken tree built in memory and must see the problem named (every check here failed on +// a broken fixture before it passed). The shared reading of Markdown and frontmatter is in this file. +package checks + +import ( + "io/fs" + "os" + "regexp" + "sort" + "strings" + "testing" +) + +// rootFS is hq's tree: the checks run from 00-META/checks, two folders under the root. +func rootFS() fs.FS { return os.DirFS("../..") } + +// skipDirs are folders no check reads: git's, the agent tooling's and a session's scratch folder. +var skipDirs = map[string]bool{".git": true, ".claude": true, ".work": true} + +// markdownFiles lists every Markdown file of the tree, sorted, in slash paths from the root. +func markdownFiles(fsys fs.FS) []string { + var out []string + _ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if p != "." && skipDirs[d.Name()] { + return fs.SkipDir + } + return nil + } + if strings.HasSuffix(p, ".md") { + out = append(out, p) + } + return nil + }) + sort.Strings(out) + return out +} + +func readFile(fsys fs.FS, p string) string { + b, err := fs.ReadFile(fsys, p) + if err != nil { + return "" + } + return string(b) +} + +// Front is a document's frontmatter: a scalar field holds a string, a list field a []string. +type Front map[string]any + +func (f Front) scalar(key string) string { + s, _ := f[key].(string) + return s +} + +func (f Front) list(key string) []string { + switch v := f[key].(type) { + case []string: + return v + case string: + if strings.TrimSpace(v) != "" { + return []string{v} + } + } + return nil +} + +var ( + fmItem = regexp.MustCompile(`^\s+-\s*(.+?)\s*$`) + fmPair = regexp.MustCompile(`^([A-Za-z_-]+):\s*(.*)$`) +) + +// frontmatter reads the block between the first two --- lines. It is not a YAML parser on purpose: a +// dependency to read a few scalar fields and one list would cost more than it returns. It returns nil +// when the document has no frontmatter. +func frontmatter(text string) Front { + if !strings.HasPrefix(text, "---\n") { + return nil + } + end := strings.Index(text[4:], "\n---") + if end == -1 { + return nil + } + out := Front{} + key := "" + for _, line := range strings.Split(text[4:4+end], "\n") { + if m := fmItem.FindStringSubmatch(line); m != nil && key != "" { + list, _ := out[key].([]string) + out[key] = append(list, m[1]) + continue + } + m := fmPair.FindStringSubmatch(line) + if m == nil { + continue + } + key = m[1] + value := strings.TrimSpace(m[2]) + switch { + case strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]"): + list := []string{} + for _, v := range strings.Split(value[1:len(value)-1], ",") { + if v = strings.TrimSpace(v); v != "" { + list = append(list, v) + } + } + out[key] = list + case value == "": + out[key] = []string{} + default: + out[key] = value + } + } + return out +} + +func unquote(v string) string { + v = strings.TrimSpace(v) + if len(v) >= 2 && v[0] == v[len(v)-1] && (v[0] == '"' || v[0] == '\'') { + return v[1 : len(v)-1] + } + return v +} + +// lineOf is the 1-based line of byte offset i in text. +func lineOf(text string, i int) int { return strings.Count(text[:i], "\n") + 1 } + +// expectNone fails the test with every problem a check found in the real tree. +func expectNone(t *testing.T, problems []string) { + t.Helper() + for _, p := range problems { + t.Error(p) + } +} + +// expectProblem fails unless some problem contains want: the broken fixture must be caught, by name. +func expectProblem(t *testing.T, problems []string, want string) { + t.Helper() + for _, p := range problems { + if strings.Contains(p, want) { + return + } + } + t.Errorf("no problem mentions %q; got %q", want, problems) +} + diff --git a/AGENTS.md b/AGENTS.md index de63ee25..39b1580f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,8 +54,10 @@ in running prose; a quotation keeps its words. ## Ground rules -- **Markdown only** — but for the checks in `00-META/checks/` and `merge-check.sh` at the root, which - runs them on every pull request as its `mesh/repo-check` (ADR 0238), and +- **Markdown only** — but for the four checks, Go tests in the package `00-META/checks/` with `go.mod` at the + root and no other Go code (ADR 0527), which the checker runs on every pull request as `mesh/check`; + until they replace them, the Python checks beside them and `merge-check.sh` at the root keep running as + `mesh/repo-check` (ADR 0238); and `00-META/process/playbooks.html`, a drawn overview of the playbooks kept for a later docs site (the operator, 2026-10-19); a change to a playbook's steps updates it in the same pull request. Nothing checks that yet: issue 1222 asks for the check. diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..79ddeeee --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/novox/hq + +go 1.26.0