Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/design.go
T
jschoubben 7c0ce2fe21
mesh/delivery merged: source.main moved to novox/mesh-catalog@980ade933853
mesh/check being carried to 7c0ce2fe218b on 980ade933853 | from 1da83c29ddf9
Progress: an unread design is not cached and is warned of at every level; head is capped (issue 1947)
2026-11-07 00:25:02 +01:00

201 lines
6.0 KiB
Go

package main
// A design as the progress verb reads it (novox/hq issue 1947): its title, its state as its frontmatter says it and
// the decisions it names, read through the records module until ADR 0423 makes designs records of the knowledge base
// (issue 428). Its milestones are the issue tracker's records (ADR 0493 §1, issue 1781), read in milestones.go: the
// table parse that guessed them from prose moved to the tracker's seed and is gone from here.
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// DesignsFolder is where the designs being built are, in hq.
const DesignsFolder = "03-DESIGN/01-to-be"
// Design is one design as read.
type Design struct {
Number int
Path string
Title string
Status string
Decisions []string
Milestones []Milestone
// Unread says why the design's frontmatter and title could not be read, when they could not (novox/hq issue
// 1947); the progress answer carries it as a warning beside a tree built from the tracker's milestone records.
Unread string
}
// unreadWarnings says each design whose head was not read, so none drops out of an answer silently.
func unreadWarnings(ds []*Design) []string {
var out []string
for _, d := range ds {
if d.Unread != "" {
out = append(out, d.Unread)
}
}
return out
}
// DesignHeadBytes is how much of a design is read: its frontmatter and its title lead it, and its milestones are the
// tracker's records, so the rest is never needed.
const DesignHeadBytes = 16 << 10
// Milestone is one milestone of a design, as the issue tracker keeps it.
type Milestone struct {
ID string
Outcome string
Issues []int
// Status is open or closed, with its Reason; a closed milestone holding no issue is done.
Status string
Reason string
// Moved is who last moved it and when, as the tracker says it; nil before its first move.
Moved *stamp
}
var (
designFile = regexp.MustCompile(`^([0-9]{2,3})-[a-z0-9-]+\.md$`)
titleLine = regexp.MustCompile(`^#\s+[0-9]+\s+[—-]\s+(.+)$`)
decisionItem = regexp.MustCompile(`ADR ([0-9]{4})`)
)
// parseDesign reads a design document.
func parseDesign(path, content string) *Design {
d := &Design{Path: path}
if m := designFile.FindStringSubmatch(lastPart(path)); m != nil {
d.Number, _ = strconv.Atoi(m[1])
}
lines := strings.Split(content, "\n")
body, closed := 0, false
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
for i := 1; i < len(lines); i++ {
l := lines[i]
if strings.TrimSpace(l) == "---" {
body = i + 1
closed = true
break
}
if v, ok := strings.CutPrefix(l, "status:"); ok {
d.Status = strings.TrimSpace(v)
}
for _, m := range decisionItem.FindAllStringSubmatch(l, -1) {
d.Decisions = append(d.Decisions, "ADR "+m[1])
}
}
}
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" && !closed {
d.Unread = fmt.Sprintf("the frontmatter of %s does not close in the part read: its state and decisions are not known here", path)
}
for i := body; i < len(lines) && d.Title == ""; i++ {
if m := titleLine.FindStringSubmatch(strings.TrimSpace(lines[i])); m != nil {
d.Title = strings.TrimSpace(m[1])
}
}
return d
}
func lastPart(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[i+1:]
}
return p
}
// milestone finds a milestone by its id, without regard to case, or to spaces against hyphens.
func (d *Design) milestone(id string) *Milestone {
// The tracker keeps an id's words joined by hyphens (3 fix is 3-fix); either is taken.
want := strings.ToLower(strings.Join(strings.Fields(id), "-"))
for i := range d.Milestones {
if strings.ToLower(strings.Join(strings.Fields(d.Milestones[i].ID), "-")) == want {
return &d.Milestones[i]
}
}
return nil
}
// issues is every issue its milestones name, each once, in order.
func (d *Design) issues() []int {
var out []int
have := map[int]bool{}
for _, m := range d.Milestones {
for _, n := range m.Issues {
if !have[n] {
have[n] = true
out = append(out, n)
}
}
}
return out
}
// designsCache is the designs read at one commit of hq: read again only when the records module's checkout moves.
type designsCache struct {
commit string
paths []string
byPath map[string]*Design
}
// designs answers the designs being built, read at the commit the records module stands at, from the cache while
// that commit stands (novox/hq issue 1947, 1947/AC6).
func (k *KB) designs(all bool, number int) ([]*Design, string, error) {
if k.Records == nil {
return nil, "", fmt.Errorf("the knowledge base reads no records here")
}
commit, err := k.Records.Standing()
if err != nil {
return nil, "", fmt.Errorf("the records module does not say where hq stands: %v", err)
}
k.progMu.Lock()
defer k.progMu.Unlock()
c := k.progCache
if c == nil || c.commit != commit {
c = &designsCache{commit: commit, byPath: map[string]*Design{}}
_, docs, err := k.Records.List(DesignsFolder)
if err != nil {
return nil, "", fmt.Errorf("the records module does not list %s: %v", DesignsFolder, err)
}
for _, doc := range docs {
if designFile.MatchString(doc) {
c.paths = append(c.paths, DesignsFolder+"/"+doc)
}
}
sort.Strings(c.paths)
k.progCache = c
}
var out []*Design
for _, p := range c.paths {
n, _ := strconv.Atoi(designFile.FindStringSubmatch(lastPart(p))[1])
if !all && n != number {
continue
}
d := c.byPath[p]
if d == nil {
var content string
var err error
if h, ok := k.Records.(HeadReader); ok {
content, _, err = h.ReadHead(p, DesignHeadBytes)
} else {
content, _, err = k.Records.Read(p)
}
if err != nil {
d = parseDesign(p, "")
d.Unread = fmt.Sprintf("the records module does not read %s (%v): its title and state are not known here", p, err)
} else {
d = parseDesign(p, content)
}
if d.Unread == "" {
c.byPath[p] = d
}
k.progReads++
}
out = append(out, d)
}
if !all && len(out) == 0 {
return nil, "", fmt.Errorf("no design %d is in %s at hq %s", number, DesignsFolder, short(commit))
}
return out, commit, nil
}