151 lines
3.9 KiB
Go
151 lines
3.9 KiB
Go
// 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)
|
|
}
|