// approved_moves_copy_test.go package main import ( "fmt" "go/ast" "go/parser" "go/token" "os" "os/exec" "path/filepath" "regexp" "slices" "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 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{ "MV-N391", "HC5-N1", "HC5-N2", "HC5-N3", "HC5-N4", "HC5-N5", "HC5-N6", "HC5-N7", "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-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", "MV-N148", "MV-N149", "MV-N150", "MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", "MV-N156", "MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162", "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168", "MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174", "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181", "MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186", "MV-N187", "MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306", "MV-N307", "MV-N308", "MV-N309", "MV-N310", "MV-N311", "MV-N312", "MV-N313", "MV-N314", "MV-N315", "MV-N316", "MV-N317", "MV-N318", "MV-N319", "MV-N325", "MV-N326", "MV-N327", "MV-N328", "MV-N329", "MV-N330", "MV-N331", "MV-N332", "MV-N333", "MV-N334", "MV-N335", "MV-N336", "MV-N337", "MV-N350", "MV-N351", "MV-N352", "MV-N353", "MV-N354", "MV-N355", "MV-N356", "MV-N357", "MV-N358", "MV-N359", "MV-N360", "MV-N361", "MV-N362", "MV-N363", "MV-N364", "MV-N365", "MV-N366", "MV-N367", "MV-N368", "MV-N369", "MV-N370", "MV-N371", "MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", "MV-N379", "MV-N380", "MV-N381", "MV-N385", "MV-N386", "MV-N387", "MV-N389", "MV-N390", "MV-N440", "MV-N441", "MV-N442", "MV-N443", "MV-N450", "MV-N451", "MV-N454", "MV-N455", "MV-N456", "MV-N500", "MV-N501", "MV-N502", "MV-N510", "MV-N511", "MV-N512", "MV-N513", // The offline operator move (plans/2026-10-20-offline-operator-move.md). "MV-O1", "MV-O2", "MV-O3", "MV-O4", "MV-O5", "MV-O6", "MV-O7", "MV-O8", "MV-O9", "MV-O10", "MV-O20", "MV-O21", "MV-O22", "MV-O23", "MV-O24", "MV-O25", "MV-O26", "MV-O27", "MV-O28", "MV-O29", "MV-O30", "MV-O31", "MV-O32", "MV-O33", "MV-O34", "MV-O35", "MV-O36", "MV-O37", "MV-O38", "MV-O39", "MV-O40", "MV-O41", "MV-O42", "MV-O43", "MV-O50", "MV-O51", "MV-O52", "MV-O53", "MV-O54", "MV-O55", "MV-O57", "MV-O58", "MV-O59", "MV-O60", "MV-O61", "MV-O62", "MV-O63", "MV-O64", "MV-O65", "MV-O66", "MV-O67", "MV-O68", "MV-O69", "MV-O70", "MV-O71", "MV-O72", "MV-O73", "MV-O74", "MV-O75", "MV-O76", "MV-O77", "MV-O78", "MV-O79", "MV-O80", "MV-O81", "MV-O82", "MV-O83", "MV-O84", "MV-O85", "MV-O86", "MV-O87", "MV-O88", "MV-O89", "MV-O90", "MV-O91", "MV-O92", "MV-O93", "MV-O94", "MV-O95", "MV-O96", "MV-O97", "MV-O98", "MV-O110", "MV-O111", "MV-O112", "MV-O113", "MV-O114", "MV-O115", "MV-O116", "MV-O117", "MV-O118", "MV-O119", "MV-O120", "MV-O121", "MV-O122", "MV-O123", "MV-O124", "MV-O125", "MV-O126", "MV-O127", "MV-O128", "MV-O129", "MV-O130", "MV-O131", "MV-O132", "MV-O133", "MV-O134", "MV-O135", "MV-O136", "MV-O137", "MV-O138", "MV-O139", "MV-O140", "MV-O141", "MV-O142", "MV-O143", "MV-O144", "MV-O145", "MV-O146", "MV-O147", "MV-O148", "MV-O149", "MV-O150", "MV-O151", "MV-O152", "MV-O153", "MV-O154", "MV-O155", "MV-O156", "MV-O157", "MV-O158", "MV-O159", "MV-O160", "MV-O161", "MV-O162", "MV-O163", "MV-O164", "MV-O165", "MV-O166", "MV-O167", "MV-O168", "MV-O169", "MV-O170", "MV-O171", "MV-O172", "MV-O173", "MV-O174", "MV-O175", "MV-O176", "MV-O177", "MV-O178", "MV-O179", "MV-O180", "MV-O181", "MV-O182", "MV-O183", "MV-O184", "MV-O185", "MV-O186", "MV-O187", "MV-O188", "MV-O189", "MV-O190", "MV-O191", "MV-O192", } // copyRow is one row of the record. The File cell is the file the sentence is // emitted from, and may carry a note after the path in parentheses ("path (a // T3 note, not a failure)"); the Text cell is the sentence and nothing else. type copyRow struct { ID, File, Text, Status string } // Path is the File cell without its note. func (r copyRow) Path() string { if i := strings.Index(r.File, " ("); i > 0 && strings.HasSuffix(r.File, ")") { return r.File[:i] } return r.File } var movesCopyIDPattern = regexp.MustCompile(`^(M-C|MV-N|MV-O|HC5-N|RUN-C)[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) { return parseCopyTable(src, name, movesCopyIDPattern, "an M-C, MV-N, MV-O, HC5-N or RUN-C") } // parseCopyTable reads a copy record's tables: the moves record, the run // deck and the seeded deck share the format. ids is the record's id pattern // and idKind names it in the refusal. A row's status is pending or approved. func parseCopyTable(src, name string, ids *regexp.Regexp, idKind string) ([]copyRow, error) { return parseCopyTableStatuses(src, name, ids, idKind, "pending", "approved") } // parseCopyTableStatuses is parseCopyTable for a deck with its own statuses // (the rootfs deck adds struck). func parseCopyTableStatuses(src, name string, ids *regexp.Regexp, idKind string, statuses ...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 !ids.MatchString(r.ID) { return nil, fmt.Errorf("%s: %q is not %s id", at, r.ID, idKind) } if r.File == "" || r.Text == "" { return nil, fmt.Errorf("%s: %s has an empty file or text cell", at, r.ID) } if !slices.Contains(statuses, r.Status) { if slices.Equal(statuses, []string{"pending", "approved"}) { return nil, fmt.Errorf("%s: %s has status %q, which is neither pending nor approved", at, r.ID, r.Status) } return nil, fmt.Errorf("%s: %s has status %q, which is not one of %s", at, r.ID, r.Status, strings.Join(statuses, ", ")) } 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 ; 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 { return checkMovesAppliedWith(rows, root, movesListElementFiles) } // checkMovesAppliedWith is checkMovesApplied with the map of where each list // template's element is built (movesListElementFiles for the real record). func checkMovesAppliedWith(rows []copyRow, root string, elementFiles map[string]string) []string { var out []string for _, r := range rows { if r.Status != "approved" { continue } body, err := os.ReadFile(filepath.Join(root, r.Path())) if err != nil { out = append(out, fmt.Sprintf("%s: %v", r.ID, err)) continue } if strings.HasSuffix(r.Path(), ".go") { if _, _, list, _ := movesListTemplate(r.Text); list { out = append(out, checkMovesListApplied(r, root, body, elementFiles)...) continue } } if !movesTextApplied(r.Path(), r.Text, body) { out = append(out, fmt.Sprintf("%s: %s does not contain the approved text %q", r.ID, r.Path(), r.Text)) } } return out } // ---- list templates (preflight L14, adopted by Paul 2026-10-10) ---- // // A row whose text ends in `, ...` describes a list the code builds // at run time: "pass --account-id with one of: (""), ...". No // literal in the code ends in ", ...", so the gate reads the template as two // things and checks each where the code makes it: // // - the head, the row's text with the whole list as one placeholder, is // spoken by one expression of the row's file, whose final part is the // code's own fill-in (the list is never fixed text); // - the element is built where the code builds it (the row's file, or the // file movesListElementFiles names); // - the list's own join, traced from the head's fill-in (listProducers), // is strings.Join with ", ", which is what the row's ", ..." shows. Any // other join in the file is another list's and does not count. // // The list follows the head's last ": ", as both template rows do; a row // that ends in ", ..." with no such colon, or whose element does not start // with a placeholder, cannot be read and is refused. An element that is one // placeholder (MV-N129's ) has no fixed text to check: only its // join is. // movesListTail is how a row's text ends when its last part is a list. const movesListTail = ", ..." // movesListElementFiles names, per row, the file that builds the element of // its list when that is not the row's own file. MV-N54's sentence is the move // door's (apiinstancemove.go), and its alternatives are built and joined by // the account gate it shares with every CLI door (apicli.go, main's own). var movesListElementFiles = map[string]string{ "MV-N54": "internal/console/apicli.go", } // movesListTemplate splits a row's text that ends in a list template into // its head (through the colon that introduces the list) and its element. // list reports whether the text ends in one at all; err says why one cannot // be read. func movesListTemplate(text string) (head, element string, list bool, err error) { if !strings.HasSuffix(text, movesListTail) { return "", "", false, nil } body := strings.TrimSuffix(text, movesListTail) i := strings.LastIndex(body, ": ") if i < 0 { return "", "", true, fmt.Errorf("no \": \" introduces the list") } head, element = body[:i+2], body[i+2:] if !strings.HasPrefix(element, "<") { return "", "", true, fmt.Errorf("the element %q does not start with a placeholder", element) } return head, element, true, nil } // checkMovesListApplied checks a list-template row: its head in its own // file, and its element and join where the element is built. func checkMovesListApplied(r copyRow, root string, body []byte, elementFiles map[string]string) []string { head, element, _, err := movesListTemplate(r.Text) if err != nil { return []string{fmt.Sprintf("%s: the gate cannot read the list template in %q: %v", r.ID, r.Text, err)} } var out []string var heads []textExpr for _, te := range goTextExprs(r.Path(), body) { if informative(te.pattern) && strings.HasSuffix(te.pattern, wild) && movesRowSpoken(head+"", te.pattern) { heads = append(heads, te) } } if len(heads) == 0 { out = append(out, fmt.Sprintf("%s: %s does not say %q with the list as its final fill-in", r.ID, r.Path(), head+"")) } elemFile := r.Path() if f, ok := elementFiles[r.ID]; ok { elemFile = f } elemBody, err := os.ReadFile(filepath.Join(root, elemFile)) if err != nil { return append(out, fmt.Sprintf("%s: the list's element file: %v", r.ID, err)) } if copyPlaceholder.ReplaceAllString(element, "") != "" { built := false for _, p := range goTextPatterns(elemFile, elemBody) { if movesRowSpoken(element, p) { built = true break } } if !built { out = append(out, fmt.Sprintf("%s: %s does not build the list's element %q", r.ID, elemFile, element)) } } // Every expression that says the head must make its list with a comma // join; the callers a parameter hop reaches are looked for in the head's // file and the element file. callFiles := []goFile{{r.Path(), body}} if elemFile != r.Path() { callFiles = append(callFiles, goFile{elemFile, elemBody}) } seen := map[string]bool{} for _, h := range heads { for _, p := range listProducers(h, r.Path(), callFiles) { var msg string switch { case p.why != "": msg = fmt.Sprintf("%s: the gate cannot find the strings.Join that makes the list in %s: %s", r.ID, p.file, p.why) case p.sep != ", ": msg = fmt.Sprintf("%s: %s does not join the list with \", \" (it joins with %q)", r.ID, p.file, p.sep) } if msg != "" && !seen[msg] { seen[msg] = true out = append(out, msg) } } } return out } // goFile is a Go file's name and body. type goFile struct { name string body []byte } // funcScope is a function as far as the list trace needs it: the name its // callers use (a declaration's name, or the field or variable a function // literal is bound to), its parameter names in order, and its body. type funcScope struct { name string params []string body *ast.BlockStmt } // textExpr is one expression that could hold a sentence, with its pattern and // the innermost function it sits in (nil at package level). type textExpr struct { pattern string expr ast.Expr scope *funcScope } // listProducer is one join that makes a list: its separator, or why it cannot // be found. type listProducer struct { file, sep, why string } // listProducers traces the list in a head expression to the joins that make // it. The list is the head's final fill-in: the last operand of a // concatenation, or the last argument of a format call. That must be a call // to strings.Join with a literal separator, directly or through one local // variable assigned once. A parameter of the enclosing function is followed // to its callers in files (the call's argument at the parameter's place, // traced the same way, with no further parameter hop), and there must be at // least one. Anything else is a producer the gate cannot find, and refused. // A drift lint, not a dataflow engine. func listProducers(h textExpr, headFile string, files []goFile) []listProducer { fill, why := listFill(h.expr) if why != "" { return []listProducer{{file: headFile, why: why}} } sep, param, why := joinOf(fill, h.scope, true) switch { case why != "": return []listProducer{{file: headFile, why: why}} case param < 0: return []listProducer{{file: headFile, sep: sep}} } var out []listProducer for _, f := range files { for _, c := range callsTo(f, h.scope.name) { if param >= len(c.call.Args) { out = append(out, listProducer{file: f.name, why: fmt.Sprintf("a call to %s has no argument for its parameter %s", h.scope.name, h.scope.params[param])}) continue } sep, _, why := joinOf(c.call.Args[param], c.scope, false) out = append(out, listProducer{file: f.name, sep: sep, why: why}) } } if len(out) == 0 { return []listProducer{{file: headFile, why: fmt.Sprintf("the list is %s's parameter %s, and nothing calls %s", h.scope.name, h.scope.params[param], h.scope.name)}} } return out } // listFill is the expression that fills a head's final placeholder. func listFill(e ast.Expr) (ast.Expr, string) { switch e := e.(type) { case *ast.ParenExpr: return listFill(e.X) case *ast.BinaryExpr: return e.Y, "" case *ast.CallExpr: lit, ok := e.Args[0].(*ast.BasicLit) if !ok || lit.Kind != token.STRING { return nil, "the head's format is not a literal" } f, err := strconv.Unquote(lit.Value) if err != nil { return nil, "the head's format is not a literal" } verbs := 0 for _, v := range formatVerb.FindAllString(f, -1) { if strings.Contains(v, "*") || strings.Contains(v, "[") { return nil, "the head's format takes a width or an index argument" } if v != "%%" { verbs++ } } if verbs != len(e.Args)-1 { return nil, "the head's format has a different number of verbs than arguments" } return e.Args[len(e.Args)-1], "" } return nil, "the head is not a concatenation or a format call" } // joinOf reads e as a strings.Join call and returns its separator. An // identifier assigned once in scope is followed one hop. With params, an // identifier that is one of scope's parameters returns its index (param >= 0) // for the caller to follow; otherwise param is -1. why says what could not be // read. func joinOf(e ast.Expr, scope *funcScope, params bool) (sep string, param int, why string) { for hop := 0; ; hop++ { for p, ok := e.(*ast.ParenExpr); ok; p, ok = e.(*ast.ParenExpr) { e = p.X } if c, ok := e.(*ast.CallExpr); ok && isStringsJoin(c) { lit, ok := c.Args[1].(*ast.BasicLit) if !ok || lit.Kind != token.STRING { return "", -1, "the separator of its strings.Join is not a literal" } v, err := strconv.Unquote(lit.Value) if err != nil { return "", -1, "the separator of its strings.Join is not a literal" } return v, -1, "" } id, ok := e.(*ast.Ident) if !ok { return "", -1, fmt.Sprintf("the list is %s, not a strings.Join", render(e)) } if scope == nil { return "", -1, fmt.Sprintf("the list is %s, outside any function", id.Name) } vals := assignmentsTo(scope.body, id.Name) pi := -1 for i, p := range scope.params { if p == id.Name { pi = i } } switch { case pi >= 0 && len(vals) == 0 && params: return "", pi, "" case pi >= 0 && len(vals) == 0: return "", -1, fmt.Sprintf("the list is %s, a parameter of the caller's own function (one hop only)", id.Name) case len(vals) != 1 || pi >= 0: return "", -1, fmt.Sprintf("the list is %s, which is not assigned exactly once", id.Name) case hop > 0: return "", -1, fmt.Sprintf("the list is %s, more than one local variable away from its join", id.Name) } e = vals[0] } } func isStringsJoin(c *ast.CallExpr) bool { sel, ok := c.Fun.(*ast.SelectorExpr) if !ok || sel.Sel.Name != "Join" || len(c.Args) != 2 { return false } id, ok := sel.X.(*ast.Ident) return ok && id.Name == "strings" } // assignmentsTo returns the values a function body assigns to name with =, // := or var. func assignmentsTo(body *ast.BlockStmt, name string) []ast.Expr { var out []ast.Expr if body == nil { return nil } ast.Inspect(body, func(n ast.Node) bool { switch n := n.(type) { case *ast.AssignStmt: for i, l := range n.Lhs { if id, ok := l.(*ast.Ident); ok && id.Name == name { if len(n.Lhs) == len(n.Rhs) { out = append(out, n.Rhs[i]) } else { out = append(out, nil, nil) // a multi-value assignment: not read } } } case *ast.ValueSpec: for i, nm := range n.Names { if nm.Name == name && i < len(n.Values) { out = append(out, n.Values[i]) } } } return true }) return out } // scopedCall is a call and the function it sits in. type scopedCall struct { call *ast.CallExpr scope *funcScope } // callsTo returns the calls in f to a function or field called name. func callsTo(f goFile, name string) []scopedCall { var out []scopedCall walkScoped(f, func(n ast.Node, scope *funcScope) { c, ok := n.(*ast.CallExpr) if !ok { return } switch fn := c.Fun.(type) { case *ast.Ident: if fn.Name == name { out = append(out, scopedCall{c, scope}) } case *ast.SelectorExpr: if fn.Sel.Name == name { out = append(out, scopedCall{c, scope}) } } }) return out } // walkScoped visits every node of a Go file with the innermost function it // sits in. A function literal takes the name of the field or variable it is // bound to. func walkScoped(f goFile, visit func(n ast.Node, scope *funcScope)) { file, err := parser.ParseFile(token.NewFileSet(), f.name, f.body, 0) if err != nil { return } litNames := map[*ast.FuncLit]string{} ast.Inspect(file, func(n ast.Node) bool { switch n := n.(type) { case *ast.KeyValueExpr: if k, ok := n.Key.(*ast.Ident); ok { if fl, ok := n.Value.(*ast.FuncLit); ok { litNames[fl] = k.Name } } case *ast.AssignStmt: for i, l := range n.Lhs { if id, ok := l.(*ast.Ident); ok && i < len(n.Rhs) { if fl, ok := n.Rhs[i].(*ast.FuncLit); ok { litNames[fl] = id.Name } } } case *ast.ValueSpec: for i, nm := range n.Names { if i < len(n.Values) { if fl, ok := n.Values[i].(*ast.FuncLit); ok { litNames[fl] = nm.Name } } } } return true }) paramsOf := func(ft *ast.FuncType) []string { var ps []string for _, fld := range ft.Params.List { if len(fld.Names) == 0 { ps = append(ps, "_") } for _, nm := range fld.Names { ps = append(ps, nm.Name) } } return ps } var nodes []ast.Node var scopes []*funcScope ast.Inspect(file, func(n ast.Node) bool { if n == nil { last := nodes[len(nodes)-1] nodes = nodes[:len(nodes)-1] switch last.(type) { case *ast.FuncDecl, *ast.FuncLit: scopes = scopes[:len(scopes)-1] } return true } nodes = append(nodes, n) switch n := n.(type) { case *ast.FuncDecl: scopes = append(scopes, &funcScope{name: n.Name.Name, params: paramsOf(n.Type), body: n.Body}) case *ast.FuncLit: scopes = append(scopes, &funcScope{name: litNames[n], params: paramsOf(n.Type), body: n.Body}) } var scope *funcScope if len(scopes) > 0 { scope = scopes[len(scopes)-1] } visit(n, scope) return true }) } // movesTextApplied reports whether file's body speaks the row's text. In a Go // file the text must be one expression: a string literal, a concatenation of // literals, or one Sprintf or errors.New format. The row's // and the code's %s match each other, but fragments in unrelated literals are // never joined, and a comment never counts. Any other file is matched raw, // the fixed text between placeholders in order. func movesTextApplied(file, text string, body []byte) bool { if !strings.HasSuffix(file, ".go") { rest := string(body) for _, s := range copyPlaceholder.Split(text, -1) { i := strings.Index(rest, s) if i < 0 { return false } rest = rest[i+len(s):] } return true } for _, p := range goTextPatterns(file, body) { if informative(p) && movesRowSpoken(text, p) { return true } } return false } // goTextPatterns returns the pattern of every expression in a Go file that // could hold a sentence whole: a string literal, a concatenation, or one // Sprintf or errors.New format, with the file's consts and vars read in // place. A file that does not parse has none. func goTextPatterns(file string, body []byte) []string { var out []string for _, te := range goTextExprs(file, body) { out = append(out, te.pattern) } return out } // goTextExprs is goTextPatterns with each pattern's expression and function. func goTextExprs(file string, body []byte) []textExpr { f, err := parser.ParseFile(token.NewFileSet(), file, body, 0) if err != nil { return nil } vars := map[string]ast.Expr{} for _, d := range f.Decls { if g, ok := d.(*ast.GenDecl); ok { for _, sp := range g.Specs { if vs, ok := sp.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) { for i, nm := range vs.Names { vars[nm.Name] = vs.Values[i] } } } } } var out []textExpr walkScoped(goFile{file, body}, func(n ast.Node, scope *funcScope) { var e ast.Expr switch n := n.(type) { case *ast.BasicLit: if n.Kind == token.STRING { e = n } case *ast.BinaryExpr: if n.Op == token.ADD { e = n } case *ast.CallExpr: if isFormatCall(n) { e = n } } if e != nil { for _, p := range literalPatterns(e, vars, 0) { out = append(out, textExpr{p, e, scope}) } } }) return out } // 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() var rows []copyRow seen := map[string]bool{} // Claim Plan 5 owns this additional refusal. Keep its pending record // beside the integration tests without editing the separate Moves plan. for _, path := range []string{movesCopyRecordPath, "testdata/claim-moves-copy.md"} { part, err := parseMovesCopyRecord(path) if err != nil { t.Fatal(err) } if len(part) == 0 { t.Fatalf("%s has no rows", path) } for _, row := range part { if seen[row.ID] { t.Fatalf("copy id %s occurs in multiple records", row.ID) } seen[row.ID] = true } rows = append(rows, part...) } 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 . | 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 .", "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) } } } // A note after the path stays out of the sentence and out of the file name. func TestCopyRowPath(t *testing.T) { rows, err := parseMovesCopyTable("| id | file | text | status |\n|---|---|---|---|\n"+ "| MV-N1 | a/b.go (a T3 note, not a failure) | () | pending |\n"+ "| MV-N2 | a/b.go | a sentence | pending |\n", "x.md") if err != nil { t.Fatal(err) } if rows[0].Path() != "a/b.go" || rows[1].Path() != "a/b.go" || rows[0].Text != " ()" { t.Errorf("rows = %+v", rows) } } 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) } }) // The three notation rules Paul adopted for the list templates // (preflight L14, 2026-10-10). Each fixture holds a row the rule lets // through and rows it must refuse, each refused for its own reason. t.Run("list templates: the head with the list as its fill-in, the element where it is built", func(t *testing.T) { root := filepath.Join(dir, "list-template") rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) if err != nil { t.Fatal(err) } got := checkMovesAppliedWith(rows, root, map[string]string{"MV-N2": "elem.go", "MV-N3": "elem.go"}) sort.Strings(got) want := []struct{ id, why string }{ {"MV-N3", "does not build the list's element \" []\""}, {"MV-N4", "with the list as its final fill-in"}, {"MV-N5", "cannot read the list template"}, {"MV-N6", "with the list as its final fill-in"}, } if len(got) != len(want) { t.Fatalf("problems = %q, want exactly MV-N3 to MV-N6 (MV-N1 and MV-N2 are spoken)", got) } for i, w := range want { if !strings.HasPrefix(got[i], w.id+":") || !strings.Contains(got[i], w.why) { t.Errorf("problem %d = %q, want %s refused because it %s", i, got[i], w.id, w.why) } } }) t.Run("a list template joined with anything but a comma", func(t *testing.T) { root := filepath.Join(dir, "list-join") rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) if err != nil { t.Fatal(err) } got := checkMovesAppliedWith(rows, root, nil) if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], `does not join the list with ", "`) { t.Errorf("problems = %q, want exactly MV-N1, for its join", got) } }) // Fix round 1: the join is the list's own, traced from the head's fill-in, // never any join in the file. t.Run("an unrelated comma join does not answer for the list's own join", func(t *testing.T) { root := filepath.Join(dir, "list-join-two") rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) if err != nil { t.Fatal(err) } got := checkMovesAppliedWith(rows, root, nil) if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], `does not join the list with ", " (it joins with " and ")`) { t.Errorf("problems = %q, want exactly MV-N1, for its own join (MV-N2's comma join is another list's)", got) } }) t.Run("the join that makes the list must be found", func(t *testing.T) { root := filepath.Join(dir, "list-producer") rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) if err != nil { t.Fatal(err) } got := checkMovesAppliedWith(rows, root, nil) sort.Strings(got) want := []struct{ id, why string }{ {"MV-N1", "cannot find the strings.Join that makes the list"}, {"MV-N2", "cannot find the strings.Join that makes the list"}, {"MV-N4", `does not join the list with ", " (it joins with " or ")`}, } if len(got) != len(want) { t.Fatalf("problems = %q, want exactly MV-N1, MV-N2 and MV-N4 (MV-N3 is spoken)", got) } for i, w := range want { if !strings.HasPrefix(got[i], w.id+":") || !strings.Contains(got[i], w.why) { t.Errorf("problem %d = %q, want %s refused because it %s", i, got[i], w.id, w.why) } } }) t.Run("%q prints quotes, so a row shows them and a %s does not satisfy them", func(t *testing.T) { root := filepath.Join(dir, "quoted-verb") rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) if err != nil { t.Fatal(err) } got := checkMovesApplied(rows, root) if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N2:") { t.Errorf("problems = %q, want exactly MV-N2 (its row shows quotes the code's %%s does not print)", got) } }) t.Run("a note belongs in the File cell, never the Text cell", func(t *testing.T) { root := filepath.Join(dir, "file-note") rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) if err != nil { t.Fatal(err) } got := checkMovesApplied(rows, root) if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], "does not contain the approved text") { t.Errorf("problems = %q, want exactly MV-N1 (its note is in the Text cell)", got) } }) } func TestMovesFormatPatternQuotesQ(t *testing.T) { cases := map[string]string{ "account %q is ambiguous": `account "` + wild + `" is ambiguous`, "%s (%q)": wild + ` ("` + wild + `")`, "%-10q left": `"` + wild + `" left`, "took %d of %d": "took " + wild + " of " + wild, "85%% full": "85% full", "%v and %s": wild + " and " + wild, } for in, want := range cases { if got := formatPattern(in); got != want { t.Errorf("formatPattern(%q) = %q, want %q", in, got, want) } } } func TestMovesListTemplate(t *testing.T) { cases := []struct { text, head, element string list, readable bool }{ {`account "" is ambiguous. pass --account-id with one of: (""), ...`, `account "" is ambiguous. pass --account-id with one of: `, ` ("")`, true, true}, {"the launch environment differs from the source's: , ...", "the launch environment differs from the source's: ", "", true, true}, {"no list here", "", "", false, true}, {"a list without a colon , ...", "", "", true, false}, {"a colon then words: the , ...", "", "", true, false}, {"an empty element: , ...", "", "", true, false}, } for _, c := range cases { head, element, list, err := movesListTemplate(c.text) if list != c.list || (err == nil) != c.readable || head != c.head || element != c.element { t.Errorf("movesListTemplate(%q) = %q, %q, %v, %v; want %q, %q, list %v, readable %v", c.text, head, element, list, err, c.head, c.element, c.list, c.readable) } } } // Every entry of movesListElementFiles is a list-template row of the real // record whose element file exists, so the map never names a row that has no // element to check. func TestMovesListElementFilesAreHonest(t *testing.T) { byID := map[string]copyRow{} for _, r := range mustParseMovesRecord(t) { byID[r.ID] = r } for id, file := range movesListElementFiles { r, ok := byID[id] if !ok { t.Errorf("movesListElementFiles names %s, which the record has no row for", id) continue } if _, _, list, err := movesListTemplate(r.Text); !list || err != nil { t.Errorf("movesListElementFiles names %s, whose text %q is not a readable list template", id, r.Text) } if _, err := os.Stat(file); err != nil { t.Errorf("movesListElementFiles: %s's element file: %v", id, err) } } } 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 is not one of this app's regions: ", src, true}, {"fixed text altered", "x.go", "region is not one of this app's zones: ", src, false}, {"fixed text in the wrong order", "x.go", "regions: region ", src, false}, {"in a comment only", "x.go", "region is gone", "package p\n\n// region x is gone\nvar a = \"\"\n", false}, {"a doc matches raw", "x.md", "region is here", "region x is here", true}, {"a doc, altered", "x.md", "region 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 ..HEAD -- '*.go'`, where base is the main commit // moves-phase1 last merged (movesScanBaseRef). Each later plan's "Gate and // copy" task appends the files it touched; TestMovesScanFilesCoverTheDiff // fails when the list falls behind. var movesScanFiles = []string{ "internal/host/relocationflush.go", "internal/host/relocationinstall.go", "internal/host/rootfloor_reconstruct.go", "internal/host/rootfloor_available.go", "internal/host/rootfloor_healthy.go", "internal/host/rootmovesfloor.go", "internal/host/rootmovesfloor_boot.go", "internal/host/rootmovesfloor_refresh.go", "internal/host/rootmovesfloor_source.go", "internal/host/rootseedsfloor.go", "internal/host/rootseedsfloor_publication.go", // Claim-owned pages share the alert transport; keep their drafts in the copy census. "internal/console/claim_loss_pages.go", "internal/host/claimhandover_crossbox.go", "internal/host/claimhandover_recovery.go", "internal/host/claimretire_local.go", "internal/hostops/move_fence_release.go", "internal/host/movessources.go", "internal/host/movesrefusals.go", "internal/console/movesrecovery.go", "internal/console/apiinstancemove_offline.go", "internal/console/claim_runtime.go", "internal/alerting/alerting.go", "internal/hostops/move_log_writer.go", "internal/hostops/move_recovery_grant.go", "internal/hostops/move_loss_owner.go", "internal/host/movesrecoveryprobe.go", "internal/host/movesrecoveryowners.go", "internal/host/movesloss.go", "internal/host/claimproduction.go", "internal/host/claimowners.go", "internal/host/claimmoveaccess.go", "cmd/console/main.go", "internal/activator/backend_exec.go", "internal/activator/claimwake.go", "internal/activator/probewake.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/dbverify/dbverify.go", "internal/edge/door.go", "internal/edge/edge.go", "internal/genselect/select.go", "internal/host/agent.go", "internal/host/buildstate.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/movesfloorgate.go", "internal/host/movesfloorgate_linux.go", "internal/host/movesfloorgate_other.go", "internal/host/movesevents.go", "internal/host/moveslogoutbox.go", "internal/host/movesabort.go", "internal/host/movesarchive.go", "internal/host/movescut.go", "internal/host/movesfinal.go", "internal/host/movesguard.go", "internal/host/movesheldenv.go", "internal/host/movesheldlock.go", "internal/host/claimstop_run.go", "internal/host/movesfiles.go", "internal/host/movesinstall.go", "internal/host/moveswakesource.go", "internal/host/moveshold.go", "internal/host/movesland.go", "internal/host/moveslandcommit.go", "internal/host/moveslandinstall.go", "internal/host/moveslandpark.go", "internal/host/moveslandprobe.go", "internal/host/movesmeasure.go", "internal/host/movesmeasure_linux.go", "internal/host/movesmeasure_other.go", "internal/host/movesparks.go", "internal/host/movespaging.go", "internal/host/movespass.go", "internal/host/movesoffline.go", "internal/host/movesoffline_seal.go", "internal/host/movesoffline_land.go", "internal/host/movesoffline_stage.go", "internal/host/movesphase1.go", "internal/host/movespolicy.go", "internal/host/movesprobe.go", "internal/host/movesprobeattempts.go", "internal/host/movesprobeowners.go", "internal/host/movesrecover.go", "internal/host/movesreport.go", "internal/host/movesretire.go", "internal/host/movesrevert.go", "internal/host/movesrollback.go", "internal/host/movesstage.go", // moves after claim Plan 12, Task 7b: root's held release. "internal/host/movesheldrelease.go", "internal/host/movesheldrelease_linux.go", "internal/host/movesheldrelease_production.go", "internal/host/movesheldrelease_root.go", "internal/host/movesstore.go", "internal/host/refusalwatch.go", "internal/host/systemapply.go", "internal/hostbox/restore.go", "internal/hostbox/writer.go", "internal/hostclaim/move.go", "internal/hostops/storage_classifier.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/lockstate.go", "internal/lease/mirror.go", "internal/lease/staging.go", "internal/lease/versioned.go", "internal/moves/abort.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/build.go", "internal/moves/check.go", "internal/moves/envreport.go", "internal/moves/evidence.go", "internal/moves/floor.go", "internal/moves/ids.go", "internal/moves/journal.go", "internal/moves/lease.go", "internal/moves/log.go", "internal/moves/ltxbuild.go", "internal/moves/offline.go", "internal/moves/operatorcopy.go", "internal/moves/parkscope.go", "internal/moves/probe.go", "internal/moves/readiness.go", "internal/moves/returnlane.go", "internal/moves/seal.go", "internal/moves/sealedclient.go", "internal/moves/source.go", "internal/moves/staging.go", "internal/moves/startcheck.go", "internal/moves/switch.go", "internal/moves/timers.go", "internal/moves/transition.go", "internal/provision/provision.go", "internal/provision/reconcile.go", "internal/registry/registry.go", "internal/release/adopt.go", "internal/release/materializeheld.go", "internal/release/proofgate.go", "internal/release/refusal.go", "internal/schedule/converge.go", "internal/schedule/hostgate.go", "internal/schedule/runner.go", "internal/stopevidence/reconstruct.go", "internal/stopevidence/reconstruct_linux.go", "internal/stopevidence/reconstruct_other.go", "main.go", "move.go", // Region HA stage B: relocation files that import internal/moves (the // shared build, the probe attempts, the landing and wake owners). "internal/host/relocationattempts.go", "internal/host/relocationbuild.go", "internal/host/relocationlanding.go", "internal/host/relocationowners.go", "internal/host/relocationwake.go", } // The scan's base is the main commit moves-phase1 last merged: the diff from // it to HEAD is the series' own, and a string literal it holds is main's own // sentence. Ruling merge-scanbase: the tests ask git for it // (`git merge-base HEAD origin/main`), so a merge of main needs no edit here. // movesScanBase is only the fallback where git or origin/main is not // available: the merge base when this was last written (c17f39d5, the branch // point, then cbc0922e, then 3407d114, claim C5/C7). const movesScanBase = "3407d114" // movesMergeBase asks git for the commit HEAD and origin/main last shared. A // variable, so the fallback can be tested. var movesMergeBase = func() (string, error) { out, err := exec.Command("git", "merge-base", "HEAD", "origin/main").Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } // resolveMovesScanBase is the base the scan's git checks diff from: what // lookup says, or pinned when it fails or says nothing. from names which. func resolveMovesScanBase(lookup func() (string, error), pinned string) (base, from string) { if b, err := lookup(); err == nil && b != "" { return b, "git merge-base HEAD origin/main" } return pinned, "the pinned movesScanBase" } // movesScanBaseRef is the base for this run, logged so a failure says which. // // Where git cannot name the merge base (a CI checkout with no origin/main), // the checks that need it skip rather than diff from the pinned commit: a // stale pin turns every commit main gained since into a false finding on // every other branch. func movesScanBaseRef(t *testing.T) string { t.Helper() base, from := resolveMovesScanBase(movesMergeBase, movesScanBase) if base == movesScanBase && from == "the pinned movesScanBase" { t.Skip("no merge base with origin/main in this checkout; the diff checks run where git can name it") } t.Logf("scan base %s, from %s", base, from) return base } // 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). A function listed in // movesReturnedSentences returns sentences in its string results (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}, // The landing's item reasons (offline move Task 5): every reason goes // through one of these, never an error's text (MV-O110 on). "internal/host/movesoffline_land.go": {"landWait": 1, "landWaitErr": 1, "landFail": 1, "landFailErr": 1, "landFailAll": 0, "landWaitAll": 0}, "internal/console/apiinstancemove.go": {"moveTransitionSaying": 8}, // Every operator sentence of `carlos move` goes through moveSay, L13's // say (named so because the scan resolves helpers by name, and // internal/release has a say of its own). // Text the console wrote goes through saySpoken instead, which is no // sink here: it takes only serverText (movesServerText). "move.go": {"moveSay": 1}, } 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]map[string]bool{ "internal/moves/readiness.go": {"Passing": true, "Ready": true, "budgetEvidence": true, "reasonOr": true, "NoFreshRate": true}, "internal/host/movesphase1.go": {"sourceRefusal": true, "routeRefusal": true, "publishArchive": true, "archiveFailure": true, "s1NotExec": true, "s1Sidecar": true, "s1Column": true, "s1PrivateStorage": true, "s1SpecialFile": true, "s1OverCap": true, "s1LongPath": true, "s1NoAnswer": true, "s1ManyBoxes": true, "oneBoxRefusal": true}, "internal/host/movesreport.go": {"t1Below": true, "t3Differs": true, "t3Shadowed": true, "t4Failed": true, "t6NoRoom": true, "t8Count": true, "t12NotQuiet": true, "replicaReason": true}, "internal/host/movesrollback.go": {"stuckPage": true}, // memberLine is what an app's members read for a move event (Plan 6 // Task 3, preflight L9), each from a shared template below. "internal/host/movesevents.go": {"memberLine": true}, // The operator sentences `carlos move` and the member stream share // (preflight L12, Task 3 fix round 1): one template each, which the // copy record names. The gate reads a call to one from either package // (resolveCallees' cross-package rule). "internal/moves/operatorcopy.go": {"MoveStartingLine": true, "CutStartingLine": true, "FlippedLine": true, "MovedLine": true, "MovingBackLine": true, "MovedBackLine": true, "StuckMemberLine": true}, // moveDeleteRefusal is M-C19, which the delete door answers (Task 2b). "internal/console/apiinstancemove.go": {"moveDeleteRefusal": true}, // The moves evidence's anomaly page (Plan 5 Task 9a, MV-N336 and MV-N337). "internal/host/movesrefusals.go": {"movesAnomalyPage": true}, // The box's move pages (Plan 6 Task 5, MV-N385 to MV-N390) and M-C20, // the operators' sentence for a stray process (the stray page's body). "internal/host/movespaging.go": {"rolledBackPage": true, "strayPage": true, "strayLine": true, "startRefusedPage": true}, // The offline sealer's lost-stop page, its two causes and the lease // states the first names (offline move Task 4, MV-O66 to MV-O75). "internal/host/movesoffline_seal.go": {"offlineLostStopPage": true, "offlineLeaseCause": true, "offlinePastSealCause": true, "offlineLeaseText": true}, // The console's move pass pages (Plan 5 Task 7, MV-N325 to MV-N334), and // the dirty fenced target's page (moves after claim Plan 12, Task 5, // MV-N450 and MV-N451). "internal/console/movesrecovery.go": {"moveWaitingPage": true, "movePreparingPageText": true, "moveOrphanPage": true, "moveCloseOverduePage": true, "moveAbortOverduePage": true, "moveTargetFencedDirtyPage": 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"}, "internal/console/apiinstancemove_offline.go": {"offlineConflict"}, } // 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%]`) // formatPattern turns a format string into a pattern. A verb is the code's // fill-in, except that %q prints its quotes, so it is a quoted fill-in and a // row shows the quotes (""), preflight L14 as Paul adopted it on // 2026-10-10. %#q may print backquotes instead, so it stays a bare fill-in. func formatPattern(f string) string { return formatVerb.ReplaceAllStringFunc(f, func(v string) string { switch { case v == "%%": return "%" case strings.HasSuffix(v, "q") && !strings.Contains(v, "#"): return `"` + wild + `"` } return wild }) } // ---- sinks must take sentences the gate can read ---- // movesFuncSinks are calls that take a sentence-producing function as an // argument (the index is where): the argument must be a recognised sentence // helper, and the parameter that receives it may be called inside the callee. var movesFuncSinks = map[string]map[string]int{ "internal/console/apiinstancemove.go": {"moveTransitionSaying": 7}, } // movesSinkAllow exempts a function's sinks from the readable-sentence rule. // An entry is only for a value that never reaches an operator, and says why. // A value that does reach an operator is mapped to a recorded sentence in the // code instead. TestMovesSinkAllowListIsHonest holds the list to this. var movesSinkAllow = map[string]map[string]string{ "move.go": { "moveFlagHelp": "the flag help: the flag package's error for a bad flag and its rendering of the usage strings parseMoveArgs declares; they go to copy review with the CLI page (preflight L13, L16), and no row can carry the flag package's layout", }, } // movesDiagnosticWrites are field writes the scan leaves out of a field's // writes because what they write is an OS diagnostic for the operator's // eye, not copy. Paul's rule (2026-10-16): internal error fragments and // diagnostics stay out of copy review. The key is "|.". // An entry leaves out only the composite-literal writes of that field in that // function of that file whose value calls Error(), and says how many there are // (Count), so the field's other writes in the same function stay scanned, and // so do its other writers (the Phase 1 Reason reads in move.go among them). // TestMovesDiagnosticWritesAreReal holds each entry to exactly that many. type movesDiagnosticWrite struct { Why string Count int } var movesDiagnosticWrites = map[string]map[string]movesDiagnosticWrite{ "internal/host/movesoffline.go": { "retire|RetiredPath.Reason": { Why: "the parked report's reason for a path left in place ends in the OS error of the rename (leftCannot or leftNotYet plus err.Error()), a box diagnostic that `carlos move offline status` shows as MV-O58's ; it is not copy", Count: 2, }, }, "internal/host/movesoffline_seal.go": { "offlineTakeSeal|sealRefusal.Reason": { Why: "the env-pin refusal for a CARLOS_SECRETS_KEY that does not decode is moves.ReportKeyFromSecrets' own error text (err.Error()), a box diagnostic that `carlos move offline status` shows as MV-O55's ; it is not copy", Count: 1, }, "offlineArchive|sealRefusal.Reason": { Why: "the files refusal for an archive Build refuses is archive.Build's own error text (err.Error()), whose sentinels are already rows MV-N77 to MV-N81 and MV-N86; a box diagnostic that `carlos move offline status` shows as MV-O55's ", Count: 1, }, }, } // movesServerText names, per file, the type that carries text the console // wrote (fix round 2 of Plan 6 Task 1a, ruling 1). It is made in one function // that reads an HTTP response body, and said through one sink; the gate // exempts that sink by its type (the scan never sees it: it is no local // sink), and TestMovesServerTextHasOneMaker holds the rest. Every other value // at a sink is traced to a copy row. var movesServerText = map[string]movesServerTextSpec{ "move.go": {Type: "serverText", Field: "spoken", Maker: "mover.roundTrip", Sink: "saySpoken"}, } // movesMappingHelpers are functions that map a refusal from another package // onto the sentence an operator sees, echoing the refusal's own text only in a // case whose guard tests that text against recorded literals (equality, // HasPrefix, HasSuffix or Contains with a literal). A `return x` in such a case // is taken; any other return is checked as usual. Anything else answers a fixed // sentence. var movesMappingHelpers = map[string]map[string]bool{ "internal/console/apiinstancemove.go": {"phase1Conflict": true}, "internal/console/apiinstancemove_offline.go": {"offlineConflict": true}, } // diagnosticPrefixes are the ids the readiness code puts before a reason // ("T11: ", "S2: ") and the record leaves on its row or off it. var diagnosticPrefixes = []string{"T11: ", "S1: ", "S2: "} // movesRowMatches reports whether a row carries an emitted sentence, whole: // the sentence equals the row's text, where the parts the code fills in // match anything, or the row's own are filled by the sentence // (the row says "a does not move" and the code says "a replica does // not move"). A diagnostic prefix ("S2: ") may sit on either side. A // sentence that only appears inside a row does not match: "valid region" is // not "invalid region". func movesRowMatches(text, pattern string) bool { return rowMatches(text, pattern, false) } // movesRowSpoken is the stricter match for an approved row: the code must say // all of the row's fixed text. The code's own fill-ins may only stand where // the row has a , never where the row has words. func movesRowSpoken(text, pattern string) bool { return rowMatches(text, pattern, true) } func rowMatches(text, pattern string, strict bool) bool { forms := []string{text} for _, p := range diagnosticPrefixes { if strings.HasPrefix(text, p) { forms = append(forms, strings.TrimPrefix(text, p)) } } parts := strings.Split(pattern, wild) for i := range parts { parts[i] = regexp.QuoteMeta(parts[i]) } code := regexp.MustCompile(`(?s)^` + strings.Join(parts, `.*`) + `$`) rendered := strings.ReplaceAll(pattern, wild, "%") for _, f := range forms { if !strict && code.MatchString(f) { return true } rowParts := copyPlaceholder.Split(f, -1) for i := range rowParts { rowParts[i] = regexp.QuoteMeta(rowParts[i]) } if regexp.MustCompile(`(?s)^` + strings.Join(rowParts, `.*`) + `$`).MatchString(rendered) { return true } } return false } // movesFragmentMatches is the match for a piece of a sentence: a literal // phase1Conflict tests with HasPrefix or Contains. Some row must contain it. func movesFragmentMatches(text, pattern string) bool { return strings.Contains(text, 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. A value the gate cannot // derive, inside a function movesSinkAllow lists, made from none of its string // or error parameters, is that entry's (flag help): its reason says whose it // is, so no row could carry it. A value made from a parameter is never // skipped (a forwarding helper's text is traced where it is called, not // exempted), and a sentence the gate can derive still needs its row. func findUnrecorded(ems []movesEmission, rows []copyRow) []movesEmission { var out []movesEmission for _, e := range ems { if movesLegacy(e) { continue } if _, allowed := movesSinkAllow[e.file][e.fn]; allowed && e.underived && !e.fromParam { continue } found := false for _, r := range rows { match := movesRowMatches if e.fragment { match = movesFragmentMatches } if match(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)} 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 given`}, {ID: "MV-N2", Text: ` boxes publish `}, {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) } } // movesProductionGoFile reports whether a path from the diff is a production // Go file: not a test, and not under a testdata directory (the go tool // ignores those, and the negative fixtures live there). func movesProductionGoFile(path string) bool { if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return false } for _, el := range strings.Split(path, "/") { if el == "testdata" { return false } } return true } // movesUncovered returns the production files in changed that listed lacks. // exists says whether a path is still in the tree (a deleted file needs no // entry). func movesUncovered(changed, listed []string, exists, movesCode func(string) bool) []string { have := map[string]bool{} for _, f := range listed { have[f] = true } var out []string for _, f := range changed { if movesProductionGoFile(f) && exists(f) && movesCode(f) && !have[f] { out = append(out, f) } } return out } // movesCodeFile is a Go file the moves series owns or speaks through: one in // internal/moves, or one that imports it. Another branch's change to a file // with no tie to moves is not this gate's to inventory. func movesCodeFile(path string) bool { if strings.HasPrefix(path, "internal/moves/") { return true } body, err := os.ReadFile(path) if err != nil { return true // unreadable: inventory it rather than pass it } return strings.Contains(string(body), `"github.com/carlosframework/platform/internal/moves`) } func TestMovesScanFilesInventoryRules(t *testing.T) { exists := func(p string) bool { return p != "internal/gone.go" } changed := []string{ "internal/moves/new.go", // production: needs an entry "internal/moves/new_test.go", // a test "testdata/moves-copy/changed-text/applied.go", // a fixture "internal/host/testdata/fixture/emit.go", // a fixture below a package "internal/gone.go", // deleted "docs/superpowers/plans/2026-10-06-copy.md", // not Go "internal/moves/listed.go", // listed "internal/host/usesmoves.go", // imports moves: needs an entry "internal/changelog/other.go", // another branch's file, no tie to moves } movesCode := func(p string) bool { return strings.HasPrefix(p, "internal/moves/") || p == "internal/host/usesmoves.go" } got := movesUncovered(changed, []string{"internal/moves/listed.go"}, exists, movesCode) if len(got) != 2 || got[0] != "internal/moves/new.go" || got[1] != "internal/host/usesmoves.go" { t.Errorf("uncovered = %q, want internal/moves/new.go and internal/host/usesmoves.go", got) } } // Ruling merge-scanbase: git's merge base wins, and the pinned constant is // only the fallback. Both paths, with the lookup faked. func TestMovesScanBaseResolution(t *testing.T) { cases := []struct { name string lookup func() (string, error) base, from string }{ {"git answers", func() (string, error) { return "0123abcd", nil }, "0123abcd", "git merge-base HEAD origin/main"}, {"git fails (no git, no origin/main)", func() (string, error) { return "", fmt.Errorf("exit status 128") }, "pinnedsha", "the pinned movesScanBase"}, {"git says nothing", func() (string, error) { return "", nil }, "pinnedsha", "the pinned movesScanBase"}, } for _, c := range cases { base, from := resolveMovesScanBase(c.lookup, "pinnedsha") if base != c.base || from != c.from { t.Errorf("%s: = %q from %q, want %q from %q", c.name, base, from, c.base, c.from) } } // The real lookup, where git and origin/main are there, names a commit // HEAD contains. b, err := movesMergeBase() if err != nil || b == "" { t.Skipf("git merge-base HEAD origin/main: %v", err) } if err := exec.Command("git", "merge-base", "--is-ancestor", b, "HEAD").Run(); err != nil { t.Errorf("the merge base %s is not an ancestor of HEAD: %v", b, err) } } // The scan reads the files listed in movesScanFiles. When moves-phase1's diff // names a production 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) { base := movesScanBaseRef(t) out, err := exec.Command("git", "diff", "--name-only", base+"..HEAD", "--", "*.go").Output() if err != nil { t.Skipf("git diff %s..HEAD: %v", base, err) } exists := func(p string) bool { _, err := os.Stat(p); return err == nil } for _, f := range movesUncovered(strings.Fields(string(out)), movesScanFiles, exists, movesCodeFile) { t.Errorf("%s changed since %s and is not in movesScanFiles", f, base) } for _, f := range movesScanFiles { if _, err := os.Stat(f); err != nil { t.Errorf("movesScanFiles names %s, which does not exist", f) } } } // movesExemptionHolds reports whether a sentence under a legacy exemption // may stay exempt: main already had it (inBase), or it is the text of an // approved, built row of another deck (approvedElsewhere), as the rootfs // series' rewrite of a sentence in an exempt file is (controller ruling, // rootfs Plan 1 Task 12). func movesExemptionHolds(pattern string, inBase, approvedElsewhere map[string]bool) bool { return inBase[pattern] || approvedElsewhere[pattern] } // rootfsBuiltTexts is the text of every approved row in rootfsCopyBuilt, as // the code prints it (rootfsAppliedText, as TestRootfsBuiltCopyIsApplied // reads it). func rootfsBuiltTexts(rows []copyRow) map[string]bool { out := map[string]bool{} for _, r := range builtRows(rows, rootfsCopyBuilt) { if r.Status == "approved" { out[rootfsAppliedText(r.Text)] = true } } return out } func TestMovesExemptionHolds(t *testing.T) { inBase := map[string]bool{"a sentence main had": true} elsewhere := rootfsBuiltTexts(mustParseRootfsRecord(t)) rootfs := `invalid kind (want "binary", "static" or "rootfs")` if !elsewhere[rootfs] { t.Fatalf("the rootfs deck has no approved, built row %q", rootfs) } for _, tc := range []struct { pattern string want bool }{ {"a sentence main had", true}, {rootfs, true}, {"a sentence in neither deck", false}, {`invalid kind (want "binary" or "static" or "rootfs")`, false}, } { if got := movesExemptionHolds(tc.pattern, inBase, elsewhere); got != tc.want { t.Errorf("movesExemptionHolds(%q) = %v, want %v", tc.pattern, got, tc.want) } } // A row that is not built, or not approved, is not approved copy. rows := []copyRow{ {ID: "RFS-1-C22", Text: "built and approved", Status: "approved"}, {ID: "RFS-1-C23", Text: "built but pending", Status: "pending"}, {ID: "RFS-9-C01", Text: "approved but not built", Status: "approved"}, } got := rootfsBuiltTexts(rows) if !got["built and approved"] || got["built but pending"] || got["approved but not built"] { t.Errorf("rootfsBuiltTexts = %v, want only the built, approved row", got) } } // The exemptions in movesLegacyFiles and movesLegacyFuncs are only for // sentences main already had, or approved copy of another deck. 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) { base := movesScanBaseRef(t) approvedElsewhere := rootfsBuiltTexts(mustParseRootfsRecord(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", base, dir+"/").Output() if err != nil { t.Skipf("git ls-tree %s: %v", base, 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", base+":"+name).Output() if err != nil { t.Skipf("git show %s:%s: %v", base, 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 !movesExemptionHolds(e.pattern, inBase, approvedElsewhere) { t.Errorf("%s is exempt as main's own sentence, but %s at %s did not say it", e, f, base) } } 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) } // The real record may have no pending rows left once Paul approves them // all, so the switch check bites on the record plus one pending row. if got := checkPendingKeepsSwitchOff(extra, true); len(got) == 0 { t.Error("a default that is on beside pending rows must fail") } } // movesViolationExempt reports whether a violation is main's own (legacy) or // is allow-listed with a reason. func movesViolationExempt(v movesViolation) bool { if _, ok := movesLegacyFiles[v.file]; ok { return true } if _, ok := movesLegacyFuncs[v.file][v.fn]; ok { return true } _, ok := movesSinkAllow[v.file][v.fn] return ok } // Every value that reaches an operator sink in the scanned files is a // sentence the gate can read: a literal, a const, a concatenation or Sprintf // with a literal format, or a call to a sentence helper. A forwarded // err.Error(), a variable holding text, or an expression the gate cannot read // fails here, naming file:line, because the record cannot vouch for text the // gate never sees. func TestMovesSinksAreSentences(t *testing.T) { for _, f := range movesScanFiles { _, vios := scanMovesFileFull(t, ".", f, false) for _, v := range vios { if !movesViolationExempt(v) { t.Errorf("%s", v) } } } } // ---- negative fixtures for the sink rule, the matcher and the placeholder check ---- // fixtureScan scans a sink fixture in its own universe, and returns the // universe too, for the function-value check. func fixtureScan(t *testing.T, dir string) ([]movesEmission, []movesViolation, *movesUniverse) { t.Helper() file := filepath.Join("testdata", "moves-copy", "sinks", dir, "emit.go") u := newUniverse(t, ".", []string{file}) ems, vios := scanMovesFileIn(t, u, ".", file, false) return ems, vios, u } // fixtureRows is the fixture's record.md, or a record with one unrelated row. func fixtureRows(t *testing.T, dir string) []copyRow { t.Helper() path := filepath.Join("testdata", "moves-copy", "sinks", dir, "record.md") if _, err := os.Stat(path); err == nil { rows, err := parseMovesCopyRecord(path) if err != nil { t.Fatal(err) } return rows } return []copyRow{{ID: "MV-N1", File: "x.go", Text: "an unrelated sentence", Status: "pending"}} } func wantViolations(t *testing.T, name string, got []movesViolation, wants ...string) { t.Helper() if len(got) != len(wants) { t.Errorf("%s: %d violations %v, want %d", name, len(got), got, len(wants)) return } for i, w := range wants { if !strings.Contains(got[i].String(), w) { t.Errorf("%s: violation %d = %q, want one containing %q", name, i, got[i], w) } } } // Each counterexample fails the sink gate, and, where the value is one the // record should have seen, fails the record gate too: an underived value matches // no row, and a substituted or named-result sentence is read as it is spoken. func TestMovesSinkFixtures(t *testing.T) { cases := []struct { dir string vios []string // substrings, in order unrecorded int // emissions no row of the fixture's record covers }{ {"forwarded-error", []string{ "emit.go:7 (jsonError in refuse): err.Error(...) forwards an error's text", "emit.go:18 (Check.Reason in report): helper why, at ", }, 2}, {"sprintf-v", []string{ "emit.go:9 (jsonError in viaSprintf): err puts an error's text in a sentence", "emit.go:9 (jsonError in viaSprintf): a value with no text of its own", "emit.go:13 (jsonError in viaConcat): err.Error(...) puts an error's text in a sentence", "emit.go:13 (jsonError in viaConcat): a value with no text of its own", "emit.go:17 (jsonError in viaVerbs): err puts an error's text in a sentence", "emit.go:17 (jsonError in viaVerbs): a value with no text of its own", }, 3}, {"field-reason", []string{ "emit.go:12 (jsonError in positional): a write to field Reason at ", "emit.go:12 (jsonError in positional): a write to field Reason at ", "emit.go:12 (jsonError in positional): a write to field Reason at ", "emit.go:17 (jsonError in keyed): a write to field Reason at ", "emit.go:17 (jsonError in keyed): a write to field Reason at ", "emit.go:17 (jsonError in keyed): a write to field Reason at ", "emit.go:22 (jsonError in assigned): a write to field Reason at ", }, 4}, {"local-variable", []string{ "emit.go:10 (jsonError in mixed): err.Error(...) forwards an error's text", "emit.go:15 (jsonError in fromMultiValue): helper reasonOf, at ", "emit.go:24 (jsonError in rangeVar): n is assigned a value the gate cannot read", }, 4}, {"helper-bad-return", []string{ "emit.go:7 (jsonError in refuse): helper say, at testdata/moves-copy/sinks/helper-bad-return/emit.go:14:2: err.Error(...) forwards an error's text", }, 2}, {"param", []string{ "emit.go:7 (jsonError in refuse): err.Error(...) forwards an error's text", "emit.go:7 (jsonError in refuse): helper wrap, at testdata/moves-copy/sinks/param/emit.go:11:2: m is an error's text (it is bound to err.Error(...)", }, 2}, {"substitution", nil, 1}, {"named-result", nil, 1}, {"shadow", []string{ "emit.go:12 (jsonError in refuse): say at ", }, 1}, {"interface", []string{ "emit.go:17 (jsonError in viaInterface): m.Message(...) at ", "emit.go:21 (jsonError in viaUnknown): x.Message(...)", }, 2}, {"funcvalue", []string{ "emit.go:8 (jsonError in send): parameter msg is not bound to an argument", "emit.go:23 (jsonError in report): parameter msg is not bound to an argument", }, 2}, {"limit", []string{ "emit.go:19 (jsonError in chainLiteral): analysis limit reached at ", "emit.go:35 (jsonError in chainError): analysis limit reached at ", }, 2}, {"unresolved", []string{ "emit.go:6 (jsonError in methodResult): s.render(...): no method render on server in this package", "emit.go:10 (jsonError in indexed): names is not a sentence the gate can read", "emit.go:14 (Check.Reason in bare): a value with no text of its own", "emit.go:18 (jsonError in dynamicFormat): fmt.Sprintf(...) has a format that is not a literal", "emit.go:18 (jsonError in dynamicFormat): parameter format is not bound to an argument", "emit.go:22 (jsonError in field): r.Message: no struct in the scanned files declares a field Message", }, 5}, // Fix round 4. Every format in error-text matches a row of its record, // so the only unrecorded emissions are the underived ones: a row never // vouches for an error's text. {"error-text", []string{ "emit.go:16 (jsonError in formatArg): err puts an error's text in a sentence", "emit.go:20 (jsonError in concatError): err.Error(...) puts an error's text in a sentence", "emit.go:25 (jsonError in wrapVerb): why is the argument of a %w verb", "emit.go:30 (jsonError in implementsError): r puts an error's text in a sentence", "emit.go:35 (jsonError in unknownErr): lastErr puts an error's text in a sentence", "emit.go:40 (jsonError in errorField): res.Err puts an error's text in a sentence", "emit.go:46 (jsonError in localText): detail puts an error's text in a sentence", "emit.go:51 (jsonError in viaHelper): helper explain, at testdata/moves-copy/sinks/error-text/emit.go:55:2: m puts an error's text in a sentence", }, 8}, // Fix round 5. MV-N2 matches every format here; the helper's body and // the field's writes are what the gate refuses. {"error-helper", []string{ "emit.go:14 (jsonError in viaHelper): detail(...) puts an error's text in a sentence (helper detail returns ", "emit.go:18 (jsonError in viaHelperConcat): detail(...) puts an error's text in a sentence (helper detail returns ", "emit.go:35 (jsonError in viaOpaque): opaque(...) puts text the gate cannot derive in a sentence (helper opaque returns ", "emit.go:44 (jsonError in viaEcho): echo(...) puts an error's text in a sentence (helper echo returns ", "emit.go:55 (jsonError in viaField): f.Detail puts an error's text in a sentence (a write to field Detail at testdata/moves-copy/sinks/error-helper/emit.go:51:25: it calls Error", "emit.go:59 (jsonError in viaFieldConcat): f.Detail puts an error's text in a sentence (a write to field Detail at testdata/moves-copy/sinks/error-helper/emit.go:51:25: it calls Error", }, 6}, {"field-limit", []string{ "emit.go:26 (jsonError in placeholder): n.Reason puts text the gate cannot derive in a sentence (a write to field Reason at testdata/moves-copy/sinks/field-limit/emit.go:22:22: it is assigned a1 at testdata/moves-copy/sinks/field-limit/emit.go:11:8, and analysis limit reached at ", }, 2}, // Both reads are of refusal.Reason, so each sees both writes. {"field-multi", []string{ "emit.go:16 (jsonError in multi): a write to field Reason at testdata/moves-copy/sinks/field-multi/emit.go:15:2 takes one result of a multi-value expression", "emit.go:16 (jsonError in multi): a write to field Reason at testdata/moves-copy/sinks/field-multi/emit.go:22:2 takes one result of a multi-value expression", "emit.go:24 (jsonError in commaOK): a write to field Reason at testdata/moves-copy/sinks/field-multi/emit.go:15:2 takes one result of a multi-value expression", "emit.go:24 (jsonError in commaOK): a write to field Reason at testdata/moves-copy/sinks/field-multi/emit.go:22:2 takes one result of a multi-value expression", }, 2}, {"shadow-receiver", []string{ "emit.go:15 (jsonError in viaShadow): m.Message(...) at testdata/moves-copy/sinks/shadow-receiver/emit.go:15:21: the receiver m is declared more than once in viaShadow", }, 1}, {"ok", nil, 0}, } for _, c := range cases { ems, vios, _ := fixtureScan(t, c.dir) wantViolations(t, c.dir, vios, c.vios...) if un := findUnrecorded(ems, fixtureRows(t, c.dir)); len(un) != c.unrecorded { t.Errorf("%s: %d unrecorded %v, want %d", c.dir, len(un), un, c.unrecorded) } } // The sentences that are read as spoken. for dir, want := range map[string]string{"substitution": "an unrecorded sentence", "named-result": "an unrecorded sentence"} { ems, _, _ := fixtureScan(t, dir) if len(ems) != 1 || ems[0].pattern != want { t.Errorf("%s: emissions = %v, want exactly %q", dir, ems, want) } } } // A sentence-flow function referenced other than as a direct call lets a call // through the value escape the argument checks. func TestMovesFunctionValueFixture(t *testing.T) { _, _, u := fixtureScan(t, "funcvalue") var got []string for _, r := range funcValueRefs(t, u) { got = append(got, r.msg) } want := []string{"emit.go:16:7: send is used as a value", "emit.go:27:7: s.report is used as a value"} if len(got) != len(want) { t.Fatalf("refs = %q, want %d", got, len(want)) } for i, w := range want { if !strings.Contains(got[i], w) { t.Errorf("ref %d = %q, want one containing %q", i, got[i], w) } } _, _, ok := fixtureScan(t, "ok") if refs := funcValueRefs(t, ok); len(refs) != 0 { t.Errorf("the ok fixture: refs = %v", refs) } } // A local closure configured as a sink (verifyArchive's fail) is a // sentence-flow function too: an alias of it (send := fail) escapes the // checks at fail's call sites, so the alias is refused. func TestMovesLocalClosureAliasFixture(t *testing.T) { file := filepath.Join("testdata", "moves-copy", "sinks", "closure-alias", "emit.go") movesLocalSinks[file] = map[string]int{"fail": 0} defer delete(movesLocalSinks, file) _, vios, u := fixtureScan(t, "closure-alias") if len(vios) != 0 { t.Errorf("violations = %v, want none: the alias is the function-value check's", vios) } var got []string for _, r := range funcValueRefs(t, u) { got = append(got, r.msg) } if len(got) != 1 || !strings.Contains(got[0], "emit.go:18:10: fail is used as a value") { t.Errorf("refs = %q, want exactly fail at emit.go:18:10", got) } // Without the configuration fail is an ordinary closure, and its own // parameter is unbound at its sink. delete(movesLocalSinks, file) if _, vios, _ := fixtureScan(t, "closure-alias"); len(vios) == 0 { t.Error("an unconfigured closure sink must still be a violation") } } // A mapping helper may echo a refusal's text only in a case whose guard tests // it against literals. func TestMovesMappingHelperFixture(t *testing.T) { file := filepath.Join("testdata", "moves-copy", "sinks", "mapping", "emit.go") movesMappingHelpers[file] = map[string]bool{"mapRefusal": true, "unguarded": true, "badGuard": true} defer delete(movesMappingHelpers, file) _, vios := scanMovesFileFull(t, ".", file, false) wantViolations(t, "a mapping helper", vios, "emit.go:8 (jsonError in refuse): helper unguarded, at ", "emit.go:9 (jsonError in refuse): helper badGuard, at ", ) // Without the configuration the same echo is refused. delete(movesMappingHelpers, file) _, vios = scanMovesFileFull(t, ".", file, false) if len(vios) != 3 { t.Errorf("an unconfigured mapping helper: %d violations, want 3: %v", len(vios), vios) } } // An allow-list entry exempts a sink, but it needs a reason and must still // exempt something. func checkSinkAllow(allow map[string]map[string]string, vios []movesViolation) []string { used := map[string]bool{} for _, v := range vios { used[v.file+" "+v.fn] = true } var out []string for file, fns := range allow { for fn, why := range fns { if len(strings.Fields(why)) < 5 { out = append(out, fmt.Sprintf("%s %s: the allow-list entry needs a comment saying why the value never reaches an operator", file, fn)) } if !used[file+" "+fn] { out = append(out, fmt.Sprintf("%s %s: the allow-list entry exempts nothing; drop it", file, fn)) } } } sort.Strings(out) return out } func TestMovesSinkAllowListIsHonest(t *testing.T) { var all []movesViolation for _, f := range movesScanFiles { _, v := scanMovesFileFull(t, ".", f, false) all = append(all, v...) } reportAll(t, checkSinkAllow(movesSinkAllow, all)) vios := []movesViolation{{file: "a.go", fn: "f"}} if got := checkSinkAllow(map[string]map[string]string{"a.go": {"f": "because"}}, vios); len(got) != 1 || !strings.Contains(got[0], "needs a comment") { t.Errorf("an entry with no reason: %q", got) } if got := checkSinkAllow(map[string]map[string]string{"a.go": {"g": "this value is only ever logged"}}, vios); len(got) != 1 || !strings.Contains(got[0], "exempts nothing") { t.Errorf("an entry that exempts nothing: %q", got) } if got := checkSinkAllow(map[string]map[string]string{"a.go": {"f": "this value is only ever logged"}}, vios); len(got) != 0 { t.Errorf("a good entry: %q", got) } // An allow-listed function's underivable value needs no row; its // derivable sentences still do, and another function's value does. saved := movesSinkAllow defer func() { movesSinkAllow = saved }() movesSinkAllow = map[string]map[string]string{"a.go": {"f": "passes on a sentence recorded elsewhere"}} ems := []movesEmission{ {file: "a.go", fn: "f", kind: "say", underived: true}, {file: "a.go", fn: "f", kind: "say", pattern: "an unrecorded sentence of f"}, {file: "a.go", fn: "f", kind: "say", underived: true, fromParam: true}, {file: "a.go", fn: "g", kind: "say", underived: true}, } if un := findUnrecorded(ems, nil); len(un) != 3 || un[0].fn != "f" || un[0].underived || !un[1].fromParam || un[2].fn != "g" { t.Errorf("unrecorded = %v, want f's derived sentence, f's value made from a parameter, and g's value", un) } } // "valid region" is not "invalid region", though the record's text contains it. func TestMovesWholeSentencesOnly(t *testing.T) { dir := filepath.Join("testdata", "moves-copy", "substring") rows, err := parseMovesCopyRecord(filepath.Join(dir, "record.md")) if err != nil { t.Fatal(err) } ems := scanMovesFile(t, filepath.Join(dir, "emit.go")) un := findUnrecorded(ems, rows) if len(un) != 1 || un[0].pattern != "valid region" { t.Errorf("unrecorded = %v, want only \"valid region\"", un) } cases := []struct { row, pattern string want bool }{ {"invalid region", "invalid region", true}, {"invalid region", "valid region", false}, {"invalid region", "invalid", false}, {"region is gone", "region " + wild + " is gone", true}, {"a does not move", "a replica does not move", true}, {"S2: the next spawn of this host is not strict", "the next spawn of this host is not strict", true}, {"T11: the cut budget admits no probe attempt", "T11: " + wild, true}, {"S2: the next spawn is not strict", "the next spawn is not strict now", false}, {"the source did not answer", "S1: the source did not answer", false}, } for _, c := range cases { if got := movesRowMatches(c.row, c.pattern); got != c.want { t.Errorf("movesRowMatches(%q, %q) = %v, want %v", c.row, c.pattern, got, c.want) } } } // Approved placeholder text is matched inside one expression. Fragments in // unrelated literals are never joined. func TestMovesApprovedPlaceholderTextIsOneExpression(t *testing.T) { root := filepath.Join("testdata", "moves-copy", "assembled") rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) if err != nil { t.Fatal(err) } got := checkMovesApplied(rows, root) if len(got) != 2 { t.Fatalf("problems = %q, want exactly MV-N1 (unrelated fragments) and MV-N4 (only the start of the sentence)", got) } sort.Strings(got) if !strings.HasPrefix(got[0], "MV-N1:") || !strings.HasPrefix(got[1], "MV-N4:") { t.Errorf("problems = %q", got) } } // literalPatterns reads the texts of an expression written in place: literals, // concatenations, Sprintf and errors.New formats, and consts of the same file. // What it cannot read is a wildcard. func literalPatterns(e ast.Expr, vars map[string]ast.Expr, depth int) []string { if depth > 6 { return []string{wild} } switch e := e.(type) { case *ast.BasicLit: if v, err := strconv.Unquote(e.Value); err == nil && e.Kind == token.STRING { return []string{v} } case *ast.ParenExpr: return literalPatterns(e.X, vars, depth) case *ast.Ident: if x, ok := vars[e.Name]; ok { return literalPatterns(x, vars, depth+1) } case *ast.BinaryExpr: if e.Op == token.ADD { var out []string for _, l := range literalPatterns(e.X, vars, depth) { for _, r := range literalPatterns(e.Y, vars, depth) { out = append(out, l+r) } } return out } case *ast.CallExpr: if isFormatCall(e) { var out []string for _, f := range literalPatterns(e.Args[0], vars, depth+1) { out = append(out, formatPattern(f)) } return out } } return []string{wild} } // A function that takes a sentence and is referenced other than as a direct // call (`f := send`, a method value, an argument) lets a call through the // value escape the argument checks. The real tree has none, bar the allow-list. func TestMovesFunctionValuesAreNotSinks(t *testing.T) { u := universeFor(t, ".", movesScanFiles[0]) for _, f := range movesScanFiles { scanMovesFileIn(t, u, ".", f, false) } refs := funcValueRefs(t, u) reportAll(t, checkFuncValueAllow(movesFuncValueAllow, refs)) for _, r := range refs { if _, ok := movesFuncValueAllow[r.file][r.name]; !ok { t.Error(r.msg) } } } // An allow-list entry needs a real reason and must still exempt a reference. func checkFuncValueAllow(allow map[string]map[string]string, refs []funcRef) []string { used := map[string]bool{} for _, r := range refs { used[r.file+" "+r.name] = true } var out []string for file, names := range allow { for name, why := range names { if len(strings.Fields(why)) < 8 { out = append(out, fmt.Sprintf("%s %s: the allow-list entry needs a real reason", file, name)) } if !used[file+" "+name] { out = append(out, fmt.Sprintf("%s %s: the allow-list entry exempts nothing; drop it", file, name)) } } } sort.Strings(out) return out } // Fix round 2 of Plan 6 Task 1a, ruling 1: text the console wrote reaches an // operator only as serverText, made in one function that reads an HTTP // response body and said only through its one sink. Every other value at a // sink is traced to a copy row. The re-review's three ways round that, and a // forged serverText, each fail the gate. func TestMovesProvenanceBypassesFail(t *testing.T) { file := filepath.Join("testdata", "moves-copy", "sinks", "provenance", "emit.go") saved := movesLocalSinks[file] movesLocalSinks[file] = map[string]int{"moveSay": 1, "sayError": 1, "sayRecorded": 1} defer func() { if saved == nil { delete(movesLocalSinks, file) } else { movesLocalSinks[file] = saved } }() u := newUniverse(t, ".", []string{file}) ems, vios := scanMovesFileIn(t, u, ".", file, false) failed := map[string]bool{} for _, e := range findUnrecorded(ems, nil) { failed[e.fn] = true } for _, v := range vios { failed[v.fn] = true } spec := movesServerTextSpec{Type: "serverText", Field: "spoken", Maker: "roundTrip", Sink: "saySpoken"} for _, p := range checkServerText(t, file, spec) { failed[p.fn] = true } for _, fn := range []string{"literalJSON", "forgedStuck", "sayRecorded", "forgedServer", "leaked"} { if !failed[fn] { t.Errorf("%s gets round the gate", fn) } } } // move.go's serverText has one maker, roundTrip, which reads the body of a // response it got from the HTTP client, and one sink, saySpoken. func TestMovesServerTextHasOneMaker(t *testing.T) { for file, spec := range movesServerText { for _, p := range checkServerText(t, file, spec) { t.Errorf("%s: %s", file, p) } } if _, ok := movesServerText["move.go"]; !ok { t.Fatal("move.go declares no serverText") } } // Fix round 3 of Plan 6 Task 1a: two cheap holes in server text's provenance. // Server text may not be a package-level value (a var or const of a type that // is or holds serverText, or a composite literal outside a function body), and // inside its one maker its field is a value read from the decoded response, // never a string literal or a named constant. func TestMovesServerTextRefusesForgedValues(t *testing.T) { spec := movesServerTextSpec{Type: "serverText", Field: "spoken", Maker: "mover.roundTrip", Sink: "saySpoken"} for _, c := range []struct { dir string want []string }{ {"server-text-var", []string{ ": declares forged, a package-level serverText", ": declares forgedList, a package-level serverText", }}, {"server-text-maker", []string{ `roundTrip: makes a serverText from "unreviewed literal in the maker", not a value read from the response`, "roundTrip: makes a serverText from unreviewed, not a value read from the response", "roundTrip: makes a serverText from local, not a value read from the response", }}, } { file := filepath.Join("testdata", "moves-copy", "sinks", c.dir, "emit.go") var got []string for _, p := range checkServerText(t, file, spec) { got = append(got, p.String()) } sort.Strings(got) want := append([]string(nil), c.want...) sort.Strings(want) if strings.Join(got, "\n") != strings.Join(want, "\n") { t.Errorf("%s:\n got %q\nwant %q", c.dir, got, want) } } } // Every movesDiagnosticWrites entry leaves out exactly the writes it counts, // and the reason it gives is a sentence. func TestMovesDiagnosticWritesAreReal(t *testing.T) { u := newUniverse(t, ".", movesScanFiles) for f, keys := range movesDiagnosticWrites { for key, w := range keys { if len(w.Why) < 40 { t.Errorf("%s %s: the reason is too short to be one", f, key) } if got := u.diagSkipped[f+"|"+key]; got != w.Count { t.Errorf("%s %s leaves out %d writes, want exactly %d", f, key, got, w.Count) } } } }