| // 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(` <TABLE BORDER="0" CELLBORDER="0" CELLSPACING="1" CELLPADDING="3" BGCOLOR="#fafafa">`) |
| p(` <TR><TD ALIGN="LEFT" COLSPAN="2"><B>Tracks</B></TD></TR>`) |
| for i, tr := range d.tracks { |
| p(` <TR><TD BGCOLOR="%s" WIDTH="18"> </TD><TD ALIGN="LEFT">%s</TD></TR>`, |
| trackFill[i%len(trackFill)], html(tr.letter+" — "+tr.name)) |
| } |
| p(` <TR><TD COLSPAN="2"><FONT POINT-SIZE="5"> </FONT></TD></TR>`) |
| p(` <TR><TD ALIGN="LEFT" COLSPAN="2"><B>Reading the graph</B></TD></TR>`) |
| // Each row shows the thing it names, rather than describing it: an arrow in |
| // the edge's own colour, a swatch in the node's own fill. |
| arrow := func(color, glyph string) string { |
| return fmt.Sprintf(`<FONT COLOR="%s" POINT-SIZE="14">%s</FONT>`, color, glyph) |
| } |
| swatch := func(fill, border string) string { |
| return fmt.Sprintf(`<TABLE BORDER="1" CELLBORDER="0" CELLSPACING="0" CELLPADDING="0" `+ |
| `COLOR="%s" BGCOLOR="%s"><TR><TD WIDTH="16" HEIGHT="9"> </TD></TR></TABLE>`, border, fill) |
| } |
| for _, row := range [][2]string{ |
| {arrow("#666666", "→"), "must be done first"}, |
| {arrow("#aaaaaa", "⇢"), "better done first, but not required"}, |
| {arrow("#c0392b", "→"), "on a longest chain: slipping it slips the project"}, |
| {swatch("#fff8dc", "#8a6d3b"), "milestone: reached, not worked on"}, |
| {swatch("#d0d0d0", "#999999"), "done"}, |
| } { |
| p(` <TR><TD ALIGN="RIGHT">%s</TD><TD ALIGN="LEFT">%s</TD></TR>`, row[0], html(row[1])) |
| } |
| p(` </TABLE>`) |
| p(` >];`) |
| p(`}`) |
| } |
| |
| // html escapes text for a Graphviz HTML-like label. |
| func html(s string) string { |
| return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s) |
| } |
| |
| func attrList(attrs []string) string { |
| if len(attrs) == 0 { |
| return "" |
| } |
| return " [" + strings.Join(attrs, ", ") + "]" |
| } |