| new file mode 100644 |
| index 000000000..1842d7d3e |
| --- /dev/null |
| +++ b/internal/provision/census_propose_test.go |
| @@ -0,0 +1,482 @@ |
| +package provision |
| + |
| +// census-propose (#369): census rulings as approvals. The root-read census |
| +// (TestPlan14RootReadCensus) fails on any change to a unit it tracks or to |
| +// a package envelope. Most of those changes are approvals, not decisions: |
| +// the same reads in an edited body, or an envelope fingerprint that moved. |
| +// This file classifies each discrepancy, writes the overlay that closes the |
| +// approvable ones (rows, @legacy fingerprints, envelope re-pins, the seal, |
| +// the chain manifest line), and the approval card a person reads to |
| +// approve it. Changes that need a disposition (a new unit, a removed one, |
| +// the scanner's field list) are decisions: it names them and writes |
| +// nothing. |
| +// |
| +// make census-card print the card; writes nothing |
| +// make census-propose NAME=<branch> CLAIMS=<file> write the overlay and the card |
| +// |
| +// CLAIMS has one line per changed unit, "<file>:<unit>\t<what changed>". |
| +// Rows carry rev3=census-ruling-<NAME> from the start: approval happens |
| +// before the merge, so there is no PROPOSED row to flip afterwards. |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Change classes. The first three are approvals; the rest are decisions. |
| +const ( |
| + censusBody = "body" // digest moved, the same reads (offsets aside) |
| + censusSites = "sites" // reads added or removed in a known unit |
| + censusEnvelope = "envelope" // a package's source fingerprint moved |
| + censusNew = "new" // an unclassified unit: needs a disposition |
| + censusRemoved = "removed" // a unit or envelope gone: needs a removal row |
| + censusFields = "fields" // the scanner's field list changed |
| +) |
| + |
| +func censusDecision(class string) bool { |
| + return class == censusNew || class == censusRemoved || class == censusFields |
| +} |
| + |
| +type censusChange struct { |
| + Key, Class string |
| + Before classifiedRootRead // the inventory's row; zero for a new unit |
| + After rootReadEntry // the scan's; zero for a removed unit |
| + // An envelope's fingerprints. |
| + OldHash, NewHash string |
| +} |
| + |
| +// kindCounts is a unit's read sites without their offsets. |
| +func kindCounts(kinds string) map[string]int { |
| + m := map[string]int{} |
| + if kinds == "" { |
| + return m |
| + } |
| + for _, k := range strings.Split(kinds, ",") { |
| + name, _, _ := strings.Cut(k, "@") |
| + m[name]++ |
| + } |
| + return m |
| +} |
| + |
| +// kindDiff says how a unit's read sites changed: "+registry:Socket", |
| +// "-file:Open ×2", or "unchanged (N)". |
| +func kindDiff(before, after string) string { |
| + b, a := kindCounts(before), kindCounts(after) |
| + names := map[string]bool{} |
| + for k := range b { |
| + names[k] = true |
| + } |
| + for k := range a { |
| + names[k] = true |
| + } |
| + var sorted []string |
| + for k := range names { |
| + sorted = append(sorted, k) |
| + } |
| + sort.Strings(sorted) |
| + var parts []string |
| + for _, k := range sorted { |
| + d := a[k] - b[k] |
| + sign := "+" |
| + if d < 0 { |
| + sign, d = "-", -d |
| + } |
| + switch { |
| + case d == 1: |
| + parts = append(parts, sign+k) |
| + case d > 1: |
| + parts = append(parts, fmt.Sprintf("%s%s ×%d", sign, k, d)) |
| + } |
| + } |
| + if len(parts) == 0 { |
| + n := 0 |
| + for _, c := range a { |
| + n += c |
| + } |
| + return fmt.Sprintf("unchanged (%d)", n) |
| + } |
| + return strings.Join(parts, ", ") |
| +} |
| + |
| +func sameKinds(before, after string) bool { |
| + return kindDiff(before, after) == kindDiff(after, after) |
| +} |
| + |
| +// classifyRootReads names every discrepancy compareRootReads and |
| +// checkRootReadDeclarations would report, one change per unit or envelope. |
| +func classifyRootReads(got rootReadScan, inv rootReadInventory) []censusChange { |
| + var cs []censusChange |
| + seen := map[string]bool{} |
| + for _, g := range got.Entries { |
| + key := g.File + ":" + g.Unit |
| + seen[key] = true |
| + w, ok := inv.Entries[key] |
| + switch { |
| + case !ok: |
| + cs = append(cs, censusChange{Key: key, Class: censusNew, After: g}) |
| + case w.Kinds == g.Kinds && w.Digest == g.Digest && declarationHolds(got, inv, key): |
| + case sameKinds(w.Kinds, g.Kinds): |
| + cs = append(cs, censusChange{Key: key, Class: censusBody, Before: w, After: g}) |
| + default: |
| + cs = append(cs, censusChange{Key: key, Class: censusSites, Before: w, After: g}) |
| + } |
| + } |
| + for key, w := range inv.Entries { |
| + if seen[key] { |
| + continue |
| + } |
| + decl, declared := got.Declarations[key] |
| + switch { |
| + case w.Class != "legacy" && w.Kinds == "" && declared && decl == w.Digest: |
| + // Declared, reads nothing, unchanged: not a discrepancy. |
| + case w.Class != "legacy" && w.Kinds == "" && declared: |
| + cs = append(cs, censusChange{Key: key, Class: censusBody, Before: w, After: rootReadEntry{File: w.File, Unit: w.Unit, Digest: decl}}) |
| + default: |
| + cs = append(cs, censusChange{Key: key, Class: censusRemoved, Before: w}) |
| + } |
| + } |
| + for dir, hash := range got.Coverage { |
| + if inv.Coverage[dir] != hash { |
| + cs = append(cs, censusChange{Key: dir, Class: censusEnvelope, OldHash: inv.Coverage[dir], NewHash: hash}) |
| + } |
| + } |
| + for dir, hash := range inv.Coverage { |
| + if _, ok := got.Coverage[dir]; !ok { |
| + cs = append(cs, censusChange{Key: dir, Class: censusRemoved, OldHash: hash}) |
| + } |
| + } |
| + if f := strings.Join(got.Fields, ","); f != inv.Fields { |
| + cs = append(cs, censusChange{Key: "@fields", Class: censusFields}) |
| + } |
| + sort.Slice(cs, func(i, j int) bool { |
| + if cs[i].Class != cs[j].Class { |
| + return cs[i].Class < cs[j].Class |
| + } |
| + return cs[i].Key < cs[j].Key |
| + }) |
| + return cs |
| +} |
| + |
| +// declarationHolds is checkRootReadDeclarations's test for one unit. |
| +func declarationHolds(got rootReadScan, inv rootReadInventory, key string) bool { |
| + frozen, ok := inv.Frozen[key] |
| + if !ok { |
| + return true |
| + } |
| + cur := inv.Entries[key] |
| + if cur.Class == "legacy" { |
| + return got.Declarations[key] == frozen.Digest && rootReadLegacyFingerprint(cur) == rootReadLegacyFingerprint(frozen) |
| + } |
| + return got.Declarations[key] == cur.Digest |
| +} |
| + |
| +type rootReadProposal struct { |
| + Overlay, Seal, Card string |
| +} |
| + |
| +var censusName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) |
| + |
| +// proposeRootReads writes the overlay that closes every change in the scan, |
| +// re-freezing each changed unit at the disposition it already has with its |
| +// claim appended, under census-ruling-<name>. It refuses decisions, a |
| +// change with no claim and a claim for no change, and checks its own work: |
| +// loaded onto a copy of inv, the overlay must leave the census clean. |
| +func proposeRootReads(got rootReadScan, inv rootReadInventory, seal, name string, claims map[string]string) (rootReadProposal, error) { |
| + if !censusName.MatchString(name) { |
| + return rootReadProposal{}, fmt.Errorf("census name %q: want lower-case letters, digits and dashes", name) |
| + } |
| + cs := classifyRootReads(got, inv) |
| + if len(cs) == 0 { |
| + return rootReadProposal{}, fmt.Errorf("nothing to propose: the census is clean") |
| + } |
| + var decisions []string |
| + units := map[string]bool{} |
| + for _, c := range cs { |
| + if censusDecision(c.Class) { |
| + decisions = append(decisions, c.Class+" "+c.Key) |
| + } |
| + if c.Class == censusBody || c.Class == censusSites { |
| + units[c.Key] = true |
| + } |
| + } |
| + if len(decisions) > 0 { |
| + return rootReadProposal{}, fmt.Errorf("a decision is needed, not an approval: %s; write those rows by hand and ask on #280", strings.Join(decisions, "; ")) |
| + } |
| + for key := range units { |
| + if strings.TrimSpace(claims[key]) == "" { |
| + return rootReadProposal{}, fmt.Errorf("no claim for %s: say what changed in the CLAIMS file", key) |
| + } |
| + } |
| + for key, claim := range claims { |
| + if !units[key] { |
| + return rootReadProposal{}, fmt.Errorf("claim for %s: not a change the census sees", key) |
| + } |
| + if strings.ContainsAny(claim, "\t\n\r") { |
| + return rootReadProposal{}, fmt.Errorf("claim for %s: a tab or newline would break the row", key) |
| + } |
| + } |
| + |
| + ruling := "census-ruling-" + name |
| + var frozen, plain, cover []string |
| + counts := map[string]int{} |
| + for _, c := range cs { |
| + counts[c.Class]++ |
| + if c.Class == censusEnvelope { |
| + cover = append(cover, "@coverage\t"+c.Key+"\t"+c.NewHash) |
| + continue |
| + } |
| + e := classifiedRootRead{rootReadEntry: c.After, Class: c.Before.Class, Task: c.Before.Task, Basis: c.Before.Basis + ";rev3=" + ruling + ";basis=" + strings.TrimSpace(claims[c.Key])} |
| + row := strings.Join([]string{e.File, e.Unit, e.Class, e.Task, e.Basis, e.Kinds, e.Digest}, "\t") |
| + if e.Class == "legacy" { |
| + frozen = append(frozen, "@legacy\t"+rootReadLegacyFingerprint(e)+"\t"+row) |
| + } |
| + plain = append(plain, row) |
| + } |
| + header := []string{ |
| + fmt.Sprintf("# Census overlay %s, written by census-propose (#369). %s, approved by the census owner before the merge; the approval card is docs/superpowers/notes/census-proposal-%s.md.", name, ruling, name), |
| + fmt.Sprintf("# %d body, %d sites, %d envelope: each changed unit re-frozen at its existing disposition with its claim appended.", counts[censusBody], counts[censusSites], counts[censusEnvelope]), |
| + } |
| + overlay := strings.Join(append(append(append(header, frozen...), plain...), cover...), "\n") + "\n" |
| + |
| + after := cloneRootReadInventory(inv) |
| + f, err := os.CreateTemp("", "census-overlay-*.tsv") |
| + if err != nil { |
| + return rootReadProposal{}, err |
| + } |
| + defer os.Remove(f.Name()) |
| + if _, err := f.WriteString(overlay); err != nil { |
| + f.Close() |
| + return rootReadProposal{}, err |
| + } |
| + if err := f.Close(); err != nil { |
| + return rootReadProposal{}, err |
| + } |
| + if err := loadRootReadInventory(f.Name(), &after, true); err != nil { |
| + return rootReadProposal{}, fmt.Errorf("the overlay does not load: %w", err) |
| + } |
| + newSeal := rootReadLegacySeal(after) |
| + if errs := censusDiscrepancies(got, after, newSeal); len(errs) != 0 { |
| + return rootReadProposal{}, fmt.Errorf("the overlay leaves discrepancies: %s", strings.Join(errs, "; ")) |
| + } |
| + return rootReadProposal{Overlay: overlay, Seal: newSeal, Card: rootReadCard(name, cs, claims, seal, newSeal)}, nil |
| +} |
| + |
| +func censusDiscrepancies(got rootReadScan, inv rootReadInventory, seal string) []string { |
| + errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| + return append(errs, checkRootReadDeclarations(got, inv)...) |
| +} |
| + |
| +// rootReadCard is what a person reads to approve the change. newSeal is |
| +// empty for a dry run (make census-card). |
| +func rootReadCard(name string, cs []censusChange, claims map[string]string, seal, newSeal string) string { |
| + var b strings.Builder |
| + fmt.Fprintf(&b, "# Census approval: %s\n\n", name) |
| + var decisions []censusChange |
| + var envelopes []string |
| + var units []censusChange |
| + for _, c := range cs { |
| + switch { |
| + case censusDecision(c.Class): |
| + decisions = append(decisions, c) |
| + case c.Class == censusEnvelope: |
| + envelopes = append(envelopes, "`"+c.Key+"`") |
| + default: |
| + units = append(units, c) |
| + } |
| + } |
| + switch { |
| + case len(cs) == 0: |
| + fmt.Fprintf(&b, "Nothing to approve: the census is clean (seal `%s`).\n", seal) |
| + return b.String() |
| + case len(decisions) == 0: |
| + fmt.Fprintf(&b, "Ruling `census-ruling-%s`. Every change below keeps the disposition it already has, so it needs an approval, not a decision.\n\n", name) |
| + default: |
| + fmt.Fprintf(&b, "Ruling `census-ruling-%s`. %d change(s) need a decision (below), which census-propose cannot write.\n\n", name, len(decisions)) |
| + } |
| + if len(units) > 0 { |
| + b.WriteString("| unit | change | read sites | what changed |\n|---|---|---|---|\n") |
| + for _, c := range units { |
| + claim := strings.TrimSpace(claims[c.Key]) |
| + if claim == "" { |
| + claim = "(no claim yet)" |
| + } |
| + fmt.Fprintf(&b, "| `%s` `%s` | %s | %s | %s |\n", c.After.File, c.After.Unit, c.Class, kindDiff(c.Before.Kinds, c.After.Kinds), claim) |
| + } |
| + b.WriteString("\n") |
| + } |
| + if len(envelopes) > 0 { |
| + fmt.Fprintf(&b, "Envelopes re-pinned: %s.\n\n", strings.Join(envelopes, ", ")) |
| + } |
| + if len(decisions) > 0 { |
| + b.WriteString("Decisions (write their rows by hand and ask on #280):\n\n") |
| + for _, c := range decisions { |
| + detail := "" |
| + if c.Class == censusNew { |
| + detail = ": reads " + kindDiff("", c.After.Kinds) |
| + } |
| + fmt.Fprintf(&b, "- %s `%s`%s\n", c.Class, c.Key, detail) |
| + } |
| + b.WriteString("\n") |
| + } |
| + if newSeal != "" { |
| + fmt.Fprintf(&b, "Seal: `%s` → `%s`.\n\nOverlay: `internal/provision/testdata/%s`, appended to `%s`.\n", seal, newSeal, rootReadOverlayFile(name), rootReadChainManifest) |
| + } else { |
| + fmt.Fprintf(&b, "Seal now: `%s`. Dry run: nothing written.\n", seal) |
| + } |
| + return b.String() |
| +} |
| + |
| +func rootReadOverlayFile(name string) string { return "plan14-root-reads-" + name + ".tsv" } |
| + |
| +func cloneRootReadInventory(inv rootReadInventory) rootReadInventory { |
| + out := rootReadInventory{Fields: inv.Fields} |
| + out.Entries = cloneMap(inv.Entries) |
| + out.Frozen = cloneMap(inv.Frozen) |
| + out.Removed = cloneMap(inv.Removed) |
| + out.Coverage = cloneMap(inv.Coverage) |
| + out.Legacy = cloneMap(inv.Legacy) |
| + return out |
| +} |
| + |
| +func cloneMap[V any](m map[string]V) map[string]V { |
| + if m == nil { |
| + return nil |
| + } |
| + out := make(map[string]V, len(m)) |
| + for k, v := range m { |
| + out[k] = v |
| + } |
| + return out |
| +} |
| + |
| +// readCensusClaims reads a CLAIMS file: "<file>:<unit>\t<what changed>" |
| +// per line, blank lines and # comments ignored. |
| +func readCensusClaims(path string) (map[string]string, error) { |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + claims := map[string]string{} |
| + for i, line := range strings.Split(string(raw), "\n") { |
| + line = strings.TrimRight(line, "\r") |
| + if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") { |
| + continue |
| + } |
| + key, claim, ok := strings.Cut(line, "\t") |
| + if !ok || !strings.Contains(key, ":") || strings.TrimSpace(claim) == "" { |
| + return nil, fmt.Errorf("%s:%d: want <file>:<unit><TAB><what changed>", path, i+1) |
| + } |
| + if _, dup := claims[key]; dup { |
| + return nil, fmt.Errorf("%s:%d: a second claim for %s", path, i+1, key) |
| + } |
| + claims[key] = claim |
| + } |
| + return claims, nil |
| +} |
| + |
| +const ( |
| + censusCardStart = "--- census card ---" |
| + censusCardEnd = "--- end census card ---" |
| +) |
| + |
| +// TestPlan14RootReadCensusPropose is census-propose and census-card (see |
| +// the Makefile). It does nothing unless one of them asked. |
| +func TestPlan14RootReadCensusPropose(t *testing.T) { |
| + name, dry := os.Getenv("CARLOS_CENSUS_PROPOSE"), os.Getenv("CARLOS_CENSUS_CARD") != "" |
| + if name == "" && !dry { |
| + t.Skip("run by make census-card and make census-propose") |
| + } |
| + root := censusRoot(t) |
| + if !dry { |
| + // A re-run (the code changed again) replaces this name's own |
| + // overlay, which must still be the manifest's last. |
| + dropOwnRootReadLink(t, root, name) |
| + } |
| + got, err := rootReadProduction(root) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + inv, seal := rootReadChain(t, root) |
| + claims := map[string]string{} |
| + if path := os.Getenv("CARLOS_CENSUS_CLAIMS"); path != "" { |
| + if claims, err = readCensusClaims(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if dry { |
| + if name == "" { |
| + name = "<name>" |
| + } |
| + fmt.Printf("%s\n%s%s\n", censusCardStart, rootReadCard(name, classifyRootReads(got, inv), claims, seal, ""), censusCardEnd) |
| + return |
| + } |
| + p, err := proposeRootReads(got, inv, seal, name, claims) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + overlay := filepath.Join(root, "internal/provision/testdata", rootReadOverlayFile(name)) |
| + if err := os.WriteFile(overlay, []byte(p.Overlay), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + manifest := filepath.Join(root, rootReadChainManifest) |
| + f, err := os.OpenFile(manifest, os.O_APPEND|os.O_WRONLY, 0o644) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fmt.Fprintf(f, "%s\t%s\n", rootReadOverlayFile(name), p.Seal); err != nil { |
| + f.Close() |
| + t.Fatal(err) |
| + } |
| + if err := f.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + note := filepath.Join(root, "docs/superpowers/notes", "census-proposal-"+name+".md") |
| + if err := os.WriteFile(note, []byte(p.Card), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + fmt.Printf("%s\n%s%s\n", censusCardStart, p.Card, censusCardEnd) |
| +} |
| + |
| +// dropOwnRootReadLink removes name's overlay from the end of the chain |
| +// manifest, and its file, so a re-run proposes against the chain before |
| +// it. One that is not last is refused: something was proposed after it. |
| +func dropOwnRootReadLink(t *testing.T, root, name string) { |
| + t.Helper() |
| + manifest := filepath.Join(root, rootReadChainManifest) |
| + links, err := readRootReadChainManifest(manifest) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + file := rootReadOverlayFile(name) |
| + for i, l := range links { |
| + if l.File != file { |
| + continue |
| + } |
| + if i != len(links)-1 { |
| + t.Fatalf("%s is not the last overlay in %s: propose under a new name", file, rootReadChainManifest) |
| + } |
| + raw, err := os.ReadFile(manifest) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") |
| + last := len(lines) - 1 |
| + if lines[last] != l.File+"\t"+l.Seal { |
| + t.Fatalf("%s: last line is %q, want %s's", rootReadChainManifest, lines[last], file) |
| + } |
| + if err := os.WriteFile(manifest, []byte(strings.Join(lines[:last], "\n")+"\n"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Remove(filepath.Join(root, "internal/provision/testdata", file)); err != nil && !os.IsNotExist(err) { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := os.Stat(filepath.Join(root, "internal/provision/testdata", file)); err == nil { |
| + t.Fatalf("%s exists but is not in %s: a hand-written overlay has that name", file, rootReadChainManifest) |
| + } |
| +} |