code.pablomurad.com

patchwork-turbo

Feeds patchwork with web pages, sites, other feeds, data files, documents, Go APIs and dependencies, written in Go

Track Go APIs and dependencies

- gocode: one page per package of a Go module with every exported
  declaration, its doc comment and no function bodies. Files are chosen
  for linux/amd64 so every machine writes the same pages; main packages,
  tests and nested modules are skipped. The note leads with what can
  break importers: "Breaking: changed func New, removed func Old.
  Changed: type Thing. Added: type Option".
- deps: go.mod (with replace lines) and package.json dependencies, one
  per line; the note names what was updated, added and removed.
- summary: the capped "a, b and 3 more" phrases shared by the notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
author
Pablo Murad
date
commit
9e470ae984a919b681a4fd68461e115fc5a07193
parent
9e6502c600d7431211c32f85aeac4f43c53695be
changes
25 files +825 −25
M go.mod go.mod
@@ -8,3 +8,5 @@ golang.org/x/net v0.61.0 	golang.org/x/text v0.43.0
 	gopkg.in/yaml.v3 v3.0.1
 )
+
+require golang.org/x/mod v0.42.0 // indirect
M go.sum go.sum
@@ -1,5 +1,7 @@ github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
 github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
 golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
 golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
 golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
@@ -15,6 +15,8 @@ "strconv" 	"strings"
 
 	"gopkg.in/yaml.v3"
+
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/summary"
 )
 
 // Format guesses the format from a file name or Content-Type: json, yaml
@@ -243,36 +245,18 @@ if _, ok := cur[k]; !ok { 			removed = append(removed, k)
 		}
 	}
