mesh-issues: import hq's issue folders at their own numbers, and an issue with a folder is whole in the tracker (hq ADR 0275, to-be 53 slice 3)
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered

This commit is contained in:
jochen
2026-10-10 16:00:19 +02:00
parent 1fcf46fbfc
commit d05308a419
19 changed files with 2243 additions and 49 deletions
@@ -0,0 +1,339 @@
package main
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
)
// An issue report in hq, `04-ISSUES/NNN-slug/00-report.md`, as the import of slice 3 reads it (novox/hq
// to-be 53 §8): a YAML frontmatter of a few keys, a heading that is the title, and the report. The
// frontmatter is read as hq's own checks read it (00-META/checks/cycle.py), a little more carefully: a
// list in brackets may run over lines and hold commas inside parentheses or quotes, a block list's items
// may be quoted, and a long value may wrap onto an indented line.
// Report is one issue report as read from its folder.
type Report struct {
Number uint64
// Folder is the issue's folder, `04-ISSUES/NNN-slug`.
Folder string
// Path is the report, `<Folder>/00-report.md`.
Path string
Title string
Status string
// Opened and Closed are dates as the frontmatter gives them, YYYY-MM-DD, or empty.
Opened string
Closed string
LocatedIn []string
FixedBy []string
AmendedDesign []string
Replay string
ReplayNone string
// Body is the report without its frontmatter and its title.
Body string
// Issues and Decisions are the other issues and the decision records the report names.
Issues []uint64
Decisions []string
// Odd is what was odd about the report and did not stop it from being read.
Odd []string
}
var (
issueFolder = regexp.MustCompile(`^([0-9]{1,4})-([a-z0-9]+(?:-[a-z0-9]+)*)$`)
frontKey = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9-]*):(.*)$`)
blockItem = regexp.MustCompile(`^\s+-\s*(.*)$`)
continued = regexp.MustCompile(`^\s+(\S.*)$`)
heading = regexp.MustCompile(`(?m)^#\s+(.+?)\s*$`)
numbered = regexp.MustCompile(`^([0-9]{1,4})\s*([.:]|—|–|-)?\s*(.*)$`)
dateShape = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`)
namesIssue = regexp.MustCompile(`(?i)\bissue ([0-9]{1,4})\b|04-ISSUES/([0-9]{1,4})-`)
namesRecord = regexp.MustCompile(`\bADR ([0-9]{4})\b|02-DECISIONS/([0-9]{4})-`)
)
// folderNumber is the number an issue folder's name carries, or false for a name that is not one.
func folderNumber(name string) (uint64, bool) {
m := issueFolder.FindStringSubmatch(name)
if m == nil || len(m[2]) > 120 {
return 0, false
}
n, err := strconv.ParseUint(m[1], 10, 64)
if err != nil || n == 0 {
return 0, false
}
return n, true
}
// frontmatter is the YAML block between the first two `---` lines as {key: values}, and the text after
// it. A scalar is one value; a list, in brackets or as block items, its items; an empty value no values.
// A key given twice keeps the last. False when the text has no frontmatter.
func frontmatter(text string) (map[string][]string, string, bool) {
text = strings.ReplaceAll(text, "\r\n", "\n")
if !strings.HasPrefix(text, "---\n") {
return nil, "", false
}
rest := text[4:]
end := -1
if strings.HasPrefix(rest, "---\n") || rest == "---" {
end = 0
} else if i := strings.Index(rest, "\n---\n"); i >= 0 {
end = i + 1
} else if strings.HasSuffix(rest, "\n---") {
end = len(rest) - 3
}
if end < 0 {
return nil, "", false
}
block := rest[:end]
body := strings.TrimPrefix(rest[end:], "---")
body = strings.TrimPrefix(body, "\n")
out := map[string][]string{}
key := ""
const (
none = iota
scalar
list
flow
)
mode := none
flowText := ""
closeFlow := func() {
out[key] = splitFlow(flowText)
mode = list
}
for _, line := range strings.Split(block, "\n") {
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
if mode == flow {
flowText += " " + strings.TrimSpace(line)
if flowClosed(flowText) {
closeFlow()
}
continue
}
if m := frontKey.FindStringSubmatch(line); m != nil && !strings.HasPrefix(line, " ") {
key = m[1]
value := strings.TrimSpace(m[2])
switch {
case value == "":
out[key] = nil
mode = list
case strings.HasPrefix(value, "["):
flowText = value
mode = flow
if flowClosed(flowText) {
closeFlow()
}
default:
out[key] = []string{unquote(value)}
mode = scalar
}
continue
}
if key == "" {
continue
}
if m := blockItem.FindStringSubmatch(line); m != nil && mode == list {
out[key] = append(out[key], unquote(strings.TrimSpace(m[1])))
continue
}
if m := continued.FindStringSubmatch(line); m != nil && len(out[key]) > 0 {
last := len(out[key]) - 1
out[key][last] = strings.TrimSpace(out[key][last] + " " + strings.TrimSpace(m[1]))
}
}
if mode == flow {
// Never closed: what was read of it, without the bracket.
out[key] = splitFlow(flowText + "]")
}
return out, body, true
}
// flowClosed says whether a bracketed list has its closing bracket, outside quotes. A quote opens an item
// only at the item's start: an apostrophe inside prose ("the provisioner's ACL") is no quote.
func flowClosed(s string) bool {
depth, quote, atStart := 0, rune(0), true
for _, r := range s {
switch {
case quote != 0:
if r == quote {
quote = 0
}
continue
case (r == '"' || r == '\'') && atStart:
quote = r
case r == '[':
depth++
case r == ']':
depth--
if depth == 0 {
return true
}
}
atStart = r == '[' || r == ',' || atStart && unicode.IsSpace(r)
}
return false
}
// splitFlow is a bracketed list's items: split on the commas outside parentheses, brackets and quotes,
// a quote opening an item only at its start.
func splitFlow(s string) []string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
var items []string
var cur strings.Builder
depth, quote, atStart := 0, rune(0), true
flush := func() {
if v := unquote(strings.TrimSpace(cur.String())); v != "" {
items = append(items, v)
}
cur.Reset()
}
for _, r := range s {
switch {
case quote != 0:
if r == quote {
quote = 0
}
case (r == '"' || r == '\'') && atStart:
quote = r
case r == '(' || r == '[':
depth++
case r == ')' || r == ']':
if depth > 0 {
depth--
}
case r == ',' && depth == 0:
flush()
atStart = true
continue
}
cur.WriteRune(r)
atStart = atStart && unicode.IsSpace(r)
}
flush()
return items
}
// unquote strips one pair of matching quotes around a value, and the escapes a double-quoted YAML
// scalar uses for its own quote.
func unquote(v string) string {
if len(v) >= 2 && (v[0] == '"' && v[len(v)-1] == '"' || v[0] == '\'' && v[len(v)-1] == '\'') {
inner := v[1 : len(v)-1]
if v[0] == '"' {
inner = strings.ReplaceAll(inner, `\"`, `"`)
} else {
inner = strings.ReplaceAll(inner, `''`, `'`)
}
return strings.TrimSpace(inner)
}
return v
}
// ParseReport reads one issue report. The folder is its name, `NNN-slug`; the text is the report whole.
// What makes a report unreadable is an error: no frontmatter, no title, no status, or a status that
// is not one of the five. What is odd but readable is said in Odd.
func ParseReport(folder, text string) (*Report, error) {
n, ok := folderNumber(folder)
if !ok {
return nil, fmt.Errorf("%q is not an issue folder's name: NNN-slug, lower-case words joined by hyphens", folder)
}
front, body, ok := frontmatter(text)
if !ok {
return nil, fmt.Errorf("%s/00-report.md has no frontmatter", folder)
}
r := &Report{Number: n, Folder: "04-ISSUES/" + folder, Path: "04-ISSUES/" + folder + "/00-report.md"}
one := func(key string) string {
if v := front[key]; len(v) > 0 {
if len(v) > 1 {
r.Odd = append(r.Odd, fmt.Sprintf("%s: is a list of %d; one value was expected, and they were joined", key, len(v)))
return strings.Join(v, "; ")
}
return v[0]
}
return ""
}
r.Status = one("status")
if r.Status == "" {
return nil, fmt.Errorf("%s/00-report.md has no status", folder)
}
if !known(r.Status) {
return nil, fmt.Errorf("%s/00-report.md has the status %q, not one of %s", folder, r.Status, strings.Join(Statuses, ", "))
}
r.Opened = one("opened")
if r.Opened != "" && !dateShape.MatchString(r.Opened) {
r.Odd = append(r.Odd, fmt.Sprintf("opened: %q is not a date", r.Opened))
r.Opened = ""
}
for _, key := range []string{"resolved", "closed"} {
if v := one(key); v != "" {
if dateShape.MatchString(v) {
r.Closed = v
} else {
r.Odd = append(r.Odd, fmt.Sprintf("%s: %q is not a date", key, v))
}
}
}
r.LocatedIn = front["located-in"]
r.FixedBy = front["fixed-by"]
r.AmendedDesign = front["amended-design"]
r.Replay = one("replay")
r.ReplayNone = one("replay-none")
imported := map[string]bool{"status": true, "opened": true, "resolved": true, "closed": true, "located-in": true,
"fixed-by": true, "amended-design": true, "replay": true, "replay-none": true}
var unknown []string
for k := range front {
if !imported[k] {
unknown = append(unknown, k)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
r.Odd = append(r.Odd, "frontmatter keys not imported: "+strings.Join(unknown, ", "))
}
m := heading.FindStringSubmatchIndex(body)
if m == nil {
return nil, fmt.Errorf("%s/00-report.md has no title: no `# ` heading", folder)
}
title := body[m[2]:m[3]]
// "362. The title", "043 — The title": the number and its separator are the folder's, not the title's. A
// title that starts with a bare number ("2 nodes …") keeps it, unless the number is the folder's.
if nm := numbered.FindStringSubmatch(title); nm != nil {
heads, _ := strconv.ParseUint(nm[1], 10, 64)
if nm[2] != "" || heads == n {
title = nm[3]
if heads != n && title != "" {
r.Odd = append(r.Odd, fmt.Sprintf("the title is numbered %s; the folder is %d", nm[1], n))
}
}
}
r.Title = strings.TrimSpace(title)
if r.Title == "" {
return nil, fmt.Errorf("%s/00-report.md has an empty title", folder)
}
r.Body = strings.TrimSpace(body[:m[0]] + body[m[1]:])
seen := map[uint64]bool{n: true}
for _, mm := range namesIssue.FindAllStringSubmatch(text, -1) {
if other, err := strconv.ParseUint(mm[1]+mm[2], 10, 64); err == nil && !seen[other] {
seen[other] = true
r.Issues = append(r.Issues, other)
}
}
sort.Slice(r.Issues, func(i, j int) bool { return r.Issues[i] < r.Issues[j] })
records := map[string]bool{}
for _, mm := range namesRecord.FindAllStringSubmatch(text, -1) {
if d := mm[1] + mm[2]; !records[d] {
records[d] = true
r.Decisions = append(r.Decisions, d)
}
}
sort.Strings(r.Decisions)
return r, nil
}