| new file mode 100644 |
| index 000000000..fbb3b6661 |
| --- /dev/null |
| +++ b/approved_moves_copy_test.go |
| @@ -0,0 +1,1200 @@ |
| +// approved_moves_copy_test.go |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "regexp" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +// The copy gate of the moves series (Phase 1 plan 1, Task 15), beside the |
| +// nearest-region gate in approved_copy_test.go. The record is a markdown |
| +// table with a status column, not JSON, so it has its own parser; the checks |
| +// are the same in spirit: |
| +// |
| +// - TestMovesCopyRecordIsComplete: the record names exactly the ids the |
| +// pinned inventory names, so a deleted row is caught; |
| +// - TestMovesApprovedCopyIsApplied: an approved row's text is in the file |
| +// the row names; |
| +// - TestPendingCopyKeepsTheSwitchOff: while a row is pending, |
| +// moves.DefaultEnabled is false; |
| +// - TestMovesStringsAreRecorded: every operator-visible sentence the moves |
| +// code emits has a row (the mechanical half of review finding F13). |
| + |
| +const movesCopyRecordPath = "docs/superpowers/plans/2026-10-06-moves-phase1-copy.md" |
| + |
| +// movesCopyIDs is the record's inventory, pinned as a literal: the spec's |
| +// M-C1 to M-C22 (rev 7.21) and every MV-N<n> the plans introduce. Each plan's |
| +// "Gate and copy" task appends its ids here as it appends its rows to the |
| +// record. The record and this literal must agree both ways. |
| +var movesCopyIDs = []string{ |
| + "M-C1", "M-C2", "M-C3", "M-C4", "M-C5", "M-C6", "M-C7", "M-C8", "M-C9", |
| + "M-C10", "M-C11", "M-C12", "M-C13", "M-C14", "M-C15", "M-C16", "M-C17", |
| + "M-C18", "M-C19", "M-C20", "M-C21", "M-C22", "MV-N1", "MV-N2", "MV-N3", |
| + "MV-N4", "MV-N5", "MV-N6", "MV-N7", "MV-N8", "MV-N9", "MV-N10", "MV-N11", |
| + "MV-N12", "MV-N13", "MV-N14", "MV-N15", "MV-N16", "MV-N17", "MV-N18", |
| + "MV-N19", "MV-N20", "MV-N21", "MV-N22", "MV-N23", "MV-N24", "MV-N25", |
| + "MV-N26", "MV-N27", "MV-N28", "MV-N29", "MV-N30", "MV-N31", "MV-N32", |
| + "MV-N33", "MV-N34", "MV-N35", "MV-N36", "MV-N37", "MV-N38", "MV-N39", |
| + "MV-N40", "MV-N41", "MV-N42", "MV-N43", "MV-N44", "MV-N45", "MV-N46", |
| + "MV-N47", "MV-N48", "MV-N49", "MV-N50", "MV-N51", "MV-N52", "MV-N53", |
| + "MV-N54", "MV-N55", "MV-N56", "MV-N57", "MV-N58", "MV-N59", "MV-N60", |
| + "MV-N61", "MV-N62", "MV-N63", "MV-N64", "MV-N65", "MV-N66", "MV-N67", |
| + "MV-N68", "MV-N69", "MV-N70", "MV-N71", "MV-N72", "MV-N73", "MV-N74", |
| + "MV-N75", "MV-N76", "MV-N77", "MV-N78", "MV-N79", "MV-N80", "MV-N81", |
| + "MV-N82", "MV-N83", "MV-N84", "MV-N85", "MV-N86", "MV-N87", "MV-N88", |
| + "MV-N89", "MV-N90", "MV-N91", "MV-N92", "MV-N93", "MV-N94", "MV-N95", |
| + "MV-N96", "MV-N97", "MV-N98", "MV-N99", "MV-N100", "MV-N101", "MV-N102", |
| + "MV-N103", "MV-N104", "MV-N105", "MV-N106", "MV-N107", "MV-N108", |
| + "MV-N109", "MV-N110", "MV-N111", "MV-N112", "MV-N113", "MV-N114", |
| + "MV-N115", "MV-N116", "MV-N117", "MV-N118", "MV-N119", "MV-N120", |
| + "MV-N121", "MV-N122", "MV-N123", "MV-N124", "MV-N125", "MV-N126", |
| + "MV-N127", "MV-N128", "MV-N129", "MV-N130", "MV-N131", "MV-N132", |
| + "MV-N133", "MV-N134", "MV-N135", "MV-N136", "MV-N137", "MV-N138", |
| + "MV-N139", "MV-N140", "MV-N141", "MV-N142", "MV-N143", "MV-N144", |
| + "MV-N145", "MV-N146", "MV-N147", |
| +} |
| + |
| +// copyRow is one row of the record. |
| +type copyRow struct { |
| + ID, File, Text, Status string |
| +} |
| + |
| +var movesCopyIDPattern = regexp.MustCompile(`^(M-C|MV-N)[0-9]+$`) |
| + |
| +// parseMovesCopyRecord reads the record's tables. A data row is a line that |
| +// follows a `| id | file | text | status |` header; the separator line is |
| +// skipped, and prose and headings are not rows. The text cell may hold a |
| +// pipe: the id and file are the first two cells and the status is the last. |
| +func parseMovesCopyRecord(path string) ([]copyRow, error) { |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return parseMovesCopyTable(string(raw), path) |
| +} |
| + |
| +func parseMovesCopyTable(src, name string) ([]copyRow, error) { |
| + var rows []copyRow |
| + seen := map[string]int{} |
| + inTable := false |
| + for n, line := range strings.Split(src, "\n") { |
| + line = strings.TrimSpace(line) |
| + if !strings.HasPrefix(line, "|") { |
| + inTable = false |
| + continue |
| + } |
| + at := fmt.Sprintf("%s:%d", name, n+1) |
| + if !strings.HasSuffix(line, "|") || len(line) < 2 { |
| + return nil, fmt.Errorf("%s: a table row must end with a pipe: %q", at, line) |
| + } |
| + raw := strings.Split(line[1:len(line)-1], "|") |
| + cells := make([]string, len(raw)) |
| + for i := range raw { |
| + cells[i] = strings.TrimSpace(raw[i]) |
| + } |
| + if len(cells) >= 4 && cells[0] == "id" { |
| + if cells[1] != "file" || cells[2] != "text" || cells[len(cells)-1] != "status" { |
| + return nil, fmt.Errorf("%s: the header must be | id | file | text | status |: %q", at, line) |
| + } |
| + inTable = true |
| + continue |
| + } |
| + if strings.Trim(strings.Join(cells, ""), "-: ") == "" { |
| + continue // the separator line |
| + } |
| + if !inTable { |
| + return nil, fmt.Errorf("%s: a row outside a table that starts with an id/file/text/status header: %q", at, line) |
| + } |
| + if len(cells) < 4 { |
| + return nil, fmt.Errorf("%s: a row needs id, file, text and status cells, got %d: %q", at, len(cells), line) |
| + } |
| + last := len(cells) - 1 |
| + r := copyRow{ |
| + ID: cells[0], |
| + File: cells[1], |
| + Text: strings.TrimSpace(strings.Join(raw[2:last], "|")), |
| + Status: cells[last], |
| + } |
| + if !movesCopyIDPattern.MatchString(r.ID) { |
| + return nil, fmt.Errorf("%s: %q is not an M-C<n> or MV-N<n> id", at, r.ID) |
| + } |
| + if r.File == "" || r.Text == "" { |
| + return nil, fmt.Errorf("%s: %s has an empty file or text cell", at, r.ID) |
| + } |
| + if r.Status != "pending" && r.Status != "approved" { |
| + return nil, fmt.Errorf("%s: %s has status %q, which is neither pending nor approved", at, r.ID, r.Status) |
| + } |
| + if first, dup := seen[r.ID]; dup { |
| + return nil, fmt.Errorf("%s: %s is already a row at line %d", at, r.ID, first) |
| + } |
| + seen[r.ID] = n + 1 |
| + rows = append(rows, r) |
| + } |
| + return rows, nil |
| +} |
| + |
| +// checkMovesInventory compares the record's ids with the pinned inventory, |
| +// both ways, and returns what differs. |
| +func checkMovesInventory(rows []copyRow, want []string) []string { |
| + have := map[string]bool{} |
| + for _, r := range rows { |
| + have[r.ID] = true |
| + } |
| + pinned := map[string]bool{} |
| + var out []string |
| + for _, id := range want { |
| + if pinned[id] { |
| + out = append(out, fmt.Sprintf("the pinned inventory names %s twice", id)) |
| + } |
| + pinned[id] = true |
| + if !have[id] { |
| + out = append(out, fmt.Sprintf("the record has no row %s, which the pinned inventory names", id)) |
| + } |
| + } |
| + var extra []string |
| + for id := range have { |
| + if !pinned[id] { |
| + extra = append(extra, id) |
| + } |
| + } |
| + sort.Strings(extra) |
| + for _, id := range extra { |
| + out = append(out, fmt.Sprintf("the record has row %s, which the pinned inventory does not name (add it to movesCopyIDs)", id)) |
| + } |
| + return out |
| +} |
| + |
| +var copyPlaceholder = regexp.MustCompile(`<[^<>]*>`) |
| + |
| +// checkMovesApplied checks every approved row against the file it names under |
| +// root. A row's text may carry <placeholders>; the fixed text between them |
| +// must be present, in order, inside the string literals of a Go file (a |
| +// short fixed text must be a whole literal, as in copyApplied) or raw in any |
| +// other file. |
| +func checkMovesApplied(rows []copyRow, root string) []string { |
| + var out []string |
| + for _, r := range rows { |
| + if r.Status != "approved" { |
| + continue |
| + } |
| + body, err := os.ReadFile(filepath.Join(root, r.File)) |
| + if err != nil { |
| + out = append(out, fmt.Sprintf("%s: %v", r.ID, err)) |
| + continue |
| + } |
| + if !movesTextApplied(r.File, r.Text, body) { |
| + out = append(out, fmt.Sprintf("%s: %s does not contain the approved text %q", r.ID, r.File, r.Text)) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func movesTextApplied(file, text string, body []byte) bool { |
| + segs := copyPlaceholder.Split(text, -1) |
| + if len(segs) == 1 { |
| + if !strings.HasSuffix(file, ".go") { |
| + return bytes.Contains(body, []byte(text)) |
| + } |
| + // In a Go file the text must be inside a string literal's decoded |
| + // value (a whole literal when it is short), never in a comment. |
| + return goLiteralMatches(body, func(v string) bool { |
| + if len(text) >= shortCopyBytes { |
| + return strings.Contains(v, text) |
| + } |
| + return v == text |
| + }) |
| + } |
| + var fixed []string |
| + for _, s := range segs { |
| + if s != "" { |
| + fixed = append(fixed, s) |
| + } |
| + } |
| + if !strings.HasSuffix(file, ".go") { |
| + rest := string(body) |
| + for _, s := range fixed { |
| + i := strings.Index(rest, s) |
| + if i < 0 { |
| + return false |
| + } |
| + rest = rest[i+len(s):] |
| + } |
| + return true |
| + } |
| + next := 0 |
| + goLiteralMatches(body, func(v string) bool { |
| + for next < len(fixed) { |
| + i := strings.Index(v, fixed[next]) |
| + if i < 0 { |
| + break |
| + } |
| + v = v[i+len(fixed[next]):] |
| + next++ |
| + } |
| + return next == len(fixed) |
| + }) |
| + return next == len(fixed) |
| +} |
| + |
| +// checkPendingKeepsSwitchOff is D13: a pending row means the compiled default |
| +// must be off. When the default is on, every row must be approved. |
| +func checkPendingKeepsSwitchOff(rows []copyRow, defaultEnabled bool) []string { |
| + if !defaultEnabled { |
| + return nil |
| + } |
| + var out []string |
| + for _, r := range rows { |
| + if r.Status != "approved" { |
| + out = append(out, fmt.Sprintf("%s is %s, but moves.DefaultEnabled is true: a pending sentence can reach an operator", r.ID, r.Status)) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// checkMovesNoEmDash fails on an em dash in any row's text. The house voice |
| +// is a full stop and a new sentence. |
| +func checkMovesNoEmDash(rows []copyRow) []string { |
| + var out []string |
| + for _, r := range rows { |
| + if strings.ContainsRune(r.Text, '\u2014') { |
| + out = append(out, fmt.Sprintf("%s: the text holds an em dash", r.ID)) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func mustParseMovesRecord(t *testing.T) []copyRow { |
| + t.Helper() |
| + rows, err := parseMovesCopyRecord(movesCopyRecordPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) == 0 { |
| + t.Fatal("the record has no rows") |
| + } |
| + return rows |
| +} |
| + |
| +func reportAll(t *testing.T, problems []string) { |
| + t.Helper() |
| + for _, p := range problems { |
| + t.Error(p) |
| + } |
| +} |
| + |
| +// The record names every id the pinned inventory names, once, and nothing |
| +// else. A record missing from the tree fails here at parse. |
| +func TestMovesCopyRecordIsComplete(t *testing.T) { |
| + reportAll(t, checkMovesInventory(mustParseMovesRecord(t), movesCopyIDs)) |
| +} |
| + |
| +// Every approved row's text is applied in the file the row names. While every |
| +// row is pending this passes vacuously; the fixture test proves it bites. |
| +func TestMovesApprovedCopyIsApplied(t *testing.T) { |
| + reportAll(t, checkMovesApplied(mustParseMovesRecord(t), ".")) |
| +} |
| + |
| +// While any row is pending, a default deployment must not speak. The day the |
| +// record is approved, one commit flips moves.DefaultEnabled and this check's |
| +// condition inverts: every row must be approved. |
| +func TestPendingCopyKeepsTheSwitchOff(t *testing.T) { |
| + reportAll(t, checkPendingKeepsSwitchOff(mustParseMovesRecord(t), moves.DefaultEnabled)) |
| +} |
| + |
| +func TestMovesCopyHasNoEmDash(t *testing.T) { |
| + reportAll(t, checkMovesNoEmDash(mustParseMovesRecord(t))) |
| +} |
| + |
| +// ---- the parser ---- |
| + |
| +func TestParseMovesCopyTable(t *testing.T) { |
| + const header = "| id | file | text | status |\n|---|---|---|---|\n" |
| + good := header + |
| + "| M-C1 | move.go | Moving <host>. | pending |\n" + |
| + "| MV-N2 | a/b.go | a | b is kept | approved |\n" |
| + rows, err := parseMovesCopyTable("# prose\n\n"+good+"\nprose again\n", "x.md") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []copyRow{ |
| + {"M-C1", "move.go", "Moving <host>.", "pending"}, |
| + {"MV-N2", "a/b.go", "a | b is kept", "approved"}, |
| + } |
| + if len(rows) != len(want) { |
| + t.Fatalf("rows = %+v", rows) |
| + } |
| + for i := range want { |
| + if rows[i] != want[i] { |
| + t.Errorf("row %d = %+v, want %+v", i, rows[i], want[i]) |
| + } |
| + } |
| + |
| + bad := []struct{ name, src, wantErr string }{ |
| + {"too few cells", header + "| M-C1 | move.go | pending |\n", "needs id, file, text and status"}, |
| + {"no closing pipe", header + "| M-C1 | move.go | text | pending\n", "must end with a pipe"}, |
| + {"duplicate id", header + "| M-C1 | a.go | one | pending |\n| M-C1 | a.go | two | pending |\n", "already a row"}, |
| + {"unknown status", header + "| M-C1 | a.go | one | maybe |\n", "neither pending nor approved"}, |
| + {"empty status", header + "| M-C1 | a.go | one | |\n", "neither pending nor approved"}, |
| + {"empty text", header + "| M-C1 | a.go | | pending |\n", "empty file or text"}, |
| + {"empty file", header + "| M-C1 | | one | pending |\n", "empty file or text"}, |
| + {"bad id", header + "| X1 | a.go | one | pending |\n", "is not an M-C"}, |
| + {"row with no header", "| M-C1 | a.go | one | pending |\n", "outside a table"}, |
| + {"row after the table ended", good + "\nprose\n| M-C3 | a.go | one | pending |\n", "outside a table"}, |
| + {"a header that is not ours", "| id | place | text | status |\n|---|---|---|---|\n", "the header must be"}, |
| + } |
| + for _, c := range bad { |
| + _, err := parseMovesCopyTable(c.src, "x.md") |
| + if err == nil || !strings.Contains(err.Error(), c.wantErr) { |
| + t.Errorf("%s: err = %v, want one containing %q", c.name, err, c.wantErr) |
| + } |
| + } |
| +} |
| + |
| +func TestParseMovesCopyRecordMissingFile(t *testing.T) { |
| + if _, err := parseMovesCopyRecord("testdata/moves-copy/no-such-record.md"); err == nil { |
| + t.Error("a record missing from the tree must fail to parse") |
| + } |
| +} |
| + |
| +// ---- the negative fixtures ---- |
| + |
| +// Each fixture is a record the gate must refuse, and for the right reason. |
| +func TestMovesCopyGateRefusesTheNegativeFixtures(t *testing.T) { |
| + dir := filepath.Join("testdata", "moves-copy") |
| + |
| + t.Run("a record missing an id", func(t *testing.T) { |
| + rows, err := parseMovesCopyRecord(filepath.Join(dir, "missing-id.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkMovesInventory(rows, []string{"M-C1", "M-C2", "MV-N1"}) |
| + if len(got) != 1 || !strings.Contains(got[0], "no row MV-N1") { |
| + t.Errorf("problems = %q, want exactly the missing MV-N1", got) |
| + } |
| + }) |
| + |
| + t.Run("a record with a row the inventory does not name", func(t *testing.T) { |
| + rows, err := parseMovesCopyRecord(filepath.Join(dir, "missing-id.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkMovesInventory(rows, []string{"M-C1"}) |
| + if len(got) != 1 || !strings.Contains(got[0], "does not name") { |
| + t.Errorf("problems = %q, want exactly the extra M-C2", got) |
| + } |
| + }) |
| + |
| + t.Run("a row whose text changed after approval", func(t *testing.T) { |
| + root := filepath.Join(dir, "changed-text") |
| + rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkMovesApplied(rows, root) |
| + if len(got) != 1 || !strings.Contains(got[0], "MV-N2") { |
| + t.Errorf("problems = %q, want exactly MV-N2 (MV-N1 is applied, MV-N3 is pending)", got) |
| + } |
| + }) |
| + |
| + t.Run("a pending row beside a default that is on", func(t *testing.T) { |
| + rows, err := parseMovesCopyRecord(filepath.Join(dir, "pending-default-on.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkPendingKeepsSwitchOff(rows, true) |
| + if len(got) != 1 || !strings.Contains(got[0], "MV-N2 is pending") { |
| + t.Errorf("problems = %q, want exactly MV-N2", got) |
| + } |
| + if got := checkPendingKeepsSwitchOff(rows, false); len(got) != 0 { |
| + t.Errorf("a pending row with the default off is the intended state: %q", got) |
| + } |
| + }) |
| + |
| + t.Run("an approved record may have the default on", func(t *testing.T) { |
| + rows, err := parseMovesCopyRecord(filepath.Join(dir, "changed-text", "record.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := range rows { |
| + rows[i].Status = "approved" |
| + } |
| + if got := checkPendingKeepsSwitchOff(rows, true); len(got) != 0 { |
| + t.Errorf("every row approved: %q", got) |
| + } |
| + }) |
| + |
| + t.Run("an em dash in a row", func(t *testing.T) { |
| + rows, err := parseMovesCopyRecord(filepath.Join(dir, "em-dash.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkMovesNoEmDash(rows) |
| + if len(got) != 1 || !strings.Contains(got[0], "MV-N2") { |
| + t.Errorf("problems = %q, want exactly MV-N2", got) |
| + } |
| + }) |
| +} |
| + |
| +func TestMovesTextAppliedPlaceholders(t *testing.T) { |
| + const src = "package p\n\nvar a = \"region \" + to + \" is not one of this app's regions: \"\n" |
| + cases := []struct { |
| + name, file, text string |
| + body string |
| + want bool |
| + }{ |
| + {"placeholders between literals", "x.go", "region <to> is not one of this app's regions: <list>", src, true}, |
| + {"fixed text altered", "x.go", "region <to> is not one of this app's zones: <list>", src, false}, |
| + {"fixed text in the wrong order", "x.go", "regions: <list> region <to>", src, false}, |
| + {"in a comment only", "x.go", "region <to> is gone", "package p\n\n// region x is gone\nvar a = \"\"\n", false}, |
| + {"a doc matches raw", "x.md", "region <to> is here", "region x is here", true}, |
| + {"a doc, altered", "x.md", "region <to> is here", "region x is there", false}, |
| + } |
| + for _, c := range cases { |
| + if got := movesTextApplied(c.file, c.text, []byte(c.body)); got != c.want { |
| + t.Errorf("%s: = %v, want %v", c.name, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// ---- the mechanical scan (F13) ---- |
| + |
| +// movesScanFiles are the non-test Go files the moves plans touched, from |
| +// `git diff --name-only c17f39d5..HEAD -- '*.go'` (c17f39d5 is the base of |
| +// moves-phase1). Each later plan's "Gate and copy" task appends the files it |
| +// touched; TestMovesScanFilesCoverTheDiff fails when the list falls behind. |
| +var movesScanFiles = []string{ |
| + "cmd/console/main.go", |
| + "internal/activator/backend_exec.go", |
| + "internal/appconfig/configack.go", |
| + "internal/appconfig/environments.go", |
| + "internal/appconfig/instances.go", |
| + "internal/appconfig/renderheld.go", |
| + "internal/appconfig/syncexec.go", |
| + "internal/boxid/boxidtest/boxidtest.go", |
| + "internal/boxid/internal/signtest/signtest.go", |
| + "internal/cgroup/strict_linux.go", |
| + "internal/cgroup/strict_other.go", |
| + "internal/console/apicli.go", |
| + "internal/console/apiinstancemove.go", |
| + "internal/console/apiinstances.go", |
| + "internal/console/authcli.go", |
| + "internal/console/boxselect.go", |
| + "internal/console/instancesplacement.go", |
| + "internal/console/server.go", |
| + "internal/edge/door.go", |
| + "internal/edge/edge.go", |
| + "internal/genselect/select.go", |
| + "internal/host/agent.go", |
| + "internal/host/edgesfile.go", |
| + "internal/host/heldstage.go", |
| + "internal/host/movesfloor.go", |
| + "internal/host/movesfloor_linux.go", |
| + "internal/host/movesfloor_other.go", |
| + "internal/host/movesguard.go", |
| + "internal/host/movesmeasure.go", |
| + "internal/host/movesmeasure_linux.go", |
| + "internal/host/movesmeasure_other.go", |
| + "internal/host/movespass.go", |
| + "internal/host/movesphase1.go", |
| + "internal/host/movesreport.go", |
| + "internal/host/movesstage.go", |
| + "internal/host/systemapply.go", |
| + "internal/hostbox/writer.go", |
| + "internal/hostclaim/move.go", |
| + "internal/lease/endpoint.go", |
| + "internal/lease/health.go", |
| + "internal/lease/lease.go", |
| + "internal/lease/leasetest/bucket.go", |
| + "internal/lease/leasetest/s3server.go", |
| + "internal/lease/mirror.go", |
| + "internal/lease/staging.go", |
| + "internal/moves/archive/archive.go", |
| + "internal/moves/archive/extract.go", |
| + "internal/moves/archive/limits.go", |
| + "internal/moves/archive/open_other.go", |
| + "internal/moves/archive/open_unix.go", |
| + "internal/moves/archive/seal.go", |
| + "internal/moves/archive/sync_linux.go", |
| + "internal/moves/archive/sync_other.go", |
| + "internal/moves/archive/verify.go", |
| + "internal/moves/budget.go", |
| + "internal/moves/check.go", |
| + "internal/moves/envreport.go", |
| + "internal/moves/floor.go", |
| + "internal/moves/ids.go", |
| + "internal/moves/lease.go", |
| + "internal/moves/readiness.go", |
| + "internal/moves/source.go", |
| + "internal/moves/staging.go", |
| + "internal/moves/switch.go", |
| + "internal/moves/timers.go", |
| + "internal/provision/provision.go", |
| + "internal/registry/registry.go", |
| + "internal/release/adopt.go", |
| + "internal/release/materializeheld.go", |
| +} |
| + |
| +// movesScanBase is the commit moves-phase1 branched from. |
| +const movesScanBase = "c17f39d5" |
| + |
| +// What the scan treats as an emission. A call to jsonError emits its message |
| +// (the third argument). A Check literal's Reason emits. A function in |
| +// movesCopyTables is a table of sentences, and every value in it emits. In a |
| +// file in movesLocalSinks, a call to the named local helper emits its |
| +// string argument (readiness.go's fail and reasonOr). In a file in |
| +// movesReturnedSentences, the string results of a function are sentences |
| +// (Ready and Passing return (bool, string), sourceRefusal (string, error)); |
| +// its error results are not. |
| +var movesLocalSinks = map[string]map[string]int{ |
| + "internal/moves/readiness.go": {"fail": 0, "reasonOr": 1}, |
| + "internal/host/movesreport.go": {"fail": 0}, |
| + "internal/console/apiinstancemove.go": {"moveTransitionSaying": 8}, |
| +} |
| + |
| +var movesCopyTables = map[string][]string{ |
| + // phase1MoveCopy is the Phase 1 move door's table (MV-N47 to MV-N54). |
| + // mainMoveCopy and mainGateCopy are main's bytes and are exempt. |
| + "internal/console/apiinstancemove.go": {"phase1MoveCopy"}, |
| +} |
| + |
| +var movesReturnedSentences = map[string]bool{ |
| + "internal/moves/readiness.go": true, |
| + "internal/host/movesphase1.go": true, |
| + "internal/host/movesreport.go": true, |
| + "internal/host/movesstage.go": true, |
| +} |
| + |
| +// movesLiteralFuncs are functions whose every string literal is a sentence |
| +// or a piece of one: phase1Conflict passes provision's refusals through to |
| +// the operator, and the literals it compares are exactly the ones allowed |
| +// through (anything else answers one fixed sentence). |
| +var movesLiteralFuncs = map[string][]string{ |
| + "internal/console/apiinstancemove.go": {"phase1Conflict"}, |
| +} |
| + |
| +// movesLegacyFiles are files the series touched only for plumbing (a copy |
| +// table threaded through a shared gate), whose sentences are main's own, |
| +// reviewed before this series and spoken by main's doors with the switch off. |
| +// Every jsonError in them is exempt. TestMovesLegacyExemptionsAreLegacy |
| +// proves, where git can, that the series added none of them. |
| +var movesLegacyFiles = map[string]string{ |
| + "internal/console/apicli.go": "the shared CLI gates; the series added only the gateCopy table (mainGateCopy is main's bytes)", |
| + "internal/console/apiinstances.go": "main's instance doors; the series only routed their gates through a table", |
| + "internal/console/authcli.go": "main's auth doors; the series added one helper", |
| + "internal/console/server.go": "main's console; the series wired the switch and the routes, and added no sentence", |
| +} |
| + |
| +// movesLegacyFuncs are main's functions inside files the series also wrote |
| +// into. Their sentences are main's own. |
| +var movesLegacyFuncs = map[string]map[string]string{ |
| + "internal/console/apiinstancemove.go": { |
| + "legacyHandleAPIInstanceMoveRequest": "main's move door, kept for the switch off", |
| + "moveTransition": "main's transition wrapper, which passes main's bytes to the shared body", |
| + "moveGate": "main's shared gate; its own sentence is main's, and the class rails come from the copy table", |
| + }, |
| +} |
| + |
| +// wild stands for the part of a sentence the code fills in. |
| +const wild = "\x00" |
| + |
| +var formatVerb = regexp.MustCompile(`%[-+# 0-9.*]*[a-zA-Z%]`) |
| + |
| +// splitVerbs turns a format string into a pattern. |
| +func formatPattern(f string) string { |
| + return formatVerb.ReplaceAllStringFunc(f, func(v string) string { |
| + if v == "%%" { |
| + return "%" |
| + } |
| + return wild |
| + }) |
| +} |
| + |
| +// movesPkgIndex is one directory's package-level strings and functions, so a |
| +// Reason that names a constant or a one-line helper is read through. |
| +type movesPkgIndex struct { |
| + vars map[string]ast.Expr |
| + funcs map[string]*ast.FuncDecl |
| + noCalls bool // read a call to a package function as a wildcard |
| +} |
| + |
| +var movesPkgCache = map[string]*movesPkgIndex{} |
| + |
| +func loadMovesPkg(t *testing.T, dir string) *movesPkgIndex { |
| + t.Helper() |
| + if p, ok := movesPkgCache[dir]; ok { |
| + return p |
| + } |
| + p := &movesPkgIndex{vars: map[string]ast.Expr{}, funcs: map[string]*ast.FuncDecl{}} |
| + movesPkgCache[dir] = p |
| + ents, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range ents { |
| + n := e.Name() |
| + if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { |
| + continue |
| + } |
| + f, err := parser.ParseFile(token.NewFileSet(), filepath.Join(dir, n), nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, d := range f.Decls { |
| + switch d := d.(type) { |
| + case *ast.FuncDecl: |
| + if d.Recv == nil { |
| + p.funcs[d.Name.Name] = d |
| + } |
| + case *ast.GenDecl: |
| + for _, s := range d.Specs { |
| + if vs, ok := s.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) { |
| + for i, nm := range vs.Names { |
| + p.vars[nm.Name] = vs.Values[i] |
| + } |
| + } |
| + } |
| + } |
| + } |
| + } |
| + return p |
| +} |
| + |
| +// patternsOf returns the sentence patterns an expression can produce: |
| +// literals, concatenations, fmt.Sprintf formats, package constants and |
| +// package functions that return strings. Anything it cannot read is a |
| +// wildcard. |
| +func patternsOf(e ast.Expr, pkg *movesPkgIndex, depth int) []string { |
| + switch e := e.(type) { |
| + case *ast.BasicLit: |
| + if e.Kind == token.STRING { |
| + if v, err := strconv.Unquote(e.Value); err == nil { |
| + return []string{v} |
| + } |
| + } |
| + case *ast.ParenExpr: |
| + return patternsOf(e.X, pkg, depth) |
| + case *ast.Ident: |
| + if x, ok := pkg.vars[e.Name]; ok && depth < 4 { |
| + return patternsOf(x, pkg, depth+1) |
| + } |
| + case *ast.BinaryExpr: |
| + if e.Op != token.ADD { |
| + break |
| + } |
| + var out []string |
| + for _, l := range patternsOf(e.X, pkg, depth) { |
| + for _, r := range patternsOf(e.Y, pkg, depth) { |
| + out = append(out, l+r) |
| + } |
| + } |
| + return out |
| + case *ast.CallExpr: |
| + switch fn := e.Fun.(type) { |
| + case *ast.SelectorExpr: |
| + if id, ok := fn.X.(*ast.Ident); ok && len(e.Args) > 0 && |
| + ((id.Name == "fmt" && (fn.Sel.Name == "Sprintf" || fn.Sel.Name == "Errorf")) || |
| + (id.Name == "errors" && fn.Sel.Name == "New")) { |
| + var out []string |
| + for _, f := range patternsOf(e.Args[0], pkg, depth) { |
| + out = append(out, formatPattern(f)) |
| + } |
| + return out |
| + } |
| + case *ast.Ident: |
| + if d, ok := pkg.funcs[fn.Name]; ok && !pkg.noCalls && depth < 4 && returnsOneString(d) { |
| + var out []string |
| + ast.Inspect(d.Body, func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.FuncLit: |
| + return false |
| + case *ast.ReturnStmt: |
| + if len(n.Results) == 1 { |
| + out = append(out, patternsOf(n.Results[0], pkg, depth+1)...) |
| + } |
| + } |
| + return true |
| + }) |
| + return out |
| + } |
| + } |
| + } |
| + return []string{wild} |
| +} |
| + |
| +func returnsOneString(d *ast.FuncDecl) bool { |
| + r := d.Type.Results |
| + if r == nil || len(r.List) != 1 || len(r.List[0].Names) > 1 { |
| + return false |
| + } |
| + id, ok := r.List[0].Type.(*ast.Ident) |
| + return ok && id.Name == "string" |
| +} |
| + |
| +type movesEmission struct { |
| + file string |
| + line int // where the call or literal starts |
| + endLine int |
| + fn string // the enclosing function |
| + kind string |
| + pattern string |
| +} |
| + |
| +func (e movesEmission) String() string { |
| + return fmt.Sprintf("%s:%d (%s) %q", e.file, e.line, e.kind, strings.ReplaceAll(e.pattern, wild, "%")) |
| +} |
| + |
| +// informative reports whether a pattern says anything: at least three letters |
| +// outside the parts the code fills in (a bare "a " or "S2: " is a piece of |
| +// a sentence, not one). |
| +func informative(p string) bool { |
| + n := 0 |
| + for _, r := range strings.ReplaceAll(p, wild, "") { |
| + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' { |
| + if n++; n == 3 { |
| + return true |
| + } |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// scanMovesFile returns the emissions of one file. |
| +func scanMovesFile(t *testing.T, file string) []movesEmission { |
| + t.Helper() |
| + return scanMovesFileWith(t, ".", file, false) |
| +} |
| + |
| +// scanMovesFileWith is scanMovesFile; noCalls reads calls to package |
| +// functions as wildcards, so a legacy function's sentence is only the text |
| +// written in that function. |
| +func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmission { |
| + t.Helper() |
| + at := func(p string) string { |
| + if root == "." { |
| + return p |
| + } |
| + return filepath.Join(root, p) |
| + } |
| + fset := token.NewFileSet() |
| + f, err := parser.ParseFile(fset, at(file), nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + idx := *loadMovesPkg(t, at(filepath.Dir(file))) |
| + idx.noCalls = noCalls |
| + pkg := &idx |
| + var out []movesEmission |
| + var fn string |
| + emit := func(pos token.Pos, kind string, e ast.Expr) { |
| + for _, p := range patternsOf(e, pkg, 0) { |
| + if informative(p) { |
| + out = append(out, movesEmission{file, fset.Position(pos).Line, fset.Position(e.End()).Line, fn, kind, p}) |
| + } |
| + } |
| + } |
| + isCheck := func(x ast.Expr) bool { |
| + switch x := x.(type) { |
| + case *ast.Ident: |
| + return x.Name == "Check" |
| + case *ast.SelectorExpr: |
| + return x.Sel.Name == "Check" |
| + } |
| + return false |
| + } |
| + tables := map[string]bool{} |
| + for _, n := range movesCopyTables[file] { |
| + tables[n] = true |
| + } |
| + literals := map[string]bool{} |
| + for _, n := range movesLiteralFuncs[file] { |
| + literals[n] = true |
| + } |
| + for _, d := range f.Decls { |
| + fd, ok := d.(*ast.FuncDecl) |
| + if !ok || fd.Body == nil { |
| + continue |
| + } |
| + fn = fd.Name.Name |
| + inTable := tables[fn] |
| + strs := stringResults(fd) |
| + returnsSentence := movesReturnedSentences[file] && len(strs) > 0 |
| + ast.Inspect(fd.Body, func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.BasicLit: |
| + // A literal with an em dash is main's wording, which phase1Conflict |
| + // matches and rewrites; it is never spoken. |
| + if literals[fn] && n.Kind == token.STRING && !strings.Contains(n.Value, `\u2014`) && !strings.ContainsRune(n.Value, '\u2014') { |
| + emit(n.Pos(), fn, n) |
| + } |
| + case *ast.CallExpr: |
| + name := "" |
| + switch fn := n.Fun.(type) { |
| + case *ast.Ident: |
| + name = fn.Name |
| + case *ast.SelectorExpr: |
| + name = fn.Sel.Name // a method: only local sinks, below |
| + } |
| + i, sink := movesLocalSinks[file][name] |
| + if id, ok := n.Fun.(*ast.Ident); ok && id.Name == "jsonError" { |
| + i, sink = 2, true |
| + } |
| + if sink && i < len(n.Args) { |
| + emit(n.Pos(), name, n.Args[i]) |
| + } |
| + case *ast.CompositeLit: |
| + if n.Type != nil && isCheck(n.Type) { |
| + for _, el := range n.Elts { |
| + if kv, ok := el.(*ast.KeyValueExpr); ok { |
| + if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "Reason" { |
| + emit(kv.Pos(), "Check.Reason", kv.Value) |
| + } |
| + } |
| + } |
| + } |
| + if inTable { |
| + for _, el := range n.Elts { |
| + if kv, ok := el.(*ast.KeyValueExpr); ok { |
| + emit(kv.Pos(), fd.Name.Name, kv.Value) |
| + } |
| + } |
| + } |
| + case *ast.AssignStmt: |
| + if len(n.Lhs) == 1 && len(n.Rhs) == 1 { |
| + if s, ok := n.Lhs[0].(*ast.SelectorExpr); ok && s.Sel.Name == "Reason" && isCheck(s.X) { |
| + emit(n.Pos(), "Check.Reason", n.Rhs[0]) |
| + } |
| + } |
| + case *ast.ReturnStmt: |
| + if inTable { |
| + for _, r := range n.Results { |
| + emit(r.Pos(), fd.Name.Name, r) |
| + } |
| + } else if returnsSentence { |
| + for i, r := range n.Results { |
| + if strs[i] && !isIdentOrCall(r) { |
| + emit(r.Pos(), "returned sentence", r) |
| + } |
| + } |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + return out |
| +} |
| + |
| +// stringResults is the set of a function's result positions that are strings. |
| +func stringResults(d *ast.FuncDecl) map[int]bool { |
| + out := map[int]bool{} |
| + if d.Type.Results == nil { |
| + return out |
| + } |
| + i := 0 |
| + for _, f := range d.Type.Results.List { |
| + n := len(f.Names) |
| + if n == 0 { |
| + n = 1 |
| + } |
| + if id, ok := f.Type.(*ast.Ident); ok && id.Name == "string" { |
| + for k := 0; k < n; k++ { |
| + out[i+k] = true |
| + } |
| + } |
| + i += n |
| + } |
| + return out |
| +} |
| + |
| +// isIdentOrCall is true for a returned value that is a variable or a call to |
| +// something other than fmt.Sprintf: those carry no sentence of their own. |
| +func isIdentOrCall(e ast.Expr) bool { |
| + switch e := e.(type) { |
| + case *ast.Ident, *ast.SelectorExpr: |
| + return true |
| + case *ast.CallExpr: |
| + if s, ok := e.Fun.(*ast.SelectorExpr); ok { |
| + if id, ok := s.X.(*ast.Ident); ok && id.Name == "fmt" { |
| + return false |
| + } |
| + } |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// movesRowMatches reports whether a row carries an emitted sentence: the |
| +// sentence's fixed text appears in the row (the parts the code fills in match |
| +// anything), or the row's own <placeholders> are filled by the sentence (the |
| +// row says "a <role> does not move" and the code says "a replica does not |
| +// move"). A prefix a caller adds around a reason ("S2: ") is outside the |
| +// sentence, so containment is enough. |
| +func movesRowMatches(text, pattern string) bool { |
| + parts := strings.Split(pattern, wild) |
| + for i := range parts { |
| + parts[i] = regexp.QuoteMeta(parts[i]) |
| + } |
| + if re, err := regexp.Compile(`(?s)` + strings.Join(parts, `.*`)); err == nil && re.MatchString(text) { |
| + return true |
| + } |
| + rowParts := copyPlaceholder.Split(text, -1) |
| + for i := range rowParts { |
| + rowParts[i] = regexp.QuoteMeta(rowParts[i]) |
| + } |
| + re, err := regexp.Compile(`(?s)^` + strings.Join(rowParts, `.*`) + `$`) |
| + return err == nil && re.MatchString(strings.ReplaceAll(pattern, wild, "%")) |
| +} |
| + |
| +// movesLegacy reports whether an emission is main's own. |
| +func movesLegacy(e movesEmission) bool { |
| + if _, ok := movesLegacyFiles[e.file]; ok { |
| + return true |
| + } |
| + _, ok := movesLegacyFuncs[e.file][e.fn] |
| + return ok |
| +} |
| + |
| +// findUnrecorded returns the emissions no row covers. |
| +func findUnrecorded(ems []movesEmission, rows []copyRow) []movesEmission { |
| + var out []movesEmission |
| + for _, e := range ems { |
| + if movesLegacy(e) { |
| + continue |
| + } |
| + found := false |
| + for _, r := range rows { |
| + if movesRowMatches(r.Text, e.pattern) { |
| + found = true |
| + break |
| + } |
| + } |
| + if !found { |
| + out = append(out, e) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// Every sentence the moves code emits to an operator (an HTTP error body, a |
| +// check's Reason, a phase 1 copy table's text) has a row in the record. A |
| +// sentence with no row is a finding: add it as a pending row. It is not a |
| +// reason to loosen this test. |
| +func TestMovesStringsAreRecorded(t *testing.T) { |
| + rows := mustParseMovesRecord(t) |
| + var all []movesEmission |
| + for _, f := range movesScanFiles { |
| + all = append(all, scanMovesFile(t, f)...) |
| + } |
| + if len(all) < 100 { |
| + t.Fatalf("the scan found only %d emissions; it has stopped reading the code", len(all)) |
| + } |
| + for _, e := range findUnrecorded(all, rows) { |
| + t.Errorf("no row of the record carries %s", e) |
| + } |
| +} |
| + |
| +func TestMovesScanFindsWhatItShould(t *testing.T) { |
| + const src = `package p |
| + |
| +import "fmt" |
| + |
| +const noKey = "this box has no key" |
| + |
| +func why(n int) string { return fmt.Sprintf("%d boxes publish it", n) } |
| + |
| +func f(w int, c Check) { |
| + jsonError(w, 400, "bad region "+c.Region+" given") |
| + _ = Check{Reason: noKey} |
| + _ = moves.Check{Reason: why(3)} |
| + _ = Check{Reason: "-"} |
| + _ = Check{Reason: c.Other} |
| + c.Check.Reason = fmt.Sprintf("took %d of %d", 1, 2) |
| +} |
| +` |
| + dir := t.TempDir() |
| + file := filepath.Join(dir, "p.go") |
| + if err := os.WriteFile(file, []byte(src), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + var got []string |
| + for _, e := range scanMovesFile(t, file) { |
| + got = append(got, strings.ReplaceAll(e.pattern, wild, "%")) |
| + } |
| + sort.Strings(got) |
| + want := []string{"% boxes publish it", "bad region % given", "this box has no key", "took % of %"} |
| + if strings.Join(got, "|") != strings.Join(want, "|") { |
| + t.Errorf("found %q, want %q", got, want) |
| + } |
| + |
| + rows := []copyRow{ |
| + {ID: "MV-N1", Text: `bad region <to> given`}, |
| + {ID: "MV-N2", Text: `<n> boxes publish <region>`}, |
| + {ID: "MV-N3", Text: `this box has no key`}, |
| + } |
| + ems := scanMovesFile(t, file) |
| + un := findUnrecorded(ems, rows) |
| + if len(un) != 1 || !strings.Contains(un[0].pattern, "took") { |
| + t.Errorf("unrecorded = %v, want only the 'took' sentence", un) |
| + } |
| + // A reworded row no longer covers its sentence. |
| + rows[2].Text = "this box has no secrets key" |
| + if un := findUnrecorded(ems, rows); len(un) != 2 { |
| + t.Errorf("unrecorded after a reword = %v, want 2", un) |
| + } |
| +} |
| + |
| +// The scan reads the files listed in movesScanFiles. When moves-phase1's diff |
| +// names a Go file the list does not, the scan has a blind spot: add it. The |
| +// check is skipped where git or the base commit is not available. |
| +func TestMovesScanFilesCoverTheDiff(t *testing.T) { |
| + out, err := exec.Command("git", "diff", "--name-only", movesScanBase+"..HEAD", "--", "*.go").Output() |
| + if err != nil { |
| + t.Skipf("git diff %s..HEAD: %v", movesScanBase, err) |
| + } |
| + listed := map[string]bool{} |
| + for _, f := range movesScanFiles { |
| + listed[f] = true |
| + } |
| + for _, f := range strings.Fields(string(out)) { |
| + if strings.HasSuffix(f, "_test.go") { |
| + continue |
| + } |
| + if _, err := os.Stat(f); err != nil { |
| + continue // deleted |
| + } |
| + if !listed[f] { |
| + t.Errorf("%s changed since %s and is not in movesScanFiles", f, movesScanBase) |
| + } |
| + } |
| + for _, f := range movesScanFiles { |
| + if _, err := os.Stat(f); err != nil { |
| + t.Errorf("movesScanFiles names %s, which does not exist", f) |
| + } |
| + } |
| +} |
| + |
| +// The exemptions in movesLegacyFiles and movesLegacyFuncs are only for |
| +// sentences main already had. Where git can say, every exempt emission's text |
| +// is already a string literal of the file at the base commit; a sentence the |
| +// series writes into one of these files must get a row, so this fails and |
| +// the sentence moves out of the exemption. |
| +func TestMovesLegacyExemptionsAreLegacy(t *testing.T) { |
| + files := map[string]bool{} |
| + for f := range movesLegacyFiles { |
| + files[f] = true |
| + } |
| + for f := range movesLegacyFuncs { |
| + files[f] = true |
| + } |
| + var names []string |
| + for f := range files { |
| + names = append(names, f) |
| + } |
| + sort.Strings(names) |
| + for _, f := range names { |
| + // The base commit's package, written out so the same scan reads it. |
| + root := t.TempDir() |
| + dir := filepath.Dir(f) |
| + list, err := exec.Command("git", "ls-tree", "--name-only", movesScanBase, dir+"/").Output() |
| + if err != nil { |
| + t.Skipf("git ls-tree %s: %v", movesScanBase, err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range strings.Fields(string(list)) { |
| + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { |
| + continue |
| + } |
| + body, err := exec.Command("git", "show", movesScanBase+":"+name).Output() |
| + if err != nil { |
| + t.Skipf("git show %s:%s: %v", movesScanBase, name, err) |
| + } |
| + if err := os.WriteFile(filepath.Join(root, name), body, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + inBase := map[string]bool{} |
| + for _, e := range scanMovesFileWith(t, root, f, true) { |
| + inBase[e.pattern] = true |
| + } |
| + exempt := 0 |
| + for _, e := range scanMovesFileWith(t, ".", f, true) { |
| + if !movesLegacy(e) { |
| + continue |
| + } |
| + exempt++ |
| + if !inBase[e.pattern] { |
| + t.Errorf("%s is exempt as main's own sentence, but %s at %s did not say it", e, f, movesScanBase) |
| + } |
| + } |
| + if exempt == 0 { |
| + t.Errorf("%s has an exemption but emits nothing under it: drop it", f) |
| + } |
| + } |
| + for f, fns := range movesLegacyFuncs { |
| + have := map[string]bool{} |
| + for _, e := range scanMovesFile(t, f) { |
| + have[e.fn] = true |
| + } |
| + for fn := range fns { |
| + if !have[fn] { |
| + t.Errorf("movesLegacyFuncs names %s in %s, which emits nothing: drop the entry", fn, f) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The checks bite on the real record too: each of these edits to it, made in |
| +// memory, is a failure the gate must report. |
| +func TestMovesGateBitesOnTheRealRecord(t *testing.T) { |
| + rows := mustParseMovesRecord(t) |
| + |
| + if got := checkMovesInventory(rows[1:], movesCopyIDs); len(got) != 1 || !strings.Contains(got[0], "no row "+rows[0].ID) { |
| + t.Errorf("a deleted first row: problems = %q", got) |
| + } |
| + if got := checkMovesInventory(rows[:len(rows)-1], movesCopyIDs); len(got) != 1 { |
| + t.Errorf("a deleted last row: problems = %q", got) |
| + } |
| + extra := append(append([]copyRow{}, rows...), copyRow{ID: "MV-N9999", File: "x.go", Text: "x", Status: "pending"}) |
| + if got := checkMovesInventory(extra, movesCopyIDs); len(got) != 1 || !strings.Contains(got[0], "MV-N9999") { |
| + t.Errorf("an added row: problems = %q", got) |
| + } |
| + |
| + // A row approved with text the file does not carry. |
| + changed := append([]copyRow{}, rows...) |
| + for i := range changed { |
| + if changed[i].ID == "MV-N6" { |
| + changed[i].Status = "approved" |
| + changed[i].Text = `cut must be "automatic" or "manual"` |
| + } |
| + } |
| + if got := checkMovesApplied(changed, "."); len(got) != 1 || !strings.Contains(got[0], "MV-N6") { |
| + t.Errorf("an approved row whose text is not in its file: problems = %q", got) |
| + } |
| + // The same row with the text the code speaks is applied. |
| + for i := range changed { |
| + if changed[i].ID == "MV-N6" { |
| + changed[i].Text = `cut must be "auto" or "manual"` |
| + } |
| + } |
| + if got := checkMovesApplied(changed, "."); len(got) != 0 { |
| + t.Errorf("an approved row whose text is in its file: problems = %q", got) |
| + } |
| + |
| + if got := checkPendingKeepsSwitchOff(rows, true); len(got) == 0 { |
| + t.Error("a default that is on beside pending rows must fail") |
| + } |
| +} |