// Copyright 2026 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Spectasks renders SPEC-TRANSITION.md's task dependencies as a Graphviz // graph, and checks the document's copies of those dependencies against each // other. // // go run ./cmd/spectasks -w # write SPEC-TASKS.dot // go run ./cmd/spectasks # check only; write the graph to stdout // // SPEC-TRANSITION.md states its dependencies three times: once in Part 2's // table, which this tool treats as the source of truth; once per task in // Part 3's "Needs:"/"Prefers:" header lines; and a third time, reversed, in // Part 3's "Blocks:" lines. go test ./cmd/spectasks fails if they disagree, and // also fails if SPEC-TASKS.dot has not been regenerated since. Adding the graph // as a fourth hand-maintained copy would have made that worse, so the graph is // generated. // // Two things the graph shows that the table cannot. The critical path is // computed, not asserted: a task is on it when delaying it delays the whole // project, which is the set of tasks whose longest-chain-in plus // longest-chain-out spans the graph. And milestones -- the points worth // announcing -- are drawn as nodes, so it is visible which tasks feed them. // Milestone state is derived from the task checkboxes and is never written // down separately. package main import ( "bytes" "cmp" "flag" "fmt" "log" "os" "path/filepath" "regexp" "slices" "strings" "simd/archsimd/_gen/gentools" "simd/archsimd/_gen/specgen" ) // paths returns the plan document and the graph generated from it. func paths(goroot string) (md, dot string) { specDir := specgen.MustFindSpecDir(goroot) genDir := filepath.Join(filepath.Clean(filepath.Join(specDir, "..", "..")), "archsimd", "_gen") return filepath.Join(genDir, "SPEC-TRANSITION.md"), filepath.Join(genDir, "SPEC-TASKS.dot") } // load parses the plan document. func load(md string) *doc { data, err := os.ReadFile(md) if err != nil { log.Fatal(err) } return parse(string(data)) } func main() { log.SetFlags(0) log.SetPrefix("spectasks: ") genOpts := gentools.RegisterFlags(nil) flag.Parse() md, _ := paths(genOpts.GOROOT) d := load(md) // The test is where inconsistency is reported properly; this is only a // guard, so that a graph is never written from a document that disagrees // with itself. if probs := d.problems(); len(probs) > 0 { for _, s := range probs { fmt.Fprintf(os.Stderr, "spectasks: %s\n", s) } log.Fatalf("%s disagrees with itself; go test ./cmd/spectasks reports this properly", filepath.Base(md)) } var files gentools.Files defer files.FlushOrExit() buf := files.NewRawFile("simd/archsimd/_gen/SPEC-TASKS.dot") d.dot(buf) } // A dep is one edge: the id of the task depended on. type dep struct { id string } type task struct { id string done bool track string title string needs, prefers []dep critical bool // on some longest chain through the graph } type track struct { letter, name string tasks []string } type milestone struct { name string needs []dep } type doc struct { tasks map[string]*task order []string // task ids, in table order, which must be topological tracks []track milestones []milestone // part4 is what Part 4's header lines claim, for cross-checking. part4 map[string]struct{ needs, prefers, blocks []dep } } // --- parsing --------------------------------------------------------------- // A markdown table, as a header row and body rows of trimmed cells. type mdTable struct { head []string rows [][]string } // cells splits one "| a | b |" line. Leading and trailing empties from the // bounding pipes are dropped. func cells(line string) []string { s := strings.TrimSpace(line) if !strings.HasPrefix(s, "|") || !strings.HasSuffix(s, "|") { return nil } parts := strings.Split(s[1:len(s)-1], "|") for i, p := range parts { parts[i] = strings.TrimSpace(p) } return parts } // findTable returns the first table in lines whose header row equals head. // The header must match exactly: this is a format we control, and a silent // mismatch would silently drop rows. func findTable(lines []string, head ...string) *mdTable { for i, line := range lines { got := cells(line) if !slices.Equal(got, head) { continue } if i+1 >= len(lines) || !strings.HasPrefix(strings.TrimSpace(lines[i+1]), "|-") { log.Fatalf("table %q at line %d has no separator row", head, i+1) } t := &mdTable{head: got} for _, row := range lines[i+2:] { c := cells(row) if c == nil { break } if len(c) != len(head) { log.Fatalf("line %d: got %d cells, want %d", i+1, len(c), len(head)) } t.rows = append(t.rows, c) } return t } log.Fatalf("no table with header %q", head) return nil } var ( // A dependency list entry: a task id. depRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) // A Part 4 task heading: "### `spec-common` — Spec the common operations". headingRE = regexp.MustCompile("^### `([a-z0-9]+(?:-[a-z0-9]+)*)` — ") // A Part 4 header field: "**Needs:** 3, 5". fieldRE = regexp.MustCompile(`\*\*(Needs|Prefers|Blocks):\*\* ([^·]*)`) // Markdown emphasis and code spans, for extracting plain text. markupRE = regexp.MustCompile("[`*]") ) // parseDeps parses a dependency cell: "—" for none, else a comma-separated // list of dep entries. func parseDeps(s string) []dep { s = strings.TrimSpace(markupRE.ReplaceAllString(s, "")) if s == "" || s == "—" || s == "-" { return nil } var out []dep for _, f := range strings.Split(s, ",") { f = strings.TrimSpace(f) if !depRE.MatchString(f) { log.Fatalf("cannot parse dependency %q in %q; want a task id", f, s) } out = append(out, dep{f}) } // Sorted so that the three copies of the edge set compare equal // regardless of the order each happens to list them in. slices.SortFunc(out, func(a, b dep) int { return cmp.Compare(a.id, b.id) }) return out } func parse(text string) *doc { lines := strings.Split(text, "\n") d := &doc{ tasks: make(map[string]*task), part4: make(map[string]struct{ needs, prefers, blocks []dep }), } for _, r := range findTable(lines, "", "Track", "What it is", "Tasks").rows { var tasks []string for _, dep := range parseDeps(r[3]) { tasks = append(tasks, dep.id) } d.tracks = append(d.tracks, track{ letter: markupRE.ReplaceAllString(r[0], ""), name: r[1], tasks: tasks, }) } for _, r := range findTable(lines, "✓", "T", "Task", "What it is", "Needs", "Prefers").rows { id := markupRE.ReplaceAllString(r[2], "") if !depRE.MatchString(id) { log.Fatalf("bad task id %q; want lowercase words joined by hyphens", r[2]) } if d.tasks[id] != nil { log.Fatalf("task %q listed twice", id) } d.tasks[id] = &task{ id: id, done: strings.Contains(r[0], "x"), track: r[1], title: markupRE.ReplaceAllString(r[3], ""), needs: parseDeps(r[4]), prefers: parseDeps(r[5]), } d.order = append(d.order, id) } for _, r := range findTable(lines, "Milestone", "Reached after", "What it means").rows { d.milestones = append(d.milestones, milestone{ name: markupRE.ReplaceAllString(r[0], ""), needs: parseDeps(r[1]), }) } // Part 4 header lines, for cross-checking. cur := "" for _, line := range lines { if m := headingRE.FindStringSubmatch(line); m != nil { cur = m[1] continue } if cur == "" || !strings.HasPrefix(line, "**Done:** [") { continue } var e struct{ needs, prefers, blocks []dep } for _, m := range fieldRE.FindAllStringSubmatch(line, -1) { switch m[1] { case "Needs": e.needs = parseDeps(m[2]) case "Prefers": e.prefers = parseDeps(m[2]) case "Blocks": e.blocks = parseDeps(m[2]) } } d.part4[cur] = e cur = "" } return d } // --- checking -------------------------------------------------------------- func ids(ds []dep) []string { out := make([]string, len(ds)) for i, d := range ds { out[i] = d.id } return out } func fmtIDs(xs []string) string { if len(xs) == 0 { return "—" } return strings.Join(xs, ", ") } // problems returns everything inconsistent about the document, as one message // per problem, in a stable order. It reports rather than fails, so that the // test can attribute each one and the tool can use it as a guard. func (d *doc) problems() []string { var probs []string fail := func(format string, args ...any) { probs = append(probs, fmt.Sprintf(format, args...)) } // Every task belongs to exactly one track, and the two lists agree. seen := make(map[string]string) for _, tr := range d.tracks { for _, id := range tr.tasks { if prev, ok := seen[id]; ok { fail("task %s is in tracks %s and %s", id, prev, tr.letter) } seen[id] = tr.letter if t := d.tasks[id]; t == nil { fail("track %s lists task %s, which the dependency table does not have", tr.letter, id) } else if t.track != tr.letter { fail("task %s is in track %s per the track table, %s per the dependency table", id, tr.letter, t.track) } } } // The table is in topological order, so that reading it top to bottom is // a legal order to do the work in. This is what task ids buy: identity no // longer encodes position, so rows can be moved and inserted freely. pos := make(map[string]int, len(d.order)) for i, id := range d.order { pos[id] = i } // Edges point at real tasks; Part 4 agrees with the table; Blocks is the // exact reverse of Needs. blocks := make(map[string][]string) for _, id := range d.order { t := d.tasks[id] if seen[id] == "" { fail("task %s is in no track", id) } for _, e := range slices.Concat(t.needs, t.prefers) { if d.tasks[e.id] == nil { fail("task %s depends on task %s, which does not exist", id, e.id) } } for _, e := range t.needs { if d.tasks[e.id] != nil && pos[e.id] > pos[id] { fail("task %s needs %s but comes before it; the table must be in topological order", id, e.id) } blocks[e.id] = append(blocks[e.id], id) } for _, e := range t.prefers { if d.tasks[e.id] != nil && pos[e.id] > pos[id] { fail("task %s prefers %s but comes before it; the table must be in topological order", id, e.id) } } p4, ok := d.part4[id] if !ok { fail("task %s has no Part 4 entry", id) continue } if !slices.Equal(ids(t.needs), ids(p4.needs)) { fail("task %s Needs: table says %s, Part 4 says %s", id, fmtIDs(ids(t.needs)), fmtIDs(ids(p4.needs))) } if !slices.Equal(ids(t.prefers), ids(p4.prefers)) { fail("task %s Prefers: table says %s, Part 4 says %s", id, fmtIDs(ids(t.prefers)), fmtIDs(ids(p4.prefers))) } } for _, id := range d.order { want := blocks[id] slices.Sort(want) if got := ids(d.part4[id].blocks); !slices.Equal(got, want) { fail("task %s Blocks: header says %s, reversing Needs gives %s", id, fmtIDs(got), fmtIDs(want)) } } // Unrelated tasks sort by track: if task A (track X) precedes task B // (track Y) and X > Y, then B must transitively depend on A through // Needs or Prefers edges. tdeps := make(map[string]map[string]bool, len(d.order)) var computeDeps func(string) map[string]bool computeDeps = func(id string) map[string]bool { if r, ok := tdeps[id]; ok { return r } r := make(map[string]bool) tdeps[id] = r for _, e := range slices.Concat(d.tasks[id].needs, d.tasks[id].prefers) { if d.tasks[e.id] != nil { r[e.id] = true for dep := range computeDeps(e.id) { r[dep] = true } } } return r } for _, id := range d.order { computeDeps(id) } for i, id := range d.order { for _, id2 := range d.order[i+1:] { if d.tasks[id].track > d.tasks[id2].track && !tdeps[id2][id] { fail("task %s (track %s) precedes %s (track %s) but %s does not depend on %s; unrelated tasks sort by track", id, d.tasks[id].track, id2, d.tasks[id2].track, id2, id) } } } for _, m := range d.milestones { for _, e := range m.needs { if d.tasks[e.id] == nil { fail("milestone %q depends on task %s, which does not exist", m.name, e.id) } } } return probs } // mark sets task.critical on every task that lies on some longest chain of // hard dependencies: delaying it delays the whole project. Ties are kept -- // there is usually more than one longest chain, and picking one of them // arbitrarily is how a hand-written "critical path" understates the work. func (d *doc) mark() { var into, outOf func(string) int memo := func(f func(string) int) func(string) int { m := make(map[string]int) return func(id string) int { if v, ok := m[id]; ok { return v } m[id] = 1 // cycles fall back to 1 rather than recursing forever v := f(id) m[id] = v return v } } // Longest chain ending at id, counted in tasks. into = memo(func(id string) int { best := 0 for _, e := range d.tasks[id].needs { if d.tasks[e.id] != nil { best = max(best, into(e.id)) } } return best + 1 }) // Longest chain starting at id. outOf = memo(func(id string) int { best := 0 for _, other := range d.order { for _, e := range d.tasks[other].needs { if e.id == id { best = max(best, outOf(other)) } } } return best + 1 }) span := 0 for _, id := range d.order { span = max(span, into(id)) } for _, id := range d.order { d.tasks[id].critical = into(id)+outOf(id)-1 == span } } // reached reports whether every task feeding m is ticked. func (d *doc) reached(m milestone) bool { for _, e := range m.needs { if !d.tasks[e.id].done { return false } } return len(m.needs) > 0 } // --- rendering ------------------------------------------------------------- // Node fills, in track order. Light enough to read black text on. var trackFill = []string{ "#dce9f7", "#d9f2e2", "#fbe6cd", "#ebdcf7", "#f7dce3", "#d8f0f0", "#e8e8e8", } // wrap breaks a title into Graphviz label lines of at most width runes, // without breaking words. func wrap(s string, width int) string { var lines []string line := "" for _, w := range strings.Fields(s) { switch { case line == "": line = w case len([]rune(line))+1+len([]rune(w)) <= width: line += " " + w default: lines = append(lines, line) line = w } } return strings.Join(append(lines, line), `\n`) } // node is a task id as a Graphviz node name. Ids contain hyphens, which are // not legal in a bare Graphviz ID, so they are always quoted. func node(id string) string { return quote(id) } func quote(s string) string { return `"` + strings.NewReplacer(`"`, `\"`, "\n", `\n`).Replace(s) + `"` } func (d *doc) dot(w *bytes.Buffer) { d.mark() p := func(format string, args ...any) { fmt.Fprintf(w, format+"\n", args...) } p(`// Task graph for SPEC-TRANSITION.md. Do not edit.`) p(`// Generated by archsimd/_gen/cmd/spectasks; regenerate with:`) p(`// go run ./cmd/spectasks -w`) p(`// Render with, e.g.:`) p(`// dot -Tsvg SPEC-TASKS.dot -o SPEC-TASKS.svg`) p(`//`) p(`// The key in the rendered graph explains the notation.`) p(``) p(`digraph spectransition {`) p(` rankdir=LR;`) p(` newrank=true;`) p(` ranksep=0.55; nodesep=0.22;`) p(` fontname="Helvetica"; fontsize=11;`) p(` node [shape=box, style="rounded,filled", fillcolor=white,`) p(` fontname="Helvetica", fontsize=10, margin="0.10,0.06"];`) p(` edge [color="#666666", arrowsize=0.7];`) p(``) // Tracks are shown as node colour, not as clusters: a cluster has to be // laid out contiguously, which fights the topological ranking and drags // long edges across the whole graph. Colour costs a legend and keeps the // left-to-right reading of dependency order. emit := func(t *task, fill string) { attrs := []string{ "label=" + quote(t.id+`\n`+wrap(t.title, 22)), "fillcolor=" + quote(fill), `color="#999999"`, } if t.done { attrs = append(attrs, `fillcolor="#d0d0d0"`, `fontcolor="#777777"`) } p(` %s [%s];`, node(t.id), strings.Join(attrs, ", ")) } // Emitted track by track: declaration order is one of the things dot breaks // ties on, and grouping by track lays out better than topological order // does. Any task no track claims is emitted afterwards rather than dropped, // so a document that problems() would reject still renders. seen := make(map[string]bool, len(d.order)) for i, tr := range d.tracks { for _, id := range tr.tasks { if t := d.tasks[id]; t != nil && !seen[id] { seen[id] = true emit(t, trackFill[i%len(trackFill)]) } } } for _, id := range d.order { if !seen[id] { emit(d.tasks[id], "white") } } p(``) for _, id := range d.order { t := d.tasks[id] for _, e := range t.needs { var attrs []string if t.critical && d.tasks[e.id].critical { attrs = append(attrs, `color="#c0392b"`, `penwidth=1.8`) } p(` %s -> %s%s;`, node(e.id), node(id), attrList(attrs)) } for _, e := range t.prefers { p(` %s -> %s [style=dotted, color="#aaaaaa", arrowsize=0.5];`, node(e.id), node(id)) } } p(``) for i, m := range d.milestones { fill := "#fff8dc" if d.reached(m) { fill = "#d7d7d7" } p(` m%d [label=%s, shape=box, style="filled,bold", fillcolor=%s, color="#8a6d3b"];`, i, quote(wrap(m.name, 22)), quote(fill)) for _, e := range m.needs { p(` %s -> m%d [color="#8a6d3b", style=bold, arrowsize=0.7];`, node(e.id), i) } } // The key is a single node with an HTML label rather than a cluster of // nodes: a cluster chained together spans as many ranks as it has entries, // and dot then aligns those ranks with the graph proper, which distorts the // layout it is supposed to annotate. p(``) p(` key [shape=plaintext, style="", label=<`) p(`
| Tracks | |
| %s | |
| Reading the graph | |