-	var parts []string
-	named := 0
-	list := func(label string, items []string) {
-		if len(items) == 0 {
-			return
-		}
-		sort.Strings(items)
-		room := max(maxNamed-named, 0)
-		shown := items[:min(len(items), room)]
-		named += len(shown)
-		s := label + strings.Join(shown, ", ")
-		if rest := len(items) - len(shown); rest > 0 {
-			if len(shown) > 0 {
-				s += " and "
-			}
-			s += strconv.Itoa(rest) + " more"
-		}
-		parts = append(parts, s)
-	}
-	list("", changed)
-	list("added ", added)
-	list("removed ", removed)
-	if len(parts) == 0 {
+	b := summary.Builder{Max: maxNamed}
+	b.Add("", changed)
+	b.Add("added ", added)
+	b.Add("removed ", removed)
+	if b.Empty() {
 		return "fields reordered", ""
 	}
 	kind := ""
 	if len(changed) == 0 && len(removed) == 0 {
 		kind = "expansion"
 	}
-	return strings.Join(parts, "; "), kind
+	return b.Join("; "), kind
 }
 
 func short(s string) string {
@@ -0,0 +1,159 @@+// Package deps lists the dependencies a project declares, one list item
+// each, so a revision reads "Updated golang.org/x/net v0.60.0 → v0.61.0".
+package deps
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"path"
+	"sort"
+	"strings"
+
+	"golang.org/x/mod/modfile"
+
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/summary"
+)
+
+// Convert reads a go.mod or package.json, told apart by its file name.
+func Convert(name string, raw []byte) (string, error) {
+	var deps map[string]string
+	var err error
+	switch path.Base(name) {
+	case "go.mod":
+		deps, err = goMod(raw)
+	case "package.json":
+		deps, err = packageJSON(raw)
+	default:
+		return "", fmt.Errorf("%s: only go.mod and package.json are read", name)
+	}
+	if err != nil {
+		return "", err
+	}
+	names := make([]string, 0, len(deps))
+	for n := range deps {
+		names = append(names, n)
+	}
+	sort.Strings(names)
+	var b strings.Builder
+	for _, n := range names {
+		b.WriteString("- " + n + " " + deps[n] + "\n")
+	}
+	return b.String(), nil
+}
+
+func goMod(raw []byte) (map[string]string, error) {
+	f, err := modfile.Parse("go.mod", raw, nil)
+	if err != nil {
+		// Directives newer than this build make Parse fail; ParseLax skips
+		// them, but drops replace lines too.
+		if f, err = modfile.ParseLax("go.mod", raw, nil); err != nil {
+			return nil, err
+		}
+	}
+	deps := map[string]string{}
+	if f.Go != nil {
+		deps["go"] = f.Go.Version
+	}
+	if f.Toolchain != nil {
+		deps["toolchain"] = f.Toolchain.Name
+	}
+	for _, r := range f.Require {
+		v := r.Mod.Version
+		if r.Indirect {
+			v += " (indirect)"
+		}
+		deps[r.Mod.Path] = v
+	}
+	for _, r := range f.Replace {
+		to := r.New.Path
+		if r.New.Version != "" {
+			to += " " + r.New.Version
+		}
+		deps["replace "+r.Old.Path] = to
+	}
+	return deps, nil
+}
+
+// packageJSON reads the four dependency maps; anything but runtime
+// dependencies is labeled.
+func packageJSON(raw []byte) (map[string]string, error) {
+	var p map[string]json.RawMessage
+	if err := json.Unmarshal(raw, &p); err != nil {
+		return nil, err
+	}
+	deps := map[string]string{}
+	found := false
+	for _, group := range []struct{ key, label string }{
+		{"dependencies", ""}, {"devDependencies", " (dev)"},
+		{"peerDependencies", " (peer)"}, {"optionalDependencies", " (optional)"},
+	} {
+		rawGroup, ok := p[group.key]
+		if !ok {
+			continue
+		}
+		found = true
+		var m map[string]string
+		if err := json.Unmarshal(rawGroup, &m); err != nil {
+			return nil, fmt.Errorf("%s: %w", group.key, err)
+		}
+		for n, v := range m {
+			deps[n] = v + group.label
+		}
+	}
+	if !found && p["name"] == nil {
+		return nil, errors.New("not a package.json")
+	}
+	return deps, nil
+}
+
+// parse reads back a body Convert wrote: name, then version.
+func parse(body string) map[string]string {
+	deps := map[string]string{}
+	for _, l := range strings.Split(body, "\n") {
+		l, ok := strings.CutPrefix(l, "- ")
+		if !ok {
+			continue
+		}
+		// "replace" entries have a space in their name.
+		if rest, ok := strings.CutPrefix(l, "replace "); ok {
+			n, v, _ := strings.Cut(rest, " ")
+			deps["replace "+n] = v
+			continue
+		}
+		n, v, _ := strings.Cut(l, " ")
+		deps[n] = v
+	}
+	return deps
+}
+
+// maxNamed bounds how many dependencies a summary names.
+const maxNamed = 6
+
+// Summary names the dependencies that changed.
+func Summary(before, after string) (string, string) {
+	old, cur := parse(before), parse(after)
+	var updated, added, removed []string
+	for n, v := range cur {
+		o, had := old[n]
+		switch {
+		case !had:
+			added = append(added, n+" "+v)
+		case o != v:
+			updated = append(updated, n+" "+o+" → "+v)
+		}
+	}
+	for n := range old {
+		if _, ok := cur[n]; !ok {
+			removed = append(removed, n)
+		}
+	}
+	b := summary.Builder{Max: maxNamed}
+	b.Add("Updated ", updated)
+	b.Add("added ", added)
+	b.Add("removed ", removed)
+	if b.Empty() {
+		return "dependencies reordered", ""
+	}
+	return b.Join("; "), ""
+}
@@ -0,0 +1,68 @@+package deps
+
+import "testing"
+
+const goModV1 = `module example.com/app
+
+go 1.26.4
+
+require (
+	golang.org/x/net v0.60.0
+	gopkg.in/yaml.v3 v3.0.1
+	golang.org/x/text v0.42.0 // indirect
+)
+
+replace example.com/old => ../old
+`
+
+func TestGoMod(t *testing.T) {
+	got, err := Convert("app/go.mod", []byte(goModV1))
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := `- go 1.26.4
+- golang.org/x/net v0.60.0
+- golang.org/x/text v0.42.0 (indirect)
+- gopkg.in/yaml.v3 v3.0.1
+- replace example.com/old ../old
+`
+	if got != want {
+		t.Errorf("got\n%s\nwant\n%s", got, want)
+	}
+}
+
+func TestPackageJSON(t *testing.T) {
+	got, err := Convert("package.json", []byte(`{"name":"site","dependencies":{"astro":"^5.1.0"},"devDependencies":{"typescript":"~5.6.0"}}`))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if want := "- astro ^5.1.0\n- typescript ~5.6.0 (dev)\n"; got != want {
+		t.Errorf("got\n%s\nwant\n%s", got, want)
+	}
+	if _, err := Convert("package.json", []byte(`{"version":"1"}`)); err == nil {
+		t.Error("a JSON file with no name and no dependencies is not a package.json")
+	}
+	if _, err := Convert("Cargo.toml", nil); err == nil {
+		t.Error("unknown manifests must be refused")
+	}
+}
+
+func TestSummary(t *testing.T) {
+	before, _ := Convert("go.mod", []byte(goModV1))
+	after, _ := Convert("go.mod", []byte(`module example.com/app
+
+go 1.26.4
+
+require (
+	golang.org/x/net v0.61.0
+	golang.org/x/mod v0.42.0
+)
+
+replace example.com/old => ../old2
+`))
+	got, kind := Summary(before, after)
+	want := "Updated golang.org/x/net v0.60.0 → v0.61.0, replace example.com/old ../old → ../old2; added golang.org/x/mod v0.42.0; removed golang.org/x/text, gopkg.in/yaml.v3"
+	if got != want || kind != "" {
+		t.Errorf("got  %q\nwant %q", got, want)
+	}
+}
@@ -0,0 +1,247 @@+// Package gocode describes the exported API of a Go module, one page per
+// package: every exported constant, variable, type, function and method,
+// with its declaration and doc comment, and nothing from function bodies.
+// Git already keeps the bodies; this is what the module promises to the
+// code that imports it.
+package gocode
+
+import (
+	"bytes"
+	"go/ast"
+	"go/build"
+	"go/doc"
+	"go/doc/comment"
+	"go/parser"
+	"go/printer"
+	"go/token"
+	"io/fs"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+
+	"golang.org/x/mod/modfile"
+
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/summary"
+)
+
+// Package is one package's API page.
+type Package struct {
+	Dir        string // relative to the module root, '/'-separated; "" for the root
+	ImportPath string
+	Body       string
+}
+
+// buildContext fixes the platform, so the same code gives the same pages
+// on every machine.
+func buildContext() build.Context {
+	c := build.Default
+	c.GOOS, c.GOARCH, c.CgoEnabled = "linux", "amd64", false
+	return c
+}
+
+// Module reads every package of the module rooted at root. Main packages,
+// test files and nested modules are left out.
+func Module(root string) ([]Package, error) {
+	modPath := "."
+	if raw, err := os.ReadFile(filepath.Join(root, "go.mod")); err == nil {
+		modPath = modfile.ModulePath(raw)
+	}
+	ctx := buildContext()
+	var pkgs []Package
+	err := filepath.WalkDir(root, func(dir string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if !d.IsDir() {
+			return nil
+		}
+		name := d.Name()
+		if dir != root {
+			if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || name == "testdata" || name == "vendor" || name == "node_modules" {
+				return filepath.SkipDir
+			}
+			if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
+				return filepath.SkipDir // another module
+			}
+		}
+		rel, _ := filepath.Rel(root, dir) // dir is under root
+		rel = filepath.ToSlash(rel)
+		if rel == "." {
+			rel = ""
+		}
+		p, ok, err := readPackage(ctx, dir, path.Join(modPath, rel))
+		if err != nil || !ok {
+			return err
+		}
+		p.Dir = rel
+		pkgs = append(pkgs, p)
+		return nil
+	})
+	return pkgs, err
+}
+
+func readPackage(ctx build.Context, dir, importPath string) (Package, bool, error) {
+	bp, err := ctx.ImportDir(dir, 0)
+	if err != nil {
+		if _, none := err.(*build.NoGoError); none {
+			return Package{}, false, nil
+		}
+		return Package{}, false, err
+	}
+	if bp.Name == "main" {
+		return Package{}, false, nil
+	}
+	fset := token.NewFileSet()
+	var files []*ast.File
+	for _, name := range bp.GoFiles {
+		f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ParseComments)
+		if err != nil {
+			return Package{}, false, err
+		}
+		files = append(files, f)
+	}
+	dp, err := doc.NewFromFiles(fset, files, importPath)
+	if err != nil {
+		return Package{}, false, err
+	}
+	return Package{ImportPath: importPath, Body: render(fset, dp)}, true, nil
+}
+
+func render(fset *token.FileSet, p *doc.Package) string {
+	var b strings.Builder
+	b.WriteString("# package " + p.Name + "\n\n")
+	b.WriteString("`import \"" + p.ImportPath + "\"`\n")
+	if text := docText(p, p.Doc); text != "" {
+		b.WriteString("\n" + text)
+	}
+	values := func(kind string, vs []*doc.Value) {
+		for _, v := range vs {
+			section(&b, kind+" "+strings.Join(v.Names, ", "), decl(fset, v.Decl), docText(p, v.Doc))
+		}
+	}
+	values("const", p.Consts)
+	values("var", p.Vars)
+	for _, f := range p.Funcs {
+		section(&b, "func "+f.Name, decl(fset, f.Decl), docText(p, f.Doc))
+	}
+	for _, t := range p.Types {
+		section(&b, "type "+t.Name, decl(fset, t.Decl), docText(p, t.Doc))
+		values("const", t.Consts)
+		values("var", t.Vars)
+		for _, f := range t.Funcs {
+			section(&b, "func "+f.Name, decl(fset, f.Decl), docText(p, f.Doc))
+		}
+		for _, m := range t.Methods {
+			section(&b, "method ("+m.Recv+") "+m.Name, decl(fset, m.Decl), docText(p, m.Doc))
+		}
+	}
+	return b.String()
+}
+
+// section writes one declaration: a heading that names it, the declaration
+// itself, then its documentation.
+func section(b *strings.Builder, name, code, text string) {
+	b.WriteString("\n## " + name + "\n\n```go\n" + code + "\n```\n")
+	if text != "" {
+		b.WriteString("\n" + text)
+	}
+}
+
+// decl prints a declaration without its doc comment or function body.
+func decl(fset *token.FileSet, node ast.Decl) string {
+	switch d := node.(type) {
+	case *ast.FuncDecl:
+		c := *d
+		c.Doc, c.Body = nil, nil
+		node = &c
+	case *ast.GenDecl:
+		c := *d
+		c.Doc = nil
+		node = &c
+	}
+	var buf bytes.Buffer
+	cfg := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 8}
+	if err := cfg.Fprint(&buf, fset, node); err != nil {
+		return "// " + err.Error()
+	}
+	return buf.String()
+}
+
+// docText renders a doc comment as Markdown, its headings below ours.
+func docText(p *doc.Package, text string) string {
+	if strings.TrimSpace(text) == "" {
+		return ""
+	}
+	pr := p.Printer()
+	pr.HeadingLevel = 3
+	pr.HeadingID = func(*comment.Heading) string { return "" } // no {#anchors}
+	return string(pr.Markdown(p.Parser().Parse(text)))
+}
+
+// Section is one declaration read back from a page.
+type Section struct {
+	Name string // "func Retract", "method (*Engine) Ingest", "type Engine"
+	Code string
+	Doc  string
+}
+
+// Sections reads back the declarations of a page Module wrote.
+func Sections(body string) map[string]Section {
+	out := map[string]Section{}
+	parts := strings.Split("\n"+body, "\n## ")
+	for _, part := range parts[1:] {
+		name, rest, _ := strings.Cut(part, "\n")
+		s := Section{Name: name}
+		if start := strings.Index(rest, "```go\n"); start >= 0 {
+			if end := strings.Index(rest[start+6:], "\n```\n"); end >= 0 {
+				s.Code = rest[start+6 : start+6+end]
+				s.Doc = strings.TrimSpace(rest[start+6+end+5:])
+			}
+		}
+		out[name] = s
+	}
+	return out
+}
+
+// maxNamed bounds how many declarations a summary names before counting
+// the rest.
+const maxNamed = 6
+
+// Summary names what changed in the API. Removing anything or changing a
+// function's signature can break importers, so those lead as "Breaking".
+func Summary(before, after string) (string, string) {
+	old, cur := Sections(before), Sections(after)
+	var breaking, added, changed, docs []string
+	for name, s := range cur {
+		o, had := old[name]
+		switch {
+		case !had:
+			added = append(added, name)
+		case o.Code != s.Code && (strings.HasPrefix(name, "func ") || strings.HasPrefix(name, "method ")):
+			breaking = append(breaking, "changed "+name)
+		case o.Code != s.Code:
+			changed = append(changed, name)
+		case o.Doc != s.Doc:
+			docs = append(docs, name)
+		}
+	}
+	for name := range old {
+		if _, ok := cur[name]; !ok {
+			breaking = append(breaking, "removed "+name)
+		}
+	}
+	b := summary.Builder{Max: maxNamed}
+	b.Add("Breaking: ", breaking)
+	b.Add("Changed: ", changed)
+	b.Add("Added: ", added)
+	b.Add("Docs: ", docs)
+	if b.Empty() {
+		return "package overview changed", ""
+	}
+	kind := ""
+	if len(breaking) == 0 && len(changed) == 0 && len(docs) == 0 {
+		kind = "expansion"
+	}
+	return b.Join(". "), kind
+}
@@ -0,0 +1,56 @@+package gocode
+
+import (
+	"flag"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+var update = flag.Bool("update", false, "rewrite golden files")
+
+func TestModule(t *testing.T) {
+	pkgs, err := Module(filepath.Join("testdata", "v1"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(pkgs) != 2 || pkgs[0].Dir != "" || pkgs[1].Dir != "sub" {
+		t.Fatalf("packages %+v", pkgs)
+	}
+	if pkgs[0].ImportPath != "example.com/lib" || pkgs[1].ImportPath != "example.com/lib/sub" {
+		t.Errorf("import paths %q %q", pkgs[0].ImportPath, pkgs[1].ImportPath)
+	}
+	golden := filepath.Join("testdata", "v1.md")
+	if *update {
+		os.WriteFile(golden, []byte(pkgs[0].Body), 0o644)
+	}
+	want, err := os.ReadFile(golden)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if pkgs[0].Body != string(want) {
+		t.Errorf("mismatch with %s:\n%s", golden, pkgs[0].Body)
+	}
+}
+
+func TestSummary(t *testing.T) {
+	v1, err := Module(filepath.Join("testdata", "v1"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	v2, err := Module(filepath.Join("testdata", "v2"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	got, kind := Summary(v1[0].Body, v2[0].Body)
+	want := "Breaking: changed func New, removed func Old. Changed: type Thing. Added: type Option. Docs: method (*Thing) Hello"
+	if got != want || kind != "" {
+		t.Errorf("got  %q %q\nwant %q", got, kind, want)
+	}
+	if got, kind := Summary(v1[1].Body, v2[1].Body); got != "package overview changed" || kind != "" {
+		t.Errorf("identical pages: %q %q", got, kind)
+	}
+	if got, kind := Summary(v2[0].Body, v2[0].Body+"\n## func Extra\n\n```go\nfunc Extra()\n```\n"); got != "Added: func Extra" || kind != "expansion" {
+		t.Errorf("addition only: %q %q", got, kind)
+	}
+}
@@ -0,0 +1,60 @@+# package lib
+
+`import "example.com/lib"`
+
+Package lib does things.
+
+### Usage
+
+Call New.
+
+## const Version
+
+```go
+const Version = "1"
+```
+
+Version is the version.
+
+## func Old
+
+```go
+func Old()
+```
+
+Old is on its way out.
+
+## func Platform
+
+```go
+func Platform() string
+```
+
+Platform names the platform.
+
+## type Thing
+
+```go
+type Thing struct {
+	Name string
+	// contains filtered or unexported fields
+}
+```
+
+Thing is a thing.
+
+## func New
+
+```go
+func New(name string) *Thing
+```
+
+New makes a Thing.
+
+## method (*Thing) Hello
+
+```go
+func (t *Thing) Hello() string
+```
+
+Hello says hello.
@@ -0,0 +1,3 @@+package main
+
+func main() {}
@@ -0,0 +1,3 @@+module example.com/lib
+
+go 1.26
@@ -0,0 +1,26 @@+// Package lib does things.
+//
+// # Usage
+//
+// Call New.
+package lib
+
+// Version is the version.
+const Version = "1"
+
+// Thing is a thing.
+type Thing struct {
+	Name   string
+	secret int
+}
+
+// New makes a Thing.
+func New(name string) *Thing { return &Thing{Name: name} }
+
+// Hello says hello.
+func (t *Thing) Hello() string { return "hi " + t.Name }
+
+// Old is on its way out.
+func Old() {}
+
+func unexported() {}
@@ -0,0 +1,6 @@+//go:build linux
+
+package lib
+
+// Platform names the platform.
+func Platform() string { return "linux" }
@@ -0,0 +1,5 @@+package lib
+
+import "testing"
+
+func TestX(t *testing.T) {}
@@ -0,0 +1,6 @@+//go:build windows
+
+package lib
+
+// Platform names the platform.
+func Platform() string { return "windows" }
@@ -0,0 +1,5 @@+// Package sub is nested.
+package sub
+
+// Answer is 42.
+var Answer = 42
@@ -0,0 +1,3 @@+package main
+
+func main() {}
@@ -0,0 +1,3 @@+module example.com/lib
+
+go 1.26
@@ -0,0 +1,27 @@+// Package lib does things.
+//
+// # Usage
+//
+// Call New.
+package lib
+
+// Version is the version.
+const Version = "1"
+
+// Thing is a thing.
+type Thing struct {
+	Name   string
+	Size   int
+	secret int
+}
+
+// Option changes a Thing.
+type Option func(*Thing)
+
+// New makes a Thing.
+func New(name string, opts ...Option) *Thing { return &Thing{Name: name} }
+
+// Hello says hello, politely.
+func (t *Thing) Hello() string { return "hi " + t.Name }
+
+func unexported() {}
@@ -0,0 +1,6 @@+//go:build linux
+
+package lib
+
+// Platform names the platform.
+func Platform() string { return "linux" }
@@ -0,0 +1,5 @@+package lib
+
+import "testing"
+
+func TestX(t *testing.T) {}
@@ -0,0 +1,6 @@+//go:build windows
+
+package lib
+
+// Platform names the platform.
+func Platform() string { return "windows" }
@@ -0,0 +1,5 @@+// Package sub is nested.
+package sub
+
+// Answer is 42.
+var Answer = 42
@@ -0,0 +1,41 @@+// Package summary builds the short phrases revision notes are made of:
+// "Updated a v1 → v2, b v3 → v4 and 2 more; added c".
+package summary
+
+import (
+	"sort"
+	"strconv"
+	"strings"
+)
+
+// Builder names up to Max items across all its groups and counts the rest,
+// so a note stays one line however much changed.
+type Builder struct {
+	Max   int
+	named int
+	parts []string
+}
+
+// Add appends a group: label followed by the items, sorted.
+func (b *Builder) Add(label string, items []string) {
+	if len(items) == 0 {
+		return
+	}
+	sort.Strings(items)
+	shown := items[:min(len(items), max(b.Max-b.named, 0))]
+	b.named += len(shown)
+	s := label + strings.Join(shown, ", ")
+	if rest := len(items) - len(shown); rest > 0 {
+		if len(shown) > 0 {
+			s += " and "
+		}
+		s += strconv.Itoa(rest) + " more"
+	}
+	b.parts = append(b.parts, s)
+}
+
+// Empty reports whether no group had items.
+func (b *Builder) Empty() bool { return len(b.parts) == 0 }
+
+// Join returns the groups joined by sep.
+func (b *Builder) Join(sep string) string { return strings.Join(b.parts, sep) }
@@ -0,0 +1,67 @@+package track
+
+import (
+	"context"
+	"fmt"
+	"path"
+
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/config"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/deps"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/fetch"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/gocode"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/out"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/slug"
+)
+
+// goAPI tracks the exported API of a Go module on disk, one page per
+// package.
+func (r *Runner) goAPI(ctx context.Context, t config.Track) (Result, error) {
+	pkgs, err := gocode.Module(t.Go)
+	if err != nil {
+		return Result{}, err
+	}
+	if len(pkgs) == 0 {
+		return Result{}, fmt.Errorf("%s: no importable packages (main packages are left out)", t.Go)
+	}
+	res := Result{Complete: true}
+	for _, p := range pkgs {
+		name := "index"
+		if p.Dir != "" {
+			name = pathSlug(p.Dir)
+		}
+		res.Pages = append(res.Pages, out.Page{Path: name, Title: p.ImportPath, Body: p.Body, Summary: gocode.Summary})
+	}
+	return res, nil
+}
+
+// pathSlug slugifies each segment of a '/'-separated path.
+func pathSlug(p string) string {
+	dir, file := path.Split(p)
+	if dir == "" {
+		return slug.Slugify(file)
+	}
+	return pathSlug(path.Clean(dir)) + "/" + slug.Slugify(file)
+}
+
+// dependencies tracks a go.mod or package.json.
+func (r *Runner) dependencies(ctx context.Context, t config.Track) (Result, error) {
+	res := Result{Complete: true}
+	s, err := r.load(ctx, t, t.Deps)
+	if err != nil && fetch.Gone(err) {
+		res.warn("%v; page removed", err)
+		return res, nil
+	}
+	if err != nil {
+		return Result{}, err
+	}
+	if s == nil {
+		res.Keep = []string{"index"}
+		return res, nil
+	}
+	body, err := deps.Convert(s.name, s.body)
+	if err != nil {
+		return Result{}, err
+	}
+	res.Pages = []out.Page{{Path: "index", Title: "Dependencies (" + s.name + ")", URL: s.url, Body: body, Summary: deps.Summary}}
+	return res, nil
+}
@@ -75,8 +75,13 @@ case config.KindData: 		return r.data(ctx, t)
 	case config.KindDoc:
 		return r.doc(ctx, t)
+	case config.KindGo:
+		return r.goAPI(ctx, t)
+	case config.KindDeps:
+		return r.dependencies(ctx, t)
 	}
-	return Result{}, fmt.Errorf("track %q: %s tracks are not implemented yet", t.Name, t.Kind())
+	// Validate accepts only the kinds above.
+	return Result{}, fmt.Errorf("track %q has no source", t.Name)
 }
 
 // pollEvery is how often run looks for tracks that are due.