carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// approved_moves_copy_test.go
2package main
3
4import (
5 "fmt"
6 "go/ast"
7 "go/parser"
8 "go/token"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "regexp"
13 "sort"
14 "strconv"
15 "strings"
16 "testing"
17
18 "github.com/carlosframework/platform/internal/moves"
19)
20
21// The copy gate of the moves series (Phase 1 plan 1, Task 15), beside the
22// nearest-region gate in approved_copy_test.go. The record is a markdown
23// table with a status column, not JSON, so it has its own parser; the checks
24// are the same in spirit:
25//
26// - TestMovesCopyRecordIsComplete: the record names exactly the ids the
27// pinned inventory names, so a deleted row is caught;
28// - TestMovesApprovedCopyIsApplied: an approved row's text is in the file
29// the row names;
30// - TestPendingCopyKeepsTheSwitchOff: while a row is pending,
31// moves.DefaultEnabled is false;
32// - TestMovesStringsAreRecorded: every operator-visible sentence the moves
33// code emits has a row (the mechanical half of review finding F13).
34
35const movesCopyRecordPath = "docs/superpowers/plans/2026-10-06-moves-phase1-copy.md"
36
37// movesCopyIDs is the record's inventory, pinned as a literal: the spec's
38// M-C1 to M-C22 (rev 7.21) and every MV-N<n> the plans introduce. Each plan's
39// "Gate and copy" task appends its ids here as it appends its rows to the
40// record. The record and this literal must agree both ways.
41var movesCopyIDs = []string{
42 "MV-N391",
43 "HC5-N1", "HC5-N2", "HC5-N3",
44 "HC5-N4", "HC5-N5", "HC5-N6", "HC5-N7",
45 "M-C1", "M-C2", "M-C3", "M-C4", "M-C5", "M-C6", "M-C7", "M-C8", "M-C9",
46 "M-C10", "M-C11", "M-C12", "M-C13", "M-C14", "M-C15", "M-C16", "M-C17",
47 "M-C18", "M-C19", "M-C20", "M-C21", "M-C22", "MV-N1", "MV-N2", "MV-N3",
48 "MV-N4", "MV-N5", "MV-N6", "MV-N7", "MV-N8", "MV-N9", "MV-N10", "MV-N11",
49 "MV-N12", "MV-N13", "MV-N14", "MV-N15", "MV-N16", "MV-N17", "MV-N18",
50 "MV-N19", "MV-N20", "MV-N22", "MV-N23", "MV-N24", "MV-N25",
51 "MV-N26", "MV-N27", "MV-N28", "MV-N29", "MV-N30", "MV-N31", "MV-N32",
52 "MV-N33", "MV-N34", "MV-N35", "MV-N36", "MV-N37", "MV-N38", "MV-N39",
53 "MV-N40", "MV-N41", "MV-N42", "MV-N43", "MV-N44", "MV-N45", "MV-N46",
54 "MV-N47", "MV-N48", "MV-N49", "MV-N50", "MV-N51", "MV-N52", "MV-N53",
55 "MV-N54", "MV-N55", "MV-N56", "MV-N57", "MV-N58", "MV-N59", "MV-N60",
56 "MV-N61", "MV-N62", "MV-N63", "MV-N64", "MV-N65", "MV-N66", "MV-N67",
57 "MV-N68", "MV-N69", "MV-N70", "MV-N71", "MV-N72", "MV-N73", "MV-N74",
58 "MV-N75", "MV-N76", "MV-N77", "MV-N78", "MV-N79", "MV-N80", "MV-N81",
59 "MV-N82", "MV-N83", "MV-N84", "MV-N85", "MV-N86", "MV-N87", "MV-N88",
60 "MV-N89", "MV-N90", "MV-N91", "MV-N92", "MV-N93", "MV-N94", "MV-N95",
61 "MV-N96", "MV-N97", "MV-N98", "MV-N99", "MV-N100", "MV-N101", "MV-N102",
62 "MV-N103", "MV-N104", "MV-N105", "MV-N106", "MV-N107", "MV-N108",
63 "MV-N109", "MV-N110", "MV-N111", "MV-N112", "MV-N113", "MV-N114",
64 "MV-N115", "MV-N116", "MV-N117", "MV-N118", "MV-N119", "MV-N120",
65 "MV-N121", "MV-N122", "MV-N123", "MV-N124", "MV-N125", "MV-N126",
66 "MV-N127", "MV-N128", "MV-N129", "MV-N130", "MV-N131", "MV-N132",
67 "MV-N133", "MV-N134", "MV-N135", "MV-N136", "MV-N137", "MV-N138",
68 "MV-N139", "MV-N140", "MV-N141", "MV-N142", "MV-N143", "MV-N144",
69 "MV-N145", "MV-N146", "MV-N147", "MV-N148", "MV-N149", "MV-N150",
70 "MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", "MV-N156",
71 "MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162",
72 "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168",
73 "MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174",
74 "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181",
75 "MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186", "MV-N187",
76 "MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306",
77 "MV-N307", "MV-N308", "MV-N309", "MV-N310", "MV-N311", "MV-N312",
78 "MV-N313", "MV-N314",
79 "MV-N315", "MV-N316", "MV-N317", "MV-N318",
80 "MV-N319",
81 "MV-N325", "MV-N326", "MV-N327", "MV-N328", "MV-N329", "MV-N330", "MV-N331", "MV-N332", "MV-N333", "MV-N334",
82 "MV-N335", "MV-N336", "MV-N337",
83 "MV-N350", "MV-N351", "MV-N352", "MV-N353", "MV-N354", "MV-N355", "MV-N356",
84 "MV-N357", "MV-N358", "MV-N359", "MV-N360", "MV-N361", "MV-N362", "MV-N363", "MV-N364",
85 "MV-N365", "MV-N366", "MV-N367", "MV-N368", "MV-N369", "MV-N370", "MV-N371",
86 "MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", "MV-N379",
87 "MV-N380", "MV-N381",
88 "MV-N385", "MV-N386", "MV-N387", "MV-N389", "MV-N390",
89 "MV-N440", "MV-N441", "MV-N442", "MV-N443",
90 "MV-N450", "MV-N451", "MV-N454", "MV-N455", "MV-N456",
91 "MV-N500",
92 "MV-N501", "MV-N502",
93 "MV-N510", "MV-N511", "MV-N512", "MV-N513",
94 // The offline operator move (plans/2026-10-20-offline-operator-move.md).
95 "MV-O1", "MV-O2", "MV-O3", "MV-O4", "MV-O5", "MV-O6", "MV-O7", "MV-O8", "MV-O9", "MV-O10",
96 "MV-O20", "MV-O21", "MV-O22", "MV-O23", "MV-O24", "MV-O25", "MV-O26", "MV-O27", "MV-O28", "MV-O29", "MV-O30", "MV-O31",
97 "MV-O32", "MV-O33",
98 "MV-O34", "MV-O35", "MV-O36", "MV-O37", "MV-O38", "MV-O39", "MV-O40", "MV-O41", "MV-O42", "MV-O43",
99 "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",
100 "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",
101 "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",
102 "MV-O98",
103 "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",
104 "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",
105 "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",
106 "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",
107 "MV-O172", "MV-O173", "MV-O174", "MV-O175", "MV-O176", "MV-O177",
108 "MV-O178",
109 "MV-O179", "MV-O180", "MV-O181", "MV-O182", "MV-O183", "MV-O184", "MV-O185", "MV-O186", "MV-O187", "MV-O188", "MV-O189",
110 "MV-O190", "MV-O191", "MV-O192",
111}
112
113// copyRow is one row of the record. The File cell is the file the sentence is
114// emitted from, and may carry a note after the path in parentheses ("path (a
115// T3 note, not a failure)"); the Text cell is the sentence and nothing else.
116type copyRow struct {
117 ID, File, Text, Status string
118}
119
120// Path is the File cell without its note.
121func (r copyRow) Path() string {
122 if i := strings.Index(r.File, " ("); i > 0 && strings.HasSuffix(r.File, ")") {
123 return r.File[:i]
124 }
125 return r.File
126}
127
128var movesCopyIDPattern = regexp.MustCompile(`^(M-C|MV-N|MV-O|HC5-N|RUN-C)[0-9]+$`)
129
130// parseMovesCopyRecord reads the record's tables. A data row is a line that
131// follows a `| id | file | text | status |` header; the separator line is
132// skipped, and prose and headings are not rows. The text cell may hold a
133// pipe: the id and file are the first two cells and the status is the last.
134func parseMovesCopyRecord(path string) ([]copyRow, error) {
135 raw, err := os.ReadFile(path)
136 if err != nil {
137 return nil, err
138 }
139 return parseMovesCopyTable(string(raw), path)
140}
141
142func parseMovesCopyTable(src, name string) ([]copyRow, error) {
143 return parseCopyTable(src, name, movesCopyIDPattern, "an M-C<n>, MV-N<n>, MV-O<n>, HC5-N<n> or RUN-C<n>")
144}
145
146// parseCopyTable reads a copy record's tables: the moves record, the run
147// deck and the seeded deck share the format. ids is the record's id pattern
148// and idKind names it in the refusal.
149func parseCopyTable(src, name string, ids *regexp.Regexp, idKind string) ([]copyRow, error) {
150 var rows []copyRow
151 seen := map[string]int{}
152 inTable := false
153 for n, line := range strings.Split(src, "\n") {
154 line = strings.TrimSpace(line)
155 if !strings.HasPrefix(line, "|") {
156 inTable = false
157 continue
158 }
159 at := fmt.Sprintf("%s:%d", name, n+1)
160 if !strings.HasSuffix(line, "|") || len(line) < 2 {
161 return nil, fmt.Errorf("%s: a table row must end with a pipe: %q", at, line)
162 }
163 raw := strings.Split(line[1:len(line)-1], "|")
164 cells := make([]string, len(raw))
165 for i := range raw {
166 cells[i] = strings.TrimSpace(raw[i])
167 }
168 if len(cells) >= 4 && cells[0] == "id" {
169 if cells[1] != "file" || cells[2] != "text" || cells[len(cells)-1] != "status" {
170 return nil, fmt.Errorf("%s: the header must be | id | file | text | status |: %q", at, line)
171 }
172 inTable = true
173 continue
174 }
175 if strings.Trim(strings.Join(cells, ""), "-: ") == "" {
176 continue // the separator line
177 }
178 if !inTable {
179 return nil, fmt.Errorf("%s: a row outside a table that starts with an id/file/text/status header: %q", at, line)
180 }
181 if len(cells) < 4 {
182 return nil, fmt.Errorf("%s: a row needs id, file, text and status cells, got %d: %q", at, len(cells), line)
183 }
184 last := len(cells) - 1
185 r := copyRow{
186 ID: cells[0],
187 File: cells[1],
188 Text: strings.TrimSpace(strings.Join(raw[2:last], "|")),
189 Status: cells[last],
190 }
191 if !ids.MatchString(r.ID) {
192 return nil, fmt.Errorf("%s: %q is not %s id", at, r.ID, idKind)
193 }
194 if r.File == "" || r.Text == "" {
195 return nil, fmt.Errorf("%s: %s has an empty file or text cell", at, r.ID)
196 }
197 if r.Status != "pending" && r.Status != "approved" {
198 return nil, fmt.Errorf("%s: %s has status %q, which is neither pending nor approved", at, r.ID, r.Status)
199 }
200 if first, dup := seen[r.ID]; dup {
201 return nil, fmt.Errorf("%s: %s is already a row at line %d", at, r.ID, first)
202 }
203 seen[r.ID] = n + 1
204 rows = append(rows, r)
205 }
206 return rows, nil
207}
208
209// checkMovesInventory compares the record's ids with the pinned inventory,
210// both ways, and returns what differs.
211func checkMovesInventory(rows []copyRow, want []string) []string {
212 have := map[string]bool{}
213 for _, r := range rows {
214 have[r.ID] = true
215 }
216 pinned := map[string]bool{}
217 var out []string
218 for _, id := range want {
219 if pinned[id] {
220 out = append(out, fmt.Sprintf("the pinned inventory names %s twice", id))
221 }
222 pinned[id] = true
223 if !have[id] {
224 out = append(out, fmt.Sprintf("the record has no row %s, which the pinned inventory names", id))
225 }
226 }
227 var extra []string
228 for id := range have {
229 if !pinned[id] {
230 extra = append(extra, id)
231 }
232 }
233 sort.Strings(extra)
234 for _, id := range extra {
235 out = append(out, fmt.Sprintf("the record has row %s, which the pinned inventory does not name (add it to movesCopyIDs)", id))
236 }
237 return out
238}
239
240var copyPlaceholder = regexp.MustCompile(`<[^<>]*>`)
241
242// checkMovesApplied checks every approved row against the file it names under
243// root. A row's text may carry <placeholders>; the fixed text between them
244// must be present, in order, inside the string literals of a Go file (a
245// short fixed text must be a whole literal, as in copyApplied) or raw in any
246// other file.
247func checkMovesApplied(rows []copyRow, root string) []string {
248 return checkMovesAppliedWith(rows, root, movesListElementFiles)
249}
250
251// checkMovesAppliedWith is checkMovesApplied with the map of where each list
252// template's element is built (movesListElementFiles for the real record).
253func checkMovesAppliedWith(rows []copyRow, root string, elementFiles map[string]string) []string {
254 var out []string
255 for _, r := range rows {
256 if r.Status != "approved" {
257 continue
258 }
259 body, err := os.ReadFile(filepath.Join(root, r.Path()))
260 if err != nil {
261 out = append(out, fmt.Sprintf("%s: %v", r.ID, err))
262 continue
263 }
264 if strings.HasSuffix(r.Path(), ".go") {
265 if _, _, list, _ := movesListTemplate(r.Text); list {
266 out = append(out, checkMovesListApplied(r, root, body, elementFiles)...)
267 continue
268 }
269 }
270 if !movesTextApplied(r.Path(), r.Text, body) {
271 out = append(out, fmt.Sprintf("%s: %s does not contain the approved text %q", r.ID, r.Path(), r.Text))
272 }
273 }
274 return out
275}
276
277// ---- list templates (preflight L14, adopted by Paul 2026-10-10) ----
278//
279// A row whose text ends in `<element>, ...` describes a list the code builds
280// at run time: "pass --account-id with one of: <sqid> ("<name>"), ...". No
281// literal in the code ends in ", ...", so the gate reads the template as two
282// things and checks each where the code makes it:
283//
284// - the head, the row's text with the whole list as one placeholder, is
285// spoken by one expression of the row's file, whose final part is the
286// code's own fill-in (the list is never fixed text);
287// - the element is built where the code builds it (the row's file, or the
288// file movesListElementFiles names);
289// - the list's own join, traced from the head's fill-in (listProducers),
290// is strings.Join with ", ", which is what the row's ", ..." shows. Any
291// other join in the file is another list's and does not count.
292//
293// The list follows the head's last ": ", as both template rows do; a row
294// that ends in ", ..." with no such colon, or whose element does not start
295// with a placeholder, cannot be read and is refused. An element that is one
296// placeholder (MV-N129's <NAME (kind)>) has no fixed text to check: only its
297// join is.
298
299// movesListTail is how a row's text ends when its last part is a list.
300const movesListTail = ", ..."
301
302// movesListElementFiles names, per row, the file that builds the element of
303// its list when that is not the row's own file. MV-N54's sentence is the move
304// door's (apiinstancemove.go), and its alternatives are built and joined by
305// the account gate it shares with every CLI door (apicli.go, main's own).
306var movesListElementFiles = map[string]string{
307 "MV-N54": "internal/console/apicli.go",
308}
309
310// movesListTemplate splits a row's text that ends in a list template into
311// its head (through the colon that introduces the list) and its element.
312// list reports whether the text ends in one at all; err says why one cannot
313// be read.
314func movesListTemplate(text string) (head, element string, list bool, err error) {
315 if !strings.HasSuffix(text, movesListTail) {
316 return "", "", false, nil
317 }
318 body := strings.TrimSuffix(text, movesListTail)
319 i := strings.LastIndex(body, ": ")
320 if i < 0 {
321 return "", "", true, fmt.Errorf("no \": \" introduces the list")
322 }
323 head, element = body[:i+2], body[i+2:]
324 if !strings.HasPrefix(element, "<") {
325 return "", "", true, fmt.Errorf("the element %q does not start with a placeholder", element)
326 }
327 return head, element, true, nil
328}
329
330// checkMovesListApplied checks a list-template row: its head in its own
331// file, and its element and join where the element is built.
332func checkMovesListApplied(r copyRow, root string, body []byte, elementFiles map[string]string) []string {
333 head, element, _, err := movesListTemplate(r.Text)
334 if err != nil {
335 return []string{fmt.Sprintf("%s: the gate cannot read the list template in %q: %v", r.ID, r.Text, err)}
336 }
337 var out []string
338 var heads []textExpr
339 for _, te := range goTextExprs(r.Path(), body) {
340 if informative(te.pattern) && strings.HasSuffix(te.pattern, wild) && movesRowSpoken(head+"<list>", te.pattern) {
341 heads = append(heads, te)
342 }
343 }
344 if len(heads) == 0 {
345 out = append(out, fmt.Sprintf("%s: %s does not say %q with the list as its final fill-in", r.ID, r.Path(), head+"<list>"))
346 }
347 elemFile := r.Path()
348 if f, ok := elementFiles[r.ID]; ok {
349 elemFile = f
350 }
351 elemBody, err := os.ReadFile(filepath.Join(root, elemFile))
352 if err != nil {
353 return append(out, fmt.Sprintf("%s: the list's element file: %v", r.ID, err))
354 }
355 if copyPlaceholder.ReplaceAllString(element, "") != "" {
356 built := false
357 for _, p := range goTextPatterns(elemFile, elemBody) {
358 if movesRowSpoken(element, p) {
359 built = true
360 break
361 }
362 }
363 if !built {
364 out = append(out, fmt.Sprintf("%s: %s does not build the list's element %q", r.ID, elemFile, element))
365 }
366 }
367 // Every expression that says the head must make its list with a comma
368 // join; the callers a parameter hop reaches are looked for in the head's
369 // file and the element file.
370 callFiles := []goFile{{r.Path(), body}}
371 if elemFile != r.Path() {
372 callFiles = append(callFiles, goFile{elemFile, elemBody})
373 }
374 seen := map[string]bool{}
375 for _, h := range heads {
376 for _, p := range listProducers(h, r.Path(), callFiles) {
377 var msg string
378 switch {
379 case p.why != "":
380 msg = fmt.Sprintf("%s: the gate cannot find the strings.Join that makes the list in %s: %s", r.ID, p.file, p.why)
381 case p.sep != ", ":
382 msg = fmt.Sprintf("%s: %s does not join the list with \", \" (it joins with %q)", r.ID, p.file, p.sep)
383 }
384 if msg != "" && !seen[msg] {
385 seen[msg] = true
386 out = append(out, msg)
387 }
388 }
389 }
390 return out
391}
392
393// goFile is a Go file's name and body.
394type goFile struct {
395 name string
396 body []byte
397}
398
399// funcScope is a function as far as the list trace needs it: the name its
400// callers use (a declaration's name, or the field or variable a function
401// literal is bound to), its parameter names in order, and its body.
402type funcScope struct {
403 name string
404 params []string
405 body *ast.BlockStmt
406}
407
408// textExpr is one expression that could hold a sentence, with its pattern and
409// the innermost function it sits in (nil at package level).
410type textExpr struct {
411 pattern string
412 expr ast.Expr
413 scope *funcScope
414}
415
416// listProducer is one join that makes a list: its separator, or why it cannot
417// be found.
418type listProducer struct {
419 file, sep, why string
420}
421
422// listProducers traces the list in a head expression to the joins that make
423// it. The list is the head's final fill-in: the last operand of a
424// concatenation, or the last argument of a format call. That must be a call
425// to strings.Join with a literal separator, directly or through one local
426// variable assigned once. A parameter of the enclosing function is followed
427// to its callers in files (the call's argument at the parameter's place,
428// traced the same way, with no further parameter hop), and there must be at
429// least one. Anything else is a producer the gate cannot find, and refused.
430// A drift lint, not a dataflow engine.
431func listProducers(h textExpr, headFile string, files []goFile) []listProducer {
432 fill, why := listFill(h.expr)
433 if why != "" {
434 return []listProducer{{file: headFile, why: why}}
435 }
436 sep, param, why := joinOf(fill, h.scope, true)
437 switch {
438 case why != "":
439 return []listProducer{{file: headFile, why: why}}
440 case param < 0:
441 return []listProducer{{file: headFile, sep: sep}}
442 }
443 var out []listProducer
444 for _, f := range files {
445 for _, c := range callsTo(f, h.scope.name) {
446 if param >= len(c.call.Args) {
447 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])})
448 continue
449 }
450 sep, _, why := joinOf(c.call.Args[param], c.scope, false)
451 out = append(out, listProducer{file: f.name, sep: sep, why: why})
452 }
453 }
454 if len(out) == 0 {
455 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)}}
456 }
457 return out
458}
459
460// listFill is the expression that fills a head's final placeholder.
461func listFill(e ast.Expr) (ast.Expr, string) {
462 switch e := e.(type) {
463 case *ast.ParenExpr:
464 return listFill(e.X)
465 case *ast.BinaryExpr:
466 return e.Y, ""
467 case *ast.CallExpr:
468 lit, ok := e.Args[0].(*ast.BasicLit)
469 if !ok || lit.Kind != token.STRING {
470 return nil, "the head's format is not a literal"
471 }
472 f, err := strconv.Unquote(lit.Value)
473 if err != nil {
474 return nil, "the head's format is not a literal"
475 }
476 verbs := 0
477 for _, v := range formatVerb.FindAllString(f, -1) {
478 if strings.Contains(v, "*") || strings.Contains(v, "[") {
479 return nil, "the head's format takes a width or an index argument"
480 }
481 if v != "%%" {
482 verbs++
483 }
484 }
485 if verbs != len(e.Args)-1 {
486 return nil, "the head's format has a different number of verbs than arguments"
487 }
488 return e.Args[len(e.Args)-1], ""
489 }
490 return nil, "the head is not a concatenation or a format call"
491}
492
493// joinOf reads e as a strings.Join call and returns its separator. An
494// identifier assigned once in scope is followed one hop. With params, an
495// identifier that is one of scope's parameters returns its index (param >= 0)
496// for the caller to follow; otherwise param is -1. why says what could not be
497// read.
498func joinOf(e ast.Expr, scope *funcScope, params bool) (sep string, param int, why string) {
499 for hop := 0; ; hop++ {
500 for p, ok := e.(*ast.ParenExpr); ok; p, ok = e.(*ast.ParenExpr) {
501 e = p.X
502 }
503 if c, ok := e.(*ast.CallExpr); ok && isStringsJoin(c) {
504 lit, ok := c.Args[1].(*ast.BasicLit)
505 if !ok || lit.Kind != token.STRING {
506 return "", -1, "the separator of its strings.Join is not a literal"
507 }
508 v, err := strconv.Unquote(lit.Value)
509 if err != nil {
510 return "", -1, "the separator of its strings.Join is not a literal"
511 }
512 return v, -1, ""
513 }
514 id, ok := e.(*ast.Ident)
515 if !ok {
516 return "", -1, fmt.Sprintf("the list is %s, not a strings.Join", render(e))
517 }
518 if scope == nil {
519 return "", -1, fmt.Sprintf("the list is %s, outside any function", id.Name)
520 }
521 vals := assignmentsTo(scope.body, id.Name)
522 pi := -1
523 for i, p := range scope.params {
524 if p == id.Name {
525 pi = i
526 }
527 }
528 switch {
529 case pi >= 0 && len(vals) == 0 && params:
530 return "", pi, ""
531 case pi >= 0 && len(vals) == 0:
532 return "", -1, fmt.Sprintf("the list is %s, a parameter of the caller's own function (one hop only)", id.Name)
533 case len(vals) != 1 || pi >= 0:
534 return "", -1, fmt.Sprintf("the list is %s, which is not assigned exactly once", id.Name)
535 case hop > 0:
536 return "", -1, fmt.Sprintf("the list is %s, more than one local variable away from its join", id.Name)
537 }
538 e = vals[0]
539 }
540}
541
542func isStringsJoin(c *ast.CallExpr) bool {
543 sel, ok := c.Fun.(*ast.SelectorExpr)
544 if !ok || sel.Sel.Name != "Join" || len(c.Args) != 2 {
545 return false
546 }
547 id, ok := sel.X.(*ast.Ident)
548 return ok && id.Name == "strings"
549}
550
551// assignmentsTo returns the values a function body assigns to name with =,
552// := or var.
553func assignmentsTo(body *ast.BlockStmt, name string) []ast.Expr {
554 var out []ast.Expr
555 if body == nil {
556 return nil
557 }
558 ast.Inspect(body, func(n ast.Node) bool {
559 switch n := n.(type) {
560 case *ast.AssignStmt:
561 for i, l := range n.Lhs {
562 if id, ok := l.(*ast.Ident); ok && id.Name == name {
563 if len(n.Lhs) == len(n.Rhs) {
564 out = append(out, n.Rhs[i])
565 } else {
566 out = append(out, nil, nil) // a multi-value assignment: not read
567 }
568 }
569 }
570 case *ast.ValueSpec:
571 for i, nm := range n.Names {
572 if nm.Name == name && i < len(n.Values) {
573 out = append(out, n.Values[i])
574 }
575 }
576 }
577 return true
578 })
579 return out
580}
581
582// scopedCall is a call and the function it sits in.
583type scopedCall struct {
584 call *ast.CallExpr
585 scope *funcScope
586}
587
588// callsTo returns the calls in f to a function or field called name.
589func callsTo(f goFile, name string) []scopedCall {
590 var out []scopedCall
591 walkScoped(f, func(n ast.Node, scope *funcScope) {
592 c, ok := n.(*ast.CallExpr)
593 if !ok {
594 return
595 }
596 switch fn := c.Fun.(type) {
597 case *ast.Ident:
598 if fn.Name == name {
599 out = append(out, scopedCall{c, scope})
600 }
601 case *ast.SelectorExpr:
602 if fn.Sel.Name == name {
603 out = append(out, scopedCall{c, scope})
604 }
605 }
606 })
607 return out
608}
609
610// walkScoped visits every node of a Go file with the innermost function it
611// sits in. A function literal takes the name of the field or variable it is
612// bound to.
613func walkScoped(f goFile, visit func(n ast.Node, scope *funcScope)) {
614 file, err := parser.ParseFile(token.NewFileSet(), f.name, f.body, 0)
615 if err != nil {
616 return
617 }
618 litNames := map[*ast.FuncLit]string{}
619 ast.Inspect(file, func(n ast.Node) bool {
620 switch n := n.(type) {
621 case *ast.KeyValueExpr:
622 if k, ok := n.Key.(*ast.Ident); ok {
623 if fl, ok := n.Value.(*ast.FuncLit); ok {
624 litNames[fl] = k.Name
625 }
626 }
627 case *ast.AssignStmt:
628 for i, l := range n.Lhs {
629 if id, ok := l.(*ast.Ident); ok && i < len(n.Rhs) {
630 if fl, ok := n.Rhs[i].(*ast.FuncLit); ok {
631 litNames[fl] = id.Name
632 }
633 }
634 }
635 case *ast.ValueSpec:
636 for i, nm := range n.Names {
637 if i < len(n.Values) {
638 if fl, ok := n.Values[i].(*ast.FuncLit); ok {
639 litNames[fl] = nm.Name
640 }
641 }
642 }
643 }
644 return true
645 })
646 paramsOf := func(ft *ast.FuncType) []string {
647 var ps []string
648 for _, fld := range ft.Params.List {
649 if len(fld.Names) == 0 {
650 ps = append(ps, "_")
651 }
652 for _, nm := range fld.Names {
653 ps = append(ps, nm.Name)
654 }
655 }
656 return ps
657 }
658 var nodes []ast.Node
659 var scopes []*funcScope
660 ast.Inspect(file, func(n ast.Node) bool {
661 if n == nil {
662 last := nodes[len(nodes)-1]
663 nodes = nodes[:len(nodes)-1]
664 switch last.(type) {
665 case *ast.FuncDecl, *ast.FuncLit:
666 scopes = scopes[:len(scopes)-1]
667 }
668 return true
669 }
670 nodes = append(nodes, n)
671 switch n := n.(type) {
672 case *ast.FuncDecl:
673 scopes = append(scopes, &funcScope{name: n.Name.Name, params: paramsOf(n.Type), body: n.Body})
674 case *ast.FuncLit:
675 scopes = append(scopes, &funcScope{name: litNames[n], params: paramsOf(n.Type), body: n.Body})
676 }
677 var scope *funcScope
678 if len(scopes) > 0 {
679 scope = scopes[len(scopes)-1]
680 }
681 visit(n, scope)
682 return true
683 })
684}
685
686// movesTextApplied reports whether file's body speaks the row's text. In a Go
687// file the text must be one expression: a string literal, a concatenation of
688// literals, or one Sprintf or errors.New format. The row's <placeholders>
689// and the code's %s match each other, but fragments in unrelated literals are
690// never joined, and a comment never counts. Any other file is matched raw,
691// the fixed text between placeholders in order.
692func movesTextApplied(file, text string, body []byte) bool {
693 if !strings.HasSuffix(file, ".go") {
694 rest := string(body)
695 for _, s := range copyPlaceholder.Split(text, -1) {
696 i := strings.Index(rest, s)
697 if i < 0 {
698 return false
699 }
700 rest = rest[i+len(s):]
701 }
702 return true
703 }
704 for _, p := range goTextPatterns(file, body) {
705 if informative(p) && movesRowSpoken(text, p) {
706 return true
707 }
708 }
709 return false
710}
711
712// goTextPatterns returns the pattern of every expression in a Go file that
713// could hold a sentence whole: a string literal, a concatenation, or one
714// Sprintf or errors.New format, with the file's consts and vars read in
715// place. A file that does not parse has none.
716func goTextPatterns(file string, body []byte) []string {
717 var out []string
718 for _, te := range goTextExprs(file, body) {
719 out = append(out, te.pattern)
720 }
721 return out
722}
723
724// goTextExprs is goTextPatterns with each pattern's expression and function.
725func goTextExprs(file string, body []byte) []textExpr {
726 f, err := parser.ParseFile(token.NewFileSet(), file, body, 0)
727 if err != nil {
728 return nil
729 }
730 vars := map[string]ast.Expr{}
731 for _, d := range f.Decls {
732 if g, ok := d.(*ast.GenDecl); ok {
733 for _, sp := range g.Specs {
734 if vs, ok := sp.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) {
735 for i, nm := range vs.Names {
736 vars[nm.Name] = vs.Values[i]
737 }
738 }
739 }
740 }
741 }
742 var out []textExpr
743 walkScoped(goFile{file, body}, func(n ast.Node, scope *funcScope) {
744 var e ast.Expr
745 switch n := n.(type) {
746 case *ast.BasicLit:
747 if n.Kind == token.STRING {
748 e = n
749 }
750 case *ast.BinaryExpr:
751 if n.Op == token.ADD {
752 e = n
753 }
754 case *ast.CallExpr:
755 if isFormatCall(n) {
756 e = n
757 }
758 }
759 if e != nil {
760 for _, p := range literalPatterns(e, vars, 0) {
761 out = append(out, textExpr{p, e, scope})
762 }
763 }
764 })
765 return out
766}
767
768// checkPendingKeepsSwitchOff is D13: a pending row means the compiled default
769// must be off. When the default is on, every row must be approved.
770func checkPendingKeepsSwitchOff(rows []copyRow, defaultEnabled bool) []string {
771 if !defaultEnabled {
772 return nil
773 }
774 var out []string
775 for _, r := range rows {
776 if r.Status != "approved" {
777 out = append(out, fmt.Sprintf("%s is %s, but moves.DefaultEnabled is true: a pending sentence can reach an operator", r.ID, r.Status))
778 }
779 }
780 return out
781}
782
783// checkMovesNoEmDash fails on an em dash in any row's text. The house voice
784// is a full stop and a new sentence.
785func checkMovesNoEmDash(rows []copyRow) []string {
786 var out []string
787 for _, r := range rows {
788 if strings.ContainsRune(r.Text, '\u2014') {
789 out = append(out, fmt.Sprintf("%s: the text holds an em dash", r.ID))
790 }
791 }
792 return out
793}
794
795func mustParseMovesRecord(t *testing.T) []copyRow {
796 t.Helper()
797 var rows []copyRow
798 seen := map[string]bool{}
799 // Claim Plan 5 owns this additional refusal. Keep its pending record
800 // beside the integration tests without editing the separate Moves plan.
801 for _, path := range []string{movesCopyRecordPath, "testdata/claim-moves-copy.md"} {
802 part, err := parseMovesCopyRecord(path)
803 if err != nil {
804 t.Fatal(err)
805 }
806 if len(part) == 0 {
807 t.Fatalf("%s has no rows", path)
808 }
809 for _, row := range part {
810 if seen[row.ID] {
811 t.Fatalf("copy id %s occurs in multiple records", row.ID)
812 }
813 seen[row.ID] = true
814 }
815 rows = append(rows, part...)
816 }
817 return rows
818}
819
820func reportAll(t *testing.T, problems []string) {
821 t.Helper()
822 for _, p := range problems {
823 t.Error(p)
824 }
825}
826
827// The record names every id the pinned inventory names, once, and nothing
828// else. A record missing from the tree fails here at parse.
829func TestMovesCopyRecordIsComplete(t *testing.T) {
830 reportAll(t, checkMovesInventory(mustParseMovesRecord(t), movesCopyIDs))
831}
832
833// Every approved row's text is applied in the file the row names. While every
834// row is pending this passes vacuously; the fixture test proves it bites.
835func TestMovesApprovedCopyIsApplied(t *testing.T) {
836 reportAll(t, checkMovesApplied(mustParseMovesRecord(t), "."))
837}
838
839// While any row is pending, a default deployment must not speak. The day the
840// record is approved, one commit flips moves.DefaultEnabled and this check's
841// condition inverts: every row must be approved.
842func TestPendingCopyKeepsTheSwitchOff(t *testing.T) {
843 reportAll(t, checkPendingKeepsSwitchOff(mustParseMovesRecord(t), moves.DefaultEnabled))
844}
845
846func TestMovesCopyHasNoEmDash(t *testing.T) {
847 reportAll(t, checkMovesNoEmDash(mustParseMovesRecord(t)))
848}
849
850// ---- the parser ----
851
852func TestParseMovesCopyTable(t *testing.T) {
853 const header = "| id | file | text | status |\n|---|---|---|---|\n"
854 good := header +
855 "| M-C1 | move.go | Moving <host>. | pending |\n" +
856 "| MV-N2 | a/b.go | a | b is kept | approved |\n"
857 rows, err := parseMovesCopyTable("# prose\n\n"+good+"\nprose again\n", "x.md")
858 if err != nil {
859 t.Fatal(err)
860 }
861 want := []copyRow{
862 {"M-C1", "move.go", "Moving <host>.", "pending"},
863 {"MV-N2", "a/b.go", "a | b is kept", "approved"},
864 }
865 if len(rows) != len(want) {
866 t.Fatalf("rows = %+v", rows)
867 }
868 for i := range want {
869 if rows[i] != want[i] {
870 t.Errorf("row %d = %+v, want %+v", i, rows[i], want[i])
871 }
872 }
873
874 bad := []struct{ name, src, wantErr string }{
875 {"too few cells", header + "| M-C1 | move.go | pending |\n", "needs id, file, text and status"},
876 {"no closing pipe", header + "| M-C1 | move.go | text | pending\n", "must end with a pipe"},
877 {"duplicate id", header + "| M-C1 | a.go | one | pending |\n| M-C1 | a.go | two | pending |\n", "already a row"},
878 {"unknown status", header + "| M-C1 | a.go | one | maybe |\n", "neither pending nor approved"},
879 {"empty status", header + "| M-C1 | a.go | one | |\n", "neither pending nor approved"},
880 {"empty text", header + "| M-C1 | a.go | | pending |\n", "empty file or text"},
881 {"empty file", header + "| M-C1 | | one | pending |\n", "empty file or text"},
882 {"bad id", header + "| X1 | a.go | one | pending |\n", "is not an M-C"},
883 {"row with no header", "| M-C1 | a.go | one | pending |\n", "outside a table"},
884 {"row after the table ended", good + "\nprose\n| M-C3 | a.go | one | pending |\n", "outside a table"},
885 {"a header that is not ours", "| id | place | text | status |\n|---|---|---|---|\n", "the header must be"},
886 }
887 for _, c := range bad {
888 _, err := parseMovesCopyTable(c.src, "x.md")
889 if err == nil || !strings.Contains(err.Error(), c.wantErr) {
890 t.Errorf("%s: err = %v, want one containing %q", c.name, err, c.wantErr)
891 }
892 }
893}
894
895// A note after the path stays out of the sentence and out of the file name.
896func TestCopyRowPath(t *testing.T) {
897 rows, err := parseMovesCopyTable("| id | file | text | status |\n|---|---|---|---|\n"+
898 "| MV-N1 | a/b.go (a T3 note, not a failure) | <NAME> (<kind>) | pending |\n"+
899 "| MV-N2 | a/b.go | a sentence | pending |\n", "x.md")
900 if err != nil {
901 t.Fatal(err)
902 }
903 if rows[0].Path() != "a/b.go" || rows[1].Path() != "a/b.go" || rows[0].Text != "<NAME> (<kind>)" {
904 t.Errorf("rows = %+v", rows)
905 }
906}
907
908func TestParseMovesCopyRecordMissingFile(t *testing.T) {
909 if _, err := parseMovesCopyRecord("testdata/moves-copy/no-such-record.md"); err == nil {
910 t.Error("a record missing from the tree must fail to parse")
911 }
912}
913
914// ---- the negative fixtures ----
915
916// Each fixture is a record the gate must refuse, and for the right reason.
917func TestMovesCopyGateRefusesTheNegativeFixtures(t *testing.T) {
918 dir := filepath.Join("testdata", "moves-copy")
919
920 t.Run("a record missing an id", func(t *testing.T) {
921 rows, err := parseMovesCopyRecord(filepath.Join(dir, "missing-id.md"))
922 if err != nil {
923 t.Fatal(err)
924 }
925 got := checkMovesInventory(rows, []string{"M-C1", "M-C2", "MV-N1"})
926 if len(got) != 1 || !strings.Contains(got[0], "no row MV-N1") {
927 t.Errorf("problems = %q, want exactly the missing MV-N1", got)
928 }
929 })
930
931 t.Run("a record with a row the inventory does not name", func(t *testing.T) {
932 rows, err := parseMovesCopyRecord(filepath.Join(dir, "missing-id.md"))
933 if err != nil {
934 t.Fatal(err)
935 }
936 got := checkMovesInventory(rows, []string{"M-C1"})
937 if len(got) != 1 || !strings.Contains(got[0], "does not name") {
938 t.Errorf("problems = %q, want exactly the extra M-C2", got)
939 }
940 })
941
942 t.Run("a row whose text changed after approval", func(t *testing.T) {
943 root := filepath.Join(dir, "changed-text")
944 rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
945 if err != nil {
946 t.Fatal(err)
947 }
948 got := checkMovesApplied(rows, root)
949 if len(got) != 1 || !strings.Contains(got[0], "MV-N2") {
950 t.Errorf("problems = %q, want exactly MV-N2 (MV-N1 is applied, MV-N3 is pending)", got)
951 }
952 })
953
954 t.Run("a pending row beside a default that is on", func(t *testing.T) {
955 rows, err := parseMovesCopyRecord(filepath.Join(dir, "pending-default-on.md"))
956 if err != nil {
957 t.Fatal(err)
958 }
959 got := checkPendingKeepsSwitchOff(rows, true)
960 if len(got) != 1 || !strings.Contains(got[0], "MV-N2 is pending") {
961 t.Errorf("problems = %q, want exactly MV-N2", got)
962 }
963 if got := checkPendingKeepsSwitchOff(rows, false); len(got) != 0 {
964 t.Errorf("a pending row with the default off is the intended state: %q", got)
965 }
966 })
967
968 t.Run("an approved record may have the default on", func(t *testing.T) {
969 rows, err := parseMovesCopyRecord(filepath.Join(dir, "changed-text", "record.md"))
970 if err != nil {
971 t.Fatal(err)
972 }
973 for i := range rows {
974 rows[i].Status = "approved"
975 }
976 if got := checkPendingKeepsSwitchOff(rows, true); len(got) != 0 {
977 t.Errorf("every row approved: %q", got)
978 }
979 })
980
981 t.Run("an em dash in a row", func(t *testing.T) {
982 rows, err := parseMovesCopyRecord(filepath.Join(dir, "em-dash.md"))
983 if err != nil {
984 t.Fatal(err)
985 }
986 got := checkMovesNoEmDash(rows)
987 if len(got) != 1 || !strings.Contains(got[0], "MV-N2") {
988 t.Errorf("problems = %q, want exactly MV-N2", got)
989 }
990 })
991
992 // The three notation rules Paul adopted for the list templates
993 // (preflight L14, 2026-10-10). Each fixture holds a row the rule lets
994 // through and rows it must refuse, each refused for its own reason.
995 t.Run("list templates: the head with the list as its fill-in, the element where it is built", func(t *testing.T) {
996 root := filepath.Join(dir, "list-template")
997 rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
998 if err != nil {
999 t.Fatal(err)
1000 }
1001 got := checkMovesAppliedWith(rows, root, map[string]string{"MV-N2": "elem.go", "MV-N3": "elem.go"})
1002 sort.Strings(got)
1003 want := []struct{ id, why string }{
1004 {"MV-N3", "does not build the list's element \"<id> [<zone>]\""},
1005 {"MV-N4", "with the list as its final fill-in"},
1006 {"MV-N5", "cannot read the list template"},
1007 {"MV-N6", "with the list as its final fill-in"},
1008 }
1009 if len(got) != len(want) {
1010 t.Fatalf("problems = %q, want exactly MV-N3 to MV-N6 (MV-N1 and MV-N2 are spoken)", got)
1011 }
1012 for i, w := range want {
1013 if !strings.HasPrefix(got[i], w.id+":") || !strings.Contains(got[i], w.why) {
1014 t.Errorf("problem %d = %q, want %s refused because it %s", i, got[i], w.id, w.why)
1015 }
1016 }
1017 })
1018
1019 t.Run("a list template joined with anything but a comma", func(t *testing.T) {
1020 root := filepath.Join(dir, "list-join")
1021 rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
1022 if err != nil {
1023 t.Fatal(err)
1024 }
1025 got := checkMovesAppliedWith(rows, root, nil)
1026 if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], `does not join the list with ", "`) {
1027 t.Errorf("problems = %q, want exactly MV-N1, for its join", got)
1028 }
1029 })
1030
1031 // Fix round 1: the join is the list's own, traced from the head's fill-in,
1032 // never any join in the file.
1033 t.Run("an unrelated comma join does not answer for the list's own join", func(t *testing.T) {
1034 root := filepath.Join(dir, "list-join-two")
1035 rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
1036 if err != nil {
1037 t.Fatal(err)
1038 }
1039 got := checkMovesAppliedWith(rows, root, nil)
1040 if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], `does not join the list with ", " (it joins with " and ")`) {
1041 t.Errorf("problems = %q, want exactly MV-N1, for its own join (MV-N2's comma join is another list's)", got)
1042 }
1043 })
1044
1045 t.Run("the join that makes the list must be found", func(t *testing.T) {
1046 root := filepath.Join(dir, "list-producer")
1047 rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
1048 if err != nil {
1049 t.Fatal(err)
1050 }
1051 got := checkMovesAppliedWith(rows, root, nil)
1052 sort.Strings(got)
1053 want := []struct{ id, why string }{
1054 {"MV-N1", "cannot find the strings.Join that makes the list"},
1055 {"MV-N2", "cannot find the strings.Join that makes the list"},
1056 {"MV-N4", `does not join the list with ", " (it joins with " or ")`},
1057 }
1058 if len(got) != len(want) {
1059 t.Fatalf("problems = %q, want exactly MV-N1, MV-N2 and MV-N4 (MV-N3 is spoken)", got)
1060 }
1061 for i, w := range want {
1062 if !strings.HasPrefix(got[i], w.id+":") || !strings.Contains(got[i], w.why) {
1063 t.Errorf("problem %d = %q, want %s refused because it %s", i, got[i], w.id, w.why)
1064 }
1065 }
1066 })
1067
1068 t.Run("%q prints quotes, so a row shows them and a %s does not satisfy them", func(t *testing.T) {
1069 root := filepath.Join(dir, "quoted-verb")
1070 rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
1071 if err != nil {
1072 t.Fatal(err)
1073 }
1074 got := checkMovesApplied(rows, root)
1075 if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N2:") {
1076 t.Errorf("problems = %q, want exactly MV-N2 (its row shows quotes the code's %%s does not print)", got)
1077 }
1078 })
1079
1080 t.Run("a note belongs in the File cell, never the Text cell", func(t *testing.T) {
1081 root := filepath.Join(dir, "file-note")
1082 rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
1083 if err != nil {
1084 t.Fatal(err)
1085 }
1086 got := checkMovesApplied(rows, root)
1087 if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], "does not contain the approved text") {
1088 t.Errorf("problems = %q, want exactly MV-N1 (its note is in the Text cell)", got)
1089 }
1090 })
1091}
1092
1093func TestMovesFormatPatternQuotesQ(t *testing.T) {
1094 cases := map[string]string{
1095 "account %q is ambiguous": `account "` + wild + `" is ambiguous`,
1096 "%s (%q)": wild + ` ("` + wild + `")`,
1097 "%-10q left": `"` + wild + `" left`,
1098 "took %d of %d": "took " + wild + " of " + wild,
1099 "85%% full": "85% full",
1100 "%v and %s": wild + " and " + wild,
1101 }
1102 for in, want := range cases {
1103 if got := formatPattern(in); got != want {
1104 t.Errorf("formatPattern(%q) = %q, want %q", in, got, want)
1105 }
1106 }
1107}
1108
1109func TestMovesListTemplate(t *testing.T) {
1110 cases := []struct {
1111 text, head, element string
1112 list, readable bool
1113 }{
1114 {`account "<segment>" is ambiguous. pass --account-id with one of: <sqid> ("<name>"), ...`,
1115 `account "<segment>" is ambiguous. pass --account-id with one of: `, `<sqid> ("<name>")`, true, true},
1116 {"the launch environment differs from the source's: <NAME (kind)>, ...",
1117 "the launch environment differs from the source's: ", "<NAME (kind)>", true, true},
1118 {"no list here", "", "", false, true},
1119 {"a list without a colon <a>, ...", "", "", true, false},
1120 {"a colon then words: the <a>, ...", "", "", true, false},
1121 {"an empty element: , ...", "", "", true, false},
1122 }
1123 for _, c := range cases {
1124 head, element, list, err := movesListTemplate(c.text)
1125 if list != c.list || (err == nil) != c.readable || head != c.head || element != c.element {
1126 t.Errorf("movesListTemplate(%q) = %q, %q, %v, %v; want %q, %q, list %v, readable %v",
1127 c.text, head, element, list, err, c.head, c.element, c.list, c.readable)
1128 }
1129 }
1130}
1131
1132// Every entry of movesListElementFiles is a list-template row of the real
1133// record whose element file exists, so the map never names a row that has no
1134// element to check.
1135func TestMovesListElementFilesAreHonest(t *testing.T) {
1136 byID := map[string]copyRow{}
1137 for _, r := range mustParseMovesRecord(t) {
1138 byID[r.ID] = r
1139 }
1140 for id, file := range movesListElementFiles {
1141 r, ok := byID[id]
1142 if !ok {
1143 t.Errorf("movesListElementFiles names %s, which the record has no row for", id)
1144 continue
1145 }
1146 if _, _, list, err := movesListTemplate(r.Text); !list || err != nil {
1147 t.Errorf("movesListElementFiles names %s, whose text %q is not a readable list template", id, r.Text)
1148 }
1149 if _, err := os.Stat(file); err != nil {
1150 t.Errorf("movesListElementFiles: %s's element file: %v", id, err)
1151 }
1152 }
1153}
1154
1155func TestMovesTextAppliedPlaceholders(t *testing.T) {
1156 const src = "package p\n\nvar a = \"region \" + to + \" is not one of this app's regions: \"\n"
1157 cases := []struct {
1158 name, file, text string
1159 body string
1160 want bool
1161 }{
1162 {"placeholders between literals", "x.go", "region <to> is not one of this app's regions: <list>", src, true},
1163 {"fixed text altered", "x.go", "region <to> is not one of this app's zones: <list>", src, false},
1164 {"fixed text in the wrong order", "x.go", "regions: <list> region <to>", src, false},
1165 {"in a comment only", "x.go", "region <to> is gone", "package p\n\n// region x is gone\nvar a = \"\"\n", false},
1166 {"a doc matches raw", "x.md", "region <to> is here", "region x is here", true},
1167 {"a doc, altered", "x.md", "region <to> is here", "region x is there", false},
1168 }
1169 for _, c := range cases {
1170 if got := movesTextApplied(c.file, c.text, []byte(c.body)); got != c.want {
1171 t.Errorf("%s: = %v, want %v", c.name, got, c.want)
1172 }
1173 }
1174}
1175
1176// ---- the mechanical scan (F13) ----
1177
1178// movesScanFiles are the non-test Go files the moves plans touched, from
1179// `git diff --name-only <base>..HEAD -- '*.go'`, where base is the main commit
1180// moves-phase1 last merged (movesScanBaseRef). Each later plan's "Gate and
1181// copy" task appends the files it touched; TestMovesScanFilesCoverTheDiff
1182// fails when the list falls behind.
1183var movesScanFiles = []string{
1184 "internal/host/relocationflush.go",
1185 "internal/host/relocationinstall.go",
1186 "internal/host/rootfloor_reconstruct.go",
1187 "internal/host/rootfloor_available.go",
1188 "internal/host/rootfloor_healthy.go",
1189 "internal/host/rootmovesfloor.go",
1190 "internal/host/rootmovesfloor_boot.go",
1191 "internal/host/rootmovesfloor_refresh.go",
1192 "internal/host/rootmovesfloor_source.go",
1193 "internal/host/rootseedsfloor.go",
1194 "internal/host/rootseedsfloor_publication.go",
1195 // Claim-owned pages share the alert transport; keep their drafts in the copy census.
1196 "internal/console/claim_loss_pages.go",
1197 "internal/host/claimhandover_crossbox.go",
1198 "internal/host/claimhandover_recovery.go",
1199 "internal/host/claimretire_local.go",
1200 "internal/hostops/move_fence_release.go",
1201 "internal/host/movessources.go",
1202 "internal/host/movesrefusals.go",
1203 "internal/console/movesrecovery.go",
1204 "internal/console/apiinstancemove_offline.go",
1205 "internal/console/claim_runtime.go",
1206 "internal/alerting/alerting.go",
1207 "internal/hostops/move_log_writer.go",
1208 "internal/hostops/move_recovery_grant.go",
1209 "internal/hostops/move_loss_owner.go",
1210 "internal/host/movesrecoveryprobe.go",
1211 "internal/host/movesrecoveryowners.go",
1212 "internal/host/movesloss.go",
1213 "internal/host/claimproduction.go",
1214 "internal/host/claimowners.go",
1215 "internal/host/claimmoveaccess.go",
1216 "cmd/console/main.go",
1217 "internal/activator/backend_exec.go",
1218 "internal/activator/claimwake.go",
1219 "internal/activator/probewake.go",
1220 "internal/appconfig/configack.go",
1221 "internal/appconfig/environments.go",
1222 "internal/appconfig/instances.go",
1223 "internal/appconfig/renderheld.go",
1224 "internal/appconfig/syncexec.go",
1225 "internal/boxid/boxidtest/boxidtest.go",
1226 "internal/boxid/internal/signtest/signtest.go",
1227 "internal/cgroup/strict_linux.go",
1228 "internal/cgroup/strict_other.go",
1229 "internal/console/apicli.go",
1230 "internal/console/apiinstancemove.go",
1231 "internal/console/apiinstances.go",
1232 "internal/console/authcli.go",
1233 "internal/console/boxselect.go",
1234 "internal/console/instancesplacement.go",
1235 "internal/console/server.go",
1236 "internal/dbverify/dbverify.go",
1237 "internal/edge/door.go",
1238 "internal/edge/edge.go",
1239 "internal/genselect/select.go",
1240 "internal/host/agent.go",
1241 "internal/host/buildstate.go",
1242 "internal/host/edgesfile.go",
1243 "internal/host/heldstage.go",
1244 "internal/host/movesfloor.go",
1245 "internal/host/movesfloor_linux.go",
1246 "internal/host/movesfloor_other.go",
1247 "internal/host/movesfloorgate.go",
1248 "internal/host/movesfloorgate_linux.go",
1249 "internal/host/movesfloorgate_other.go",
1250 "internal/host/movesevents.go",
1251 "internal/host/moveslogoutbox.go",
1252 "internal/host/movesabort.go",
1253 "internal/host/movesarchive.go",
1254 "internal/host/movescut.go",
1255 "internal/host/movesfinal.go",
1256 "internal/host/movesguard.go",
1257 "internal/host/movesheldenv.go",
1258 "internal/host/movesheldlock.go",
1259 "internal/host/claimstop_run.go",
1260 "internal/host/movesfiles.go",
1261 "internal/host/movesinstall.go",
1262 "internal/host/moveswakesource.go",
1263 "internal/host/moveshold.go",
1264 "internal/host/movesland.go",
1265 "internal/host/moveslandcommit.go",
1266 "internal/host/moveslandinstall.go",
1267 "internal/host/moveslandpark.go",
1268 "internal/host/moveslandprobe.go",
1269 "internal/host/movesmeasure.go",
1270 "internal/host/movesmeasure_linux.go",
1271 "internal/host/movesmeasure_other.go",
1272 "internal/host/movesparks.go",
1273 "internal/host/movespaging.go",
1274 "internal/host/movespass.go",
1275 "internal/host/movesoffline.go",
1276 "internal/host/movesoffline_seal.go",
1277 "internal/host/movesoffline_land.go",
1278 "internal/host/movesoffline_stage.go",
1279 "internal/host/movesphase1.go",
1280 "internal/host/movespolicy.go",
1281 "internal/host/movesprobe.go",
1282 "internal/host/movesprobeattempts.go",
1283 "internal/host/movesprobeowners.go",
1284 "internal/host/movesrecover.go",
1285 "internal/host/movesreport.go",
1286 "internal/host/movesretire.go",
1287 "internal/host/movesrevert.go",
1288 "internal/host/movesrollback.go",
1289 "internal/host/movesstage.go",
1290 // moves after claim Plan 12, Task 7b: root's held release.
1291 "internal/host/movesheldrelease.go",
1292 "internal/host/movesheldrelease_linux.go",
1293 "internal/host/movesheldrelease_production.go",
1294 "internal/host/movesheldrelease_root.go",
1295 "internal/host/movesstore.go",
1296 "internal/host/refusalwatch.go",
1297 "internal/host/systemapply.go",
1298 "internal/hostbox/restore.go",
1299 "internal/hostbox/writer.go",
1300 "internal/hostclaim/move.go",
1301 "internal/hostops/storage_classifier.go",
1302 "internal/lease/endpoint.go",
1303 "internal/lease/health.go",
1304 "internal/lease/lease.go",
1305 "internal/lease/leasetest/bucket.go",
1306 "internal/lease/leasetest/s3server.go",
1307 "internal/lease/lockstate.go",
1308 "internal/lease/mirror.go",
1309 "internal/lease/staging.go",
1310 "internal/lease/versioned.go",
1311 "internal/moves/abort.go",
1312 "internal/moves/archive/archive.go",
1313 "internal/moves/archive/extract.go",
1314 "internal/moves/archive/limits.go",
1315 "internal/moves/archive/open_other.go",
1316 "internal/moves/archive/open_unix.go",
1317 "internal/moves/archive/seal.go",
1318 "internal/moves/archive/sync_linux.go",
1319 "internal/moves/archive/sync_other.go",
1320 "internal/moves/archive/verify.go",
1321 "internal/moves/budget.go",
1322 "internal/moves/build.go",
1323 "internal/moves/check.go",
1324 "internal/moves/envreport.go",
1325 "internal/moves/evidence.go",
1326 "internal/moves/floor.go",
1327 "internal/moves/ids.go",
1328 "internal/moves/journal.go",
1329 "internal/moves/lease.go",
1330 "internal/moves/log.go",
1331 "internal/moves/ltxbuild.go",
1332 "internal/moves/offline.go",
1333 "internal/moves/operatorcopy.go",
1334 "internal/moves/parkscope.go",
1335 "internal/moves/probe.go",
1336 "internal/moves/readiness.go",
1337 "internal/moves/returnlane.go",
1338 "internal/moves/seal.go",
1339 "internal/moves/sealedclient.go",
1340 "internal/moves/source.go",
1341 "internal/moves/staging.go",
1342 "internal/moves/startcheck.go",
1343 "internal/moves/switch.go",
1344 "internal/moves/timers.go",
1345 "internal/moves/transition.go",
1346 "internal/provision/provision.go",
1347 "internal/provision/reconcile.go",
1348 "internal/registry/registry.go",
1349 "internal/release/adopt.go",
1350 "internal/release/materializeheld.go",
1351 "internal/release/proofgate.go",
1352 "internal/release/refusal.go",
1353 "internal/schedule/converge.go",
1354 "internal/schedule/hostgate.go",
1355 "internal/schedule/runner.go",
1356 "internal/stopevidence/reconstruct.go",
1357 "internal/stopevidence/reconstruct_linux.go",
1358 "internal/stopevidence/reconstruct_other.go",
1359 "main.go",
1360 "move.go",
1361 // Region HA stage B: relocation files that import internal/moves (the
1362 // shared build, the probe attempts, the landing and wake owners).
1363 "internal/host/relocationattempts.go",
1364 "internal/host/relocationbuild.go",
1365 "internal/host/relocationlanding.go",
1366 "internal/host/relocationowners.go",
1367 "internal/host/relocationwake.go",
1368}
1369
1370// The scan's base is the main commit moves-phase1 last merged: the diff from
1371// it to HEAD is the series' own, and a string literal it holds is main's own
1372// sentence. Ruling merge-scanbase: the tests ask git for it
1373// (`git merge-base HEAD origin/main`), so a merge of main needs no edit here.
1374// movesScanBase is only the fallback where git or origin/main is not
1375// available: the merge base when this was last written (c17f39d5, the branch
1376// point, then cbc0922e, then 3407d114, claim C5/C7).
1377const movesScanBase = "3407d114"
1378
1379// movesMergeBase asks git for the commit HEAD and origin/main last shared. A
1380// variable, so the fallback can be tested.
1381var movesMergeBase = func() (string, error) {
1382 out, err := exec.Command("git", "merge-base", "HEAD", "origin/main").Output()
1383 if err != nil {
1384 return "", err
1385 }
1386 return strings.TrimSpace(string(out)), nil
1387}
1388
1389// resolveMovesScanBase is the base the scan's git checks diff from: what
1390// lookup says, or pinned when it fails or says nothing. from names which.
1391func resolveMovesScanBase(lookup func() (string, error), pinned string) (base, from string) {
1392 if b, err := lookup(); err == nil && b != "" {
1393 return b, "git merge-base HEAD origin/main"
1394 }
1395 return pinned, "the pinned movesScanBase"
1396}
1397
1398// movesScanBaseRef is the base for this run, logged so a failure says which.
1399//
1400// Where git cannot name the merge base (a CI checkout with no origin/main),
1401// the checks that need it skip rather than diff from the pinned commit: a
1402// stale pin turns every commit main gained since into a false finding on
1403// every other branch.
1404func movesScanBaseRef(t *testing.T) string {
1405 t.Helper()
1406 base, from := resolveMovesScanBase(movesMergeBase, movesScanBase)
1407 if base == movesScanBase && from == "the pinned movesScanBase" {
1408 t.Skip("no merge base with origin/main in this checkout; the diff checks run where git can name it")
1409 }
1410 t.Logf("scan base %s, from %s", base, from)
1411 return base
1412}
1413
1414// What the scan treats as an emission. A call to jsonError emits its message
1415// (the third argument). A Check literal's Reason emits. A function in
1416// movesCopyTables is a table of sentences, and every value in it emits. In a
1417// file in movesLocalSinks, a call to the named local helper emits its
1418// string argument (readiness.go's fail and reasonOr). A function listed in
1419// movesReturnedSentences returns sentences in its string results (Ready and
1420// Passing return (bool, string), sourceRefusal (string, error)); its error
1421// results are not.
1422var movesLocalSinks = map[string]map[string]int{
1423 "internal/moves/readiness.go": {"fail": 0, "reasonOr": 1},
1424 "internal/host/movesreport.go": {"fail": 0},
1425 // The landing's item reasons (offline move Task 5): every reason goes
1426 // through one of these, never an error's text (MV-O110 on).
1427 "internal/host/movesoffline_land.go": {"landWait": 1, "landWaitErr": 1, "landFail": 1, "landFailErr": 1, "landFailAll": 0, "landWaitAll": 0},
1428 "internal/console/apiinstancemove.go": {"moveTransitionSaying": 8},
1429 // Every operator sentence of `carlos move` goes through moveSay, L13's
1430 // say (named so because the scan resolves helpers by name, and
1431 // internal/release has a say of its own).
1432 // Text the console wrote goes through saySpoken instead, which is no
1433 // sink here: it takes only serverText (movesServerText).
1434 "move.go": {"moveSay": 1},
1435}
1436
1437var movesCopyTables = map[string][]string{
1438 // phase1MoveCopy is the Phase 1 move door's table (MV-N47 to MV-N54).
1439 // mainMoveCopy and mainGateCopy are main's bytes and are exempt.
1440 "internal/console/apiinstancemove.go": {"phase1MoveCopy"},
1441}
1442
1443var movesReturnedSentences = map[string]map[string]bool{
1444 "internal/moves/readiness.go": {"Passing": true, "Ready": true, "budgetEvidence": true, "reasonOr": true, "NoFreshRate": true},
1445 "internal/host/movesphase1.go": {"sourceRefusal": true, "routeRefusal": true, "publishArchive": true, "archiveFailure": true,
1446 "s1NotExec": true, "s1Sidecar": true, "s1Column": true, "s1PrivateStorage": true, "s1SpecialFile": true,
1447 "s1OverCap": true, "s1LongPath": true, "s1NoAnswer": true, "s1ManyBoxes": true, "oneBoxRefusal": true},
1448 "internal/host/movesreport.go": {"t1Below": true, "t3Differs": true, "t3Shadowed": true, "t4Failed": true,
1449 "t6NoRoom": true, "t8Count": true, "t12NotQuiet": true, "replicaReason": true},
1450 "internal/host/movesrollback.go": {"stuckPage": true},
1451 // memberLine is what an app's members read for a move event (Plan 6
1452 // Task 3, preflight L9), each from a shared template below.
1453 "internal/host/movesevents.go": {"memberLine": true},
1454 // The operator sentences `carlos move` and the member stream share
1455 // (preflight L12, Task 3 fix round 1): one template each, which the
1456 // copy record names. The gate reads a call to one from either package
1457 // (resolveCallees' cross-package rule).
1458 "internal/moves/operatorcopy.go": {"MoveStartingLine": true, "CutStartingLine": true, "FlippedLine": true,
1459 "MovedLine": true, "MovingBackLine": true, "MovedBackLine": true, "StuckMemberLine": true},
1460 // moveDeleteRefusal is M-C19, which the delete door answers (Task 2b).
1461 "internal/console/apiinstancemove.go": {"moveDeleteRefusal": true},
1462 // The moves evidence's anomaly page (Plan 5 Task 9a, MV-N336 and MV-N337).
1463 "internal/host/movesrefusals.go": {"movesAnomalyPage": true},
1464 // The box's move pages (Plan 6 Task 5, MV-N385 to MV-N390) and M-C20,
1465 // the operators' sentence for a stray process (the stray page's body).
1466 "internal/host/movespaging.go": {"rolledBackPage": true, "strayPage": true, "strayLine": true, "startRefusedPage": true},
1467 // The offline sealer's lost-stop page, its two causes and the lease
1468 // states the first names (offline move Task 4, MV-O66 to MV-O75).
1469 "internal/host/movesoffline_seal.go": {"offlineLostStopPage": true, "offlineLeaseCause": true, "offlinePastSealCause": true, "offlineLeaseText": true},
1470 // The console's move pass pages (Plan 5 Task 7, MV-N325 to MV-N334), and
1471 // the dirty fenced target's page (moves after claim Plan 12, Task 5,
1472 // MV-N450 and MV-N451).
1473 "internal/console/movesrecovery.go": {"moveWaitingPage": true, "movePreparingPageText": true, "moveOrphanPage": true, "moveCloseOverduePage": true, "moveAbortOverduePage": true,
1474 "moveTargetFencedDirtyPage": true},
1475}
1476
1477// movesLiteralFuncs are functions whose every string literal is a sentence
1478// or a piece of one: phase1Conflict passes provision's refusals through to
1479// the operator, and the literals it compares are exactly the ones allowed
1480// through (anything else answers one fixed sentence).
1481var movesLiteralFuncs = map[string][]string{
1482 "internal/console/apiinstancemove.go": {"phase1Conflict"},
1483 "internal/console/apiinstancemove_offline.go": {"offlineConflict"},
1484}
1485
1486// movesLegacyFiles are files the series touched only for plumbing (a copy
1487// table threaded through a shared gate), whose sentences are main's own,
1488// reviewed before this series and spoken by main's doors with the switch off.
1489// Every jsonError in them is exempt. TestMovesLegacyExemptionsAreLegacy
1490// proves, where git can, that the series added none of them.
1491var movesLegacyFiles = map[string]string{
1492 "internal/console/apicli.go": "the shared CLI gates; the series added only the gateCopy table (mainGateCopy is main's bytes)",
1493 "internal/console/apiinstances.go": "main's instance doors; the series only routed their gates through a table",
1494 "internal/console/authcli.go": "main's auth doors; the series added one helper",
1495 "internal/console/server.go": "main's console; the series wired the switch and the routes, and added no sentence",
1496}
1497
1498// movesLegacyFuncs are main's functions inside files the series also wrote
1499// into. Their sentences are main's own.
1500var movesLegacyFuncs = map[string]map[string]string{
1501 "internal/console/apiinstancemove.go": {
1502 "legacyHandleAPIInstanceMoveRequest": "main's move door, kept for the switch off",
1503 "moveTransition": "main's transition wrapper, which passes main's bytes to the shared body",
1504 "moveGate": "main's shared gate; its own sentence is main's, and the class rails come from the copy table",
1505 },
1506}
1507
1508// wild stands for the part of a sentence the code fills in.
1509const wild = "\x00"
1510
1511var formatVerb = regexp.MustCompile(`%[-+# 0-9.*]*[a-zA-Z%]`)
1512
1513// formatPattern turns a format string into a pattern. A verb is the code's
1514// fill-in, except that %q prints its quotes, so it is a quoted fill-in and a
1515// row shows the quotes ("<segment>"), preflight L14 as Paul adopted it on
1516// 2026-10-10. %#q may print backquotes instead, so it stays a bare fill-in.
1517func formatPattern(f string) string {
1518 return formatVerb.ReplaceAllStringFunc(f, func(v string) string {
1519 switch {
1520 case v == "%%":
1521 return "%"
1522 case strings.HasSuffix(v, "q") && !strings.Contains(v, "#"):
1523 return `"` + wild + `"`
1524 }
1525 return wild
1526 })
1527}
1528
1529// ---- sinks must take sentences the gate can read ----
1530
1531// movesFuncSinks are calls that take a sentence-producing function as an
1532// argument (the index is where): the argument must be a recognised sentence
1533// helper, and the parameter that receives it may be called inside the callee.
1534var movesFuncSinks = map[string]map[string]int{
1535 "internal/console/apiinstancemove.go": {"moveTransitionSaying": 7},
1536}
1537
1538// movesSinkAllow exempts a function's sinks from the readable-sentence rule.
1539// An entry is only for a value that never reaches an operator, and says why.
1540// A value that does reach an operator is mapped to a recorded sentence in the
1541// code instead. TestMovesSinkAllowListIsHonest holds the list to this.
1542var movesSinkAllow = map[string]map[string]string{
1543 "move.go": {
1544 "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",
1545 },
1546}
1547
1548// movesDiagnosticWrites are field writes the scan leaves out of a field's
1549// writes because what they write is an OS diagnostic for the operator's
1550// eye, not copy. Paul's rule (2026-10-16): internal error fragments and
1551// diagnostics stay out of copy review. The key is "<function>|<struct>.<field>".
1552// An entry leaves out only the composite-literal writes of that field in that
1553// function of that file whose value calls Error(), and says how many there are
1554// (Count), so the field's other writes in the same function stay scanned, and
1555// so do its other writers (the Phase 1 Reason reads in move.go among them).
1556// TestMovesDiagnosticWritesAreReal holds each entry to exactly that many.
1557type movesDiagnosticWrite struct {
1558 Why string
1559 Count int
1560}
1561
1562var movesDiagnosticWrites = map[string]map[string]movesDiagnosticWrite{
1563 "internal/host/movesoffline.go": {
1564 "retire|RetiredPath.Reason": {
1565 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 <reason>; it is not copy",
1566 Count: 2,
1567 },
1568 },
1569 "internal/host/movesoffline_seal.go": {
1570 "offlineTakeSeal|sealRefusal.Reason": {
1571 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 <detail>; it is not copy",
1572 Count: 1,
1573 },
1574 "offlineArchive|sealRefusal.Reason": {
1575 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 <detail>",
1576 Count: 1,
1577 },
1578 },
1579}
1580
1581// movesServerText names, per file, the type that carries text the console
1582// wrote (fix round 2 of Plan 6 Task 1a, ruling 1). It is made in one function
1583// that reads an HTTP response body, and said through one sink; the gate
1584// exempts that sink by its type (the scan never sees it: it is no local
1585// sink), and TestMovesServerTextHasOneMaker holds the rest. Every other value
1586// at a sink is traced to a copy row.
1587var movesServerText = map[string]movesServerTextSpec{
1588 "move.go": {Type: "serverText", Field: "spoken", Maker: "mover.roundTrip", Sink: "saySpoken"},
1589}
1590
1591// movesMappingHelpers are functions that map a refusal from another package
1592// onto the sentence an operator sees, echoing the refusal's own text only in a
1593// case whose guard tests that text against recorded literals (equality,
1594// HasPrefix, HasSuffix or Contains with a literal). A `return x` in such a case
1595// is taken; any other return is checked as usual. Anything else answers a fixed
1596// sentence.
1597var movesMappingHelpers = map[string]map[string]bool{
1598 "internal/console/apiinstancemove.go": {"phase1Conflict": true},
1599 "internal/console/apiinstancemove_offline.go": {"offlineConflict": true},
1600}
1601
1602// diagnosticPrefixes are the ids the readiness code puts before a reason
1603// ("T11: ", "S2: ") and the record leaves on its row or off it.
1604var diagnosticPrefixes = []string{"T11: ", "S1: ", "S2: "}
1605
1606// movesRowMatches reports whether a row carries an emitted sentence, whole:
1607// the sentence equals the row's text, where the parts the code fills in
1608// match anything, or the row's own <placeholders> are filled by the sentence
1609// (the row says "a <role> does not move" and the code says "a replica does
1610// not move"). A diagnostic prefix ("S2: ") may sit on either side. A
1611// sentence that only appears inside a row does not match: "valid region" is
1612// not "invalid region".
1613func movesRowMatches(text, pattern string) bool {
1614 return rowMatches(text, pattern, false)
1615}
1616
1617// movesRowSpoken is the stricter match for an approved row: the code must say
1618// all of the row's fixed text. The code's own fill-ins may only stand where
1619// the row has a <placeholder>, never where the row has words.
1620func movesRowSpoken(text, pattern string) bool {
1621 return rowMatches(text, pattern, true)
1622}
1623
1624func rowMatches(text, pattern string, strict bool) bool {
1625 forms := []string{text}
1626 for _, p := range diagnosticPrefixes {
1627 if strings.HasPrefix(text, p) {
1628 forms = append(forms, strings.TrimPrefix(text, p))
1629 }
1630 }
1631 parts := strings.Split(pattern, wild)
1632 for i := range parts {
1633 parts[i] = regexp.QuoteMeta(parts[i])
1634 }
1635 code := regexp.MustCompile(`(?s)^` + strings.Join(parts, `.*`) + `$`)
1636 rendered := strings.ReplaceAll(pattern, wild, "%")
1637 for _, f := range forms {
1638 if !strict && code.MatchString(f) {
1639 return true
1640 }
1641 rowParts := copyPlaceholder.Split(f, -1)
1642 for i := range rowParts {
1643 rowParts[i] = regexp.QuoteMeta(rowParts[i])
1644 }
1645 if regexp.MustCompile(`(?s)^` + strings.Join(rowParts, `.*`) + `$`).MatchString(rendered) {
1646 return true
1647 }
1648 }
1649 return false
1650}
1651
1652// movesFragmentMatches is the match for a piece of a sentence: a literal
1653// phase1Conflict tests with HasPrefix or Contains. Some row must contain it.
1654func movesFragmentMatches(text, pattern string) bool {
1655 return strings.Contains(text, strings.ReplaceAll(pattern, wild, ""))
1656}
1657
1658// movesLegacy reports whether an emission is main's own.
1659func movesLegacy(e movesEmission) bool {
1660 if _, ok := movesLegacyFiles[e.file]; ok {
1661 return true
1662 }
1663 _, ok := movesLegacyFuncs[e.file][e.fn]
1664 return ok
1665}
1666
1667// findUnrecorded returns the emissions no row covers. A value the gate cannot
1668// derive, inside a function movesSinkAllow lists, made from none of its string
1669// or error parameters, is that entry's (flag help): its reason says whose it
1670// is, so no row could carry it. A value made from a parameter is never
1671// skipped (a forwarding helper's text is traced where it is called, not
1672// exempted), and a sentence the gate can derive still needs its row.
1673func findUnrecorded(ems []movesEmission, rows []copyRow) []movesEmission {
1674 var out []movesEmission
1675 for _, e := range ems {
1676 if movesLegacy(e) {
1677 continue
1678 }
1679 if _, allowed := movesSinkAllow[e.file][e.fn]; allowed && e.underived && !e.fromParam {
1680 continue
1681 }
1682 found := false
1683 for _, r := range rows {
1684 match := movesRowMatches
1685 if e.fragment {
1686 match = movesFragmentMatches
1687 }
1688 if match(r.Text, e.pattern) {
1689 found = true
1690 break
1691 }
1692 }
1693 if !found {
1694 out = append(out, e)
1695 }
1696 }
1697 return out
1698}
1699
1700// Every sentence the moves code emits to an operator (an HTTP error body, a
1701// check's Reason, a phase 1 copy table's text) has a row in the record. A
1702// sentence with no row is a finding: add it as a pending row. It is not a
1703// reason to loosen this test.
1704func TestMovesStringsAreRecorded(t *testing.T) {
1705 rows := mustParseMovesRecord(t)
1706 var all []movesEmission
1707 for _, f := range movesScanFiles {
1708 all = append(all, scanMovesFile(t, f)...)
1709 }
1710 if len(all) < 100 {
1711 t.Fatalf("the scan found only %d emissions; it has stopped reading the code", len(all))
1712 }
1713 for _, e := range findUnrecorded(all, rows) {
1714 t.Errorf("no row of the record carries %s", e)
1715 }
1716}
1717
1718func TestMovesScanFindsWhatItShould(t *testing.T) {
1719 const src = `package p
1720
1721import "fmt"
1722
1723const noKey = "this box has no key"
1724
1725func why(n int) string { return fmt.Sprintf("%d boxes publish it", n) }
1726
1727func f(w int, c Check) {
1728 jsonError(w, 400, "bad region "+c.Region+" given")
1729 _ = Check{Reason: noKey}
1730 _ = moves.Check{Reason: why(3)}
1731 c.Check.Reason = fmt.Sprintf("took %d of %d", 1, 2)
1732}
1733`
1734 dir := t.TempDir()
1735 file := filepath.Join(dir, "p.go")
1736 if err := os.WriteFile(file, []byte(src), 0o600); err != nil {
1737 t.Fatal(err)
1738 }
1739 var got []string
1740 for _, e := range scanMovesFile(t, file) {
1741 got = append(got, strings.ReplaceAll(e.pattern, wild, "%"))
1742 }
1743 sort.Strings(got)
1744 want := []string{"% boxes publish it", "bad region % given", "this box has no key", "took % of %"}
1745 if strings.Join(got, "|") != strings.Join(want, "|") {
1746 t.Errorf("found %q, want %q", got, want)
1747 }
1748
1749 rows := []copyRow{
1750 {ID: "MV-N1", Text: `bad region <to> given`},
1751 {ID: "MV-N2", Text: `<n> boxes publish <region>`},
1752 {ID: "MV-N3", Text: `this box has no key`},
1753 }
1754 ems := scanMovesFile(t, file)
1755 un := findUnrecorded(ems, rows)
1756 if len(un) != 1 || !strings.Contains(un[0].pattern, "took") {
1757 t.Errorf("unrecorded = %v, want only the 'took' sentence", un)
1758 }
1759 // A reworded row no longer covers its sentence.
1760 rows[2].Text = "this box has no secrets key"
1761 if un := findUnrecorded(ems, rows); len(un) != 2 {
1762 t.Errorf("unrecorded after a reword = %v, want 2", un)
1763 }
1764}
1765
1766// movesProductionGoFile reports whether a path from the diff is a production
1767// Go file: not a test, and not under a testdata directory (the go tool
1768// ignores those, and the negative fixtures live there).
1769func movesProductionGoFile(path string) bool {
1770 if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
1771 return false
1772 }
1773 for _, el := range strings.Split(path, "/") {
1774 if el == "testdata" {
1775 return false
1776 }
1777 }
1778 return true
1779}
1780
1781// movesUncovered returns the production files in changed that listed lacks.
1782// exists says whether a path is still in the tree (a deleted file needs no
1783// entry).
1784func movesUncovered(changed, listed []string, exists, movesCode func(string) bool) []string {
1785 have := map[string]bool{}
1786 for _, f := range listed {
1787 have[f] = true
1788 }
1789 var out []string
1790 for _, f := range changed {
1791 if movesProductionGoFile(f) && exists(f) && movesCode(f) && !have[f] {
1792 out = append(out, f)
1793 }
1794 }
1795 return out
1796}
1797
1798// movesCodeFile is a Go file the moves series owns or speaks through: one in
1799// internal/moves, or one that imports it. Another branch's change to a file
1800// with no tie to moves is not this gate's to inventory.
1801func movesCodeFile(path string) bool {
1802 if strings.HasPrefix(path, "internal/moves/") {
1803 return true
1804 }
1805 body, err := os.ReadFile(path)
1806 if err != nil {
1807 return true // unreadable: inventory it rather than pass it
1808 }
1809 return strings.Contains(string(body), `"github.com/carlosframework/platform/internal/moves`)
1810}
1811
1812func TestMovesScanFilesInventoryRules(t *testing.T) {
1813 exists := func(p string) bool { return p != "internal/gone.go" }
1814 changed := []string{
1815 "internal/moves/new.go", // production: needs an entry
1816 "internal/moves/new_test.go", // a test
1817 "testdata/moves-copy/changed-text/applied.go", // a fixture
1818 "internal/host/testdata/fixture/emit.go", // a fixture below a package
1819 "internal/gone.go", // deleted
1820 "docs/superpowers/plans/2026-10-06-copy.md", // not Go
1821 "internal/moves/listed.go", // listed
1822 "internal/host/usesmoves.go", // imports moves: needs an entry
1823 "internal/changelog/other.go", // another branch's file, no tie to moves
1824 }
1825 movesCode := func(p string) bool {
1826 return strings.HasPrefix(p, "internal/moves/") || p == "internal/host/usesmoves.go"
1827 }
1828 got := movesUncovered(changed, []string{"internal/moves/listed.go"}, exists, movesCode)
1829 if len(got) != 2 || got[0] != "internal/moves/new.go" || got[1] != "internal/host/usesmoves.go" {
1830 t.Errorf("uncovered = %q, want internal/moves/new.go and internal/host/usesmoves.go", got)
1831 }
1832}
1833
1834// Ruling merge-scanbase: git's merge base wins, and the pinned constant is
1835// only the fallback. Both paths, with the lookup faked.
1836func TestMovesScanBaseResolution(t *testing.T) {
1837 cases := []struct {
1838 name string
1839 lookup func() (string, error)
1840 base, from string
1841 }{
1842 {"git answers", func() (string, error) { return "0123abcd", nil }, "0123abcd", "git merge-base HEAD origin/main"},
1843 {"git fails (no git, no origin/main)", func() (string, error) { return "", fmt.Errorf("exit status 128") }, "pinnedsha", "the pinned movesScanBase"},
1844 {"git says nothing", func() (string, error) { return "", nil }, "pinnedsha", "the pinned movesScanBase"},
1845 }
1846 for _, c := range cases {
1847 base, from := resolveMovesScanBase(c.lookup, "pinnedsha")
1848 if base != c.base || from != c.from {
1849 t.Errorf("%s: = %q from %q, want %q from %q", c.name, base, from, c.base, c.from)
1850 }
1851 }
1852 // The real lookup, where git and origin/main are there, names a commit
1853 // HEAD contains.
1854 b, err := movesMergeBase()
1855 if err != nil || b == "" {
1856 t.Skipf("git merge-base HEAD origin/main: %v", err)
1857 }
1858 if err := exec.Command("git", "merge-base", "--is-ancestor", b, "HEAD").Run(); err != nil {
1859 t.Errorf("the merge base %s is not an ancestor of HEAD: %v", b, err)
1860 }
1861}
1862
1863// The scan reads the files listed in movesScanFiles. When moves-phase1's diff
1864// names a production Go file the list does not, the scan has a blind spot:
1865// add it. The check is skipped where git or the base commit is not available.
1866func TestMovesScanFilesCoverTheDiff(t *testing.T) {
1867 base := movesScanBaseRef(t)
1868 out, err := exec.Command("git", "diff", "--name-only", base+"..HEAD", "--", "*.go").Output()
1869 if err != nil {
1870 t.Skipf("git diff %s..HEAD: %v", base, err)
1871 }
1872 exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
1873 for _, f := range movesUncovered(strings.Fields(string(out)), movesScanFiles, exists, movesCodeFile) {
1874 t.Errorf("%s changed since %s and is not in movesScanFiles", f, base)
1875 }
1876 for _, f := range movesScanFiles {
1877 if _, err := os.Stat(f); err != nil {
1878 t.Errorf("movesScanFiles names %s, which does not exist", f)
1879 }
1880 }
1881}
1882
1883// The exemptions in movesLegacyFiles and movesLegacyFuncs are only for
1884// sentences main already had. Where git can say, every exempt emission's text
1885// is already a string literal of the file at the base commit; a sentence the
1886// series writes into one of these files must get a row, so this fails and
1887// the sentence moves out of the exemption.
1888func TestMovesLegacyExemptionsAreLegacy(t *testing.T) {
1889 base := movesScanBaseRef(t)
1890 files := map[string]bool{}
1891 for f := range movesLegacyFiles {
1892 files[f] = true
1893 }
1894 for f := range movesLegacyFuncs {
1895 files[f] = true
1896 }
1897 var names []string
1898 for f := range files {
1899 names = append(names, f)
1900 }
1901 sort.Strings(names)
1902 for _, f := range names {
1903 // The base commit's package, written out so the same scan reads it.
1904 root := t.TempDir()
1905 dir := filepath.Dir(f)
1906 list, err := exec.Command("git", "ls-tree", "--name-only", base, dir+"/").Output()
1907 if err != nil {
1908 t.Skipf("git ls-tree %s: %v", base, err)
1909 }
1910 if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
1911 t.Fatal(err)
1912 }
1913 for _, name := range strings.Fields(string(list)) {
1914 if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
1915 continue
1916 }
1917 body, err := exec.Command("git", "show", base+":"+name).Output()
1918 if err != nil {
1919 t.Skipf("git show %s:%s: %v", base, name, err)
1920 }
1921 if err := os.WriteFile(filepath.Join(root, name), body, 0o600); err != nil {
1922 t.Fatal(err)
1923 }
1924 }
1925 inBase := map[string]bool{}
1926 for _, e := range scanMovesFileWith(t, root, f, true) {
1927 inBase[e.pattern] = true
1928 }
1929 exempt := 0
1930 for _, e := range scanMovesFileWith(t, ".", f, true) {
1931 if !movesLegacy(e) {
1932 continue
1933 }
1934 exempt++
1935 if !inBase[e.pattern] {
1936 t.Errorf("%s is exempt as main's own sentence, but %s at %s did not say it", e, f, base)
1937 }
1938 }
1939 if exempt == 0 {
1940 t.Errorf("%s has an exemption but emits nothing under it: drop it", f)
1941 }
1942 }
1943 for f, fns := range movesLegacyFuncs {
1944 have := map[string]bool{}
1945 for _, e := range scanMovesFile(t, f) {
1946 have[e.fn] = true
1947 }
1948 for fn := range fns {
1949 if !have[fn] {
1950 t.Errorf("movesLegacyFuncs names %s in %s, which emits nothing: drop the entry", fn, f)
1951 }
1952 }
1953 }
1954}
1955
1956// The checks bite on the real record too: each of these edits to it, made in
1957// memory, is a failure the gate must report.
1958func TestMovesGateBitesOnTheRealRecord(t *testing.T) {
1959 rows := mustParseMovesRecord(t)
1960
1961 if got := checkMovesInventory(rows[1:], movesCopyIDs); len(got) != 1 || !strings.Contains(got[0], "no row "+rows[0].ID) {
1962 t.Errorf("a deleted first row: problems = %q", got)
1963 }
1964 if got := checkMovesInventory(rows[:len(rows)-1], movesCopyIDs); len(got) != 1 {
1965 t.Errorf("a deleted last row: problems = %q", got)
1966 }
1967 extra := append(append([]copyRow{}, rows...), copyRow{ID: "MV-N9999", File: "x.go", Text: "x", Status: "pending"})
1968 if got := checkMovesInventory(extra, movesCopyIDs); len(got) != 1 || !strings.Contains(got[0], "MV-N9999") {
1969 t.Errorf("an added row: problems = %q", got)
1970 }
1971
1972 // A row approved with text the file does not carry.
1973 changed := append([]copyRow{}, rows...)
1974 for i := range changed {
1975 if changed[i].ID == "MV-N6" {
1976 changed[i].Status = "approved"
1977 changed[i].Text = `cut must be "automatic" or "manual"`
1978 }
1979 }
1980 if got := checkMovesApplied(changed, "."); len(got) != 1 || !strings.Contains(got[0], "MV-N6") {
1981 t.Errorf("an approved row whose text is not in its file: problems = %q", got)
1982 }
1983 // The same row with the text the code speaks is applied.
1984 for i := range changed {
1985 if changed[i].ID == "MV-N6" {
1986 changed[i].Text = `cut must be "auto" or "manual"`
1987 }
1988 }
1989 if got := checkMovesApplied(changed, "."); len(got) != 0 {
1990 t.Errorf("an approved row whose text is in its file: problems = %q", got)
1991 }
1992
1993 // The real record may have no pending rows left once Paul approves them
1994 // all, so the switch check bites on the record plus one pending row.
1995 if got := checkPendingKeepsSwitchOff(extra, true); len(got) == 0 {
1996 t.Error("a default that is on beside pending rows must fail")
1997 }
1998}
1999
2000// movesViolationExempt reports whether a violation is main's own (legacy) or
2001// is allow-listed with a reason.
2002func movesViolationExempt(v movesViolation) bool {
2003 if _, ok := movesLegacyFiles[v.file]; ok {
2004 return true
2005 }
2006 if _, ok := movesLegacyFuncs[v.file][v.fn]; ok {
2007 return true
2008 }
2009 _, ok := movesSinkAllow[v.file][v.fn]
2010 return ok
2011}
2012
2013// Every value that reaches an operator sink in the scanned files is a
2014// sentence the gate can read: a literal, a const, a concatenation or Sprintf
2015// with a literal format, or a call to a sentence helper. A forwarded
2016// err.Error(), a variable holding text, or an expression the gate cannot read
2017// fails here, naming file:line, because the record cannot vouch for text the
2018// gate never sees.
2019func TestMovesSinksAreSentences(t *testing.T) {
2020 for _, f := range movesScanFiles {
2021 _, vios := scanMovesFileFull(t, ".", f, false)
2022 for _, v := range vios {
2023 if !movesViolationExempt(v) {
2024 t.Errorf("%s", v)
2025 }
2026 }
2027 }
2028}
2029
2030// ---- negative fixtures for the sink rule, the matcher and the placeholder check ----
2031
2032// fixtureScan scans a sink fixture in its own universe, and returns the
2033// universe too, for the function-value check.
2034func fixtureScan(t *testing.T, dir string) ([]movesEmission, []movesViolation, *movesUniverse) {
2035 t.Helper()
2036 file := filepath.Join("testdata", "moves-copy", "sinks", dir, "emit.go")
2037 u := newUniverse(t, ".", []string{file})
2038 ems, vios := scanMovesFileIn(t, u, ".", file, false)
2039 return ems, vios, u
2040}
2041
2042// fixtureRows is the fixture's record.md, or a record with one unrelated row.
2043func fixtureRows(t *testing.T, dir string) []copyRow {
2044 t.Helper()
2045 path := filepath.Join("testdata", "moves-copy", "sinks", dir, "record.md")
2046 if _, err := os.Stat(path); err == nil {
2047 rows, err := parseMovesCopyRecord(path)
2048 if err != nil {
2049 t.Fatal(err)
2050 }
2051 return rows
2052 }
2053 return []copyRow{{ID: "MV-N1", File: "x.go", Text: "an unrelated sentence", Status: "pending"}}
2054}
2055
2056func wantViolations(t *testing.T, name string, got []movesViolation, wants ...string) {
2057 t.Helper()
2058 if len(got) != len(wants) {
2059 t.Errorf("%s: %d violations %v, want %d", name, len(got), got, len(wants))
2060 return
2061 }
2062 for i, w := range wants {
2063 if !strings.Contains(got[i].String(), w) {
2064 t.Errorf("%s: violation %d = %q, want one containing %q", name, i, got[i], w)
2065 }
2066 }
2067}
2068
2069// Each counterexample fails the sink gate, and, where the value is one the
2070// record should have seen, fails the record gate too: an underived value matches
2071// no row, and a substituted or named-result sentence is read as it is spoken.
2072func TestMovesSinkFixtures(t *testing.T) {
2073 cases := []struct {
2074 dir string
2075 vios []string // substrings, in order
2076 unrecorded int // emissions no row of the fixture's record covers
2077 }{
2078 {"forwarded-error", []string{
2079 "emit.go:7 (jsonError in refuse): err.Error(...) forwards an error's text",
2080 "emit.go:18 (Check.Reason in report): helper why, at ",
2081 }, 2},
2082 {"sprintf-v", []string{
2083 "emit.go:9 (jsonError in viaSprintf): err puts an error's text in a sentence",
2084 "emit.go:9 (jsonError in viaSprintf): a value with no text of its own",
2085 "emit.go:13 (jsonError in viaConcat): err.Error(...) puts an error's text in a sentence",
2086 "emit.go:13 (jsonError in viaConcat): a value with no text of its own",
2087 "emit.go:17 (jsonError in viaVerbs): err puts an error's text in a sentence",
2088 "emit.go:17 (jsonError in viaVerbs): a value with no text of its own",
2089 }, 3},
2090 {"field-reason", []string{
2091 "emit.go:12 (jsonError in positional): a write to field Reason at ",
2092 "emit.go:12 (jsonError in positional): a write to field Reason at ",
2093 "emit.go:12 (jsonError in positional): a write to field Reason at ",
2094 "emit.go:17 (jsonError in keyed): a write to field Reason at ",
2095 "emit.go:17 (jsonError in keyed): a write to field Reason at ",
2096 "emit.go:17 (jsonError in keyed): a write to field Reason at ",
2097 "emit.go:22 (jsonError in assigned): a write to field Reason at ",
2098 }, 4},
2099 {"local-variable", []string{
2100 "emit.go:10 (jsonError in mixed): err.Error(...) forwards an error's text",
2101 "emit.go:15 (jsonError in fromMultiValue): helper reasonOf, at ",
2102 "emit.go:24 (jsonError in rangeVar): n is assigned a value the gate cannot read",
2103 }, 4},
2104 {"helper-bad-return", []string{
2105 "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",
2106 }, 2},
2107 {"param", []string{
2108 "emit.go:7 (jsonError in refuse): err.Error(...) forwards an error's text",
2109 "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(...)",
2110 }, 2},
2111 {"substitution", nil, 1},
2112 {"named-result", nil, 1},
2113 {"shadow", []string{
2114 "emit.go:12 (jsonError in refuse): say at ",
2115 }, 1},
2116 {"interface", []string{
2117 "emit.go:17 (jsonError in viaInterface): m.Message(...) at ",
2118 "emit.go:21 (jsonError in viaUnknown): x.Message(...)",
2119 }, 2},
2120 {"funcvalue", []string{
2121 "emit.go:8 (jsonError in send): parameter msg is not bound to an argument",
2122 "emit.go:23 (jsonError in report): parameter msg is not bound to an argument",
2123 }, 2},
2124 {"limit", []string{
2125 "emit.go:19 (jsonError in chainLiteral): analysis limit reached at ",
2126 "emit.go:35 (jsonError in chainError): analysis limit reached at ",
2127 }, 2},
2128 {"unresolved", []string{
2129 "emit.go:6 (jsonError in methodResult): s.render(...): no method render on server in this package",
2130 "emit.go:10 (jsonError in indexed): names is not a sentence the gate can read",
2131 "emit.go:14 (Check.Reason in bare): a value with no text of its own",
2132 "emit.go:18 (jsonError in dynamicFormat): fmt.Sprintf(...) has a format that is not a literal",
2133 "emit.go:18 (jsonError in dynamicFormat): parameter format is not bound to an argument",
2134 "emit.go:22 (jsonError in field): r.Message: no struct in the scanned files declares a field Message",
2135 }, 5},
2136 // Fix round 4. Every format in error-text matches a row of its record,
2137 // so the only unrecorded emissions are the underived ones: a row never
2138 // vouches for an error's text.
2139 {"error-text", []string{
2140 "emit.go:16 (jsonError in formatArg): err puts an error's text in a sentence",
2141 "emit.go:20 (jsonError in concatError): err.Error(...) puts an error's text in a sentence",
2142 "emit.go:25 (jsonError in wrapVerb): why is the argument of a %w verb",
2143 "emit.go:30 (jsonError in implementsError): r puts an error's text in a sentence",
2144 "emit.go:35 (jsonError in unknownErr): lastErr puts an error's text in a sentence",
2145 "emit.go:40 (jsonError in errorField): res.Err puts an error's text in a sentence",
2146 "emit.go:46 (jsonError in localText): detail puts an error's text in a sentence",
2147 "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",
2148 }, 8},
2149 // Fix round 5. MV-N2 matches every format here; the helper's body and
2150 // the field's writes are what the gate refuses.
2151 {"error-helper", []string{
2152 "emit.go:14 (jsonError in viaHelper): detail(...) puts an error's text in a sentence (helper detail returns ",
2153 "emit.go:18 (jsonError in viaHelperConcat): detail(...) puts an error's text in a sentence (helper detail returns ",
2154 "emit.go:35 (jsonError in viaOpaque): opaque(...) puts text the gate cannot derive in a sentence (helper opaque returns ",
2155 "emit.go:44 (jsonError in viaEcho): echo(...) puts an error's text in a sentence (helper echo returns ",
2156 "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",
2157 "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",
2158 }, 6},
2159 {"field-limit", []string{
2160 "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 ",
2161 }, 2},
2162 // Both reads are of refusal.Reason, so each sees both writes.
2163 {"field-multi", []string{
2164 "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",
2165 "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",
2166 "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",
2167 "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",
2168 }, 2},
2169 {"shadow-receiver", []string{
2170 "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",
2171 }, 1},
2172 {"ok", nil, 0},
2173 }
2174 for _, c := range cases {
2175 ems, vios, _ := fixtureScan(t, c.dir)
2176 wantViolations(t, c.dir, vios, c.vios...)
2177 if un := findUnrecorded(ems, fixtureRows(t, c.dir)); len(un) != c.unrecorded {
2178 t.Errorf("%s: %d unrecorded %v, want %d", c.dir, len(un), un, c.unrecorded)
2179 }
2180 }
2181 // The sentences that are read as spoken.
2182 for dir, want := range map[string]string{"substitution": "an unrecorded sentence", "named-result": "an unrecorded sentence"} {
2183 ems, _, _ := fixtureScan(t, dir)
2184 if len(ems) != 1 || ems[0].pattern != want {
2185 t.Errorf("%s: emissions = %v, want exactly %q", dir, ems, want)
2186 }
2187 }
2188}
2189
2190// A sentence-flow function referenced other than as a direct call lets a call
2191// through the value escape the argument checks.
2192func TestMovesFunctionValueFixture(t *testing.T) {
2193 _, _, u := fixtureScan(t, "funcvalue")
2194 var got []string
2195 for _, r := range funcValueRefs(t, u) {
2196 got = append(got, r.msg)
2197 }
2198 want := []string{"emit.go:16:7: send is used as a value", "emit.go:27:7: s.report is used as a value"}
2199 if len(got) != len(want) {
2200 t.Fatalf("refs = %q, want %d", got, len(want))
2201 }
2202 for i, w := range want {
2203 if !strings.Contains(got[i], w) {
2204 t.Errorf("ref %d = %q, want one containing %q", i, got[i], w)
2205 }
2206 }
2207 _, _, ok := fixtureScan(t, "ok")
2208 if refs := funcValueRefs(t, ok); len(refs) != 0 {
2209 t.Errorf("the ok fixture: refs = %v", refs)
2210 }
2211}
2212
2213// A local closure configured as a sink (verifyArchive's fail) is a
2214// sentence-flow function too: an alias of it (send := fail) escapes the
2215// checks at fail's call sites, so the alias is refused.
2216func TestMovesLocalClosureAliasFixture(t *testing.T) {
2217 file := filepath.Join("testdata", "moves-copy", "sinks", "closure-alias", "emit.go")
2218 movesLocalSinks[file] = map[string]int{"fail": 0}
2219 defer delete(movesLocalSinks, file)
2220 _, vios, u := fixtureScan(t, "closure-alias")
2221 if len(vios) != 0 {
2222 t.Errorf("violations = %v, want none: the alias is the function-value check's", vios)
2223 }
2224 var got []string
2225 for _, r := range funcValueRefs(t, u) {
2226 got = append(got, r.msg)
2227 }
2228 if len(got) != 1 || !strings.Contains(got[0], "emit.go:18:10: fail is used as a value") {
2229 t.Errorf("refs = %q, want exactly fail at emit.go:18:10", got)
2230 }
2231 // Without the configuration fail is an ordinary closure, and its own
2232 // parameter is unbound at its sink.
2233 delete(movesLocalSinks, file)
2234 if _, vios, _ := fixtureScan(t, "closure-alias"); len(vios) == 0 {
2235 t.Error("an unconfigured closure sink must still be a violation")
2236 }
2237}
2238
2239// A mapping helper may echo a refusal's text only in a case whose guard tests
2240// it against literals.
2241func TestMovesMappingHelperFixture(t *testing.T) {
2242 file := filepath.Join("testdata", "moves-copy", "sinks", "mapping", "emit.go")
2243 movesMappingHelpers[file] = map[string]bool{"mapRefusal": true, "unguarded": true, "badGuard": true}
2244 defer delete(movesMappingHelpers, file)
2245 _, vios := scanMovesFileFull(t, ".", file, false)
2246 wantViolations(t, "a mapping helper", vios,
2247 "emit.go:8 (jsonError in refuse): helper unguarded, at ",
2248 "emit.go:9 (jsonError in refuse): helper badGuard, at ",
2249 )
2250 // Without the configuration the same echo is refused.
2251 delete(movesMappingHelpers, file)
2252 _, vios = scanMovesFileFull(t, ".", file, false)
2253 if len(vios) != 3 {
2254 t.Errorf("an unconfigured mapping helper: %d violations, want 3: %v", len(vios), vios)
2255 }
2256}
2257
2258// An allow-list entry exempts a sink, but it needs a reason and must still
2259// exempt something.
2260func checkSinkAllow(allow map[string]map[string]string, vios []movesViolation) []string {
2261 used := map[string]bool{}
2262 for _, v := range vios {
2263 used[v.file+" "+v.fn] = true
2264 }
2265 var out []string
2266 for file, fns := range allow {
2267 for fn, why := range fns {
2268 if len(strings.Fields(why)) < 5 {
2269 out = append(out, fmt.Sprintf("%s %s: the allow-list entry needs a comment saying why the value never reaches an operator", file, fn))
2270 }
2271 if !used[file+" "+fn] {
2272 out = append(out, fmt.Sprintf("%s %s: the allow-list entry exempts nothing; drop it", file, fn))
2273 }
2274 }
2275 }
2276 sort.Strings(out)
2277 return out
2278}
2279
2280func TestMovesSinkAllowListIsHonest(t *testing.T) {
2281 var all []movesViolation
2282 for _, f := range movesScanFiles {
2283 _, v := scanMovesFileFull(t, ".", f, false)
2284 all = append(all, v...)
2285 }
2286 reportAll(t, checkSinkAllow(movesSinkAllow, all))
2287
2288 vios := []movesViolation{{file: "a.go", fn: "f"}}
2289 if got := checkSinkAllow(map[string]map[string]string{"a.go": {"f": "because"}}, vios); len(got) != 1 || !strings.Contains(got[0], "needs a comment") {
2290 t.Errorf("an entry with no reason: %q", got)
2291 }
2292 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") {
2293 t.Errorf("an entry that exempts nothing: %q", got)
2294 }
2295 if got := checkSinkAllow(map[string]map[string]string{"a.go": {"f": "this value is only ever logged"}}, vios); len(got) != 0 {
2296 t.Errorf("a good entry: %q", got)
2297 }
2298
2299 // An allow-listed function's underivable value needs no row; its
2300 // derivable sentences still do, and another function's value does.
2301 saved := movesSinkAllow
2302 defer func() { movesSinkAllow = saved }()
2303 movesSinkAllow = map[string]map[string]string{"a.go": {"f": "passes on a sentence recorded elsewhere"}}
2304 ems := []movesEmission{
2305 {file: "a.go", fn: "f", kind: "say", underived: true},
2306 {file: "a.go", fn: "f", kind: "say", pattern: "an unrecorded sentence of f"},
2307 {file: "a.go", fn: "f", kind: "say", underived: true, fromParam: true},
2308 {file: "a.go", fn: "g", kind: "say", underived: true},
2309 }
2310 if un := findUnrecorded(ems, nil); len(un) != 3 || un[0].fn != "f" || un[0].underived || !un[1].fromParam || un[2].fn != "g" {
2311 t.Errorf("unrecorded = %v, want f's derived sentence, f's value made from a parameter, and g's value", un)
2312 }
2313}
2314
2315// "valid region" is not "invalid region", though the record's text contains it.
2316func TestMovesWholeSentencesOnly(t *testing.T) {
2317 dir := filepath.Join("testdata", "moves-copy", "substring")
2318 rows, err := parseMovesCopyRecord(filepath.Join(dir, "record.md"))
2319 if err != nil {
2320 t.Fatal(err)
2321 }
2322 ems := scanMovesFile(t, filepath.Join(dir, "emit.go"))
2323 un := findUnrecorded(ems, rows)
2324 if len(un) != 1 || un[0].pattern != "valid region" {
2325 t.Errorf("unrecorded = %v, want only \"valid region\"", un)
2326 }
2327
2328 cases := []struct {
2329 row, pattern string
2330 want bool
2331 }{
2332 {"invalid region", "invalid region", true},
2333 {"invalid region", "valid region", false},
2334 {"invalid region", "invalid", false},
2335 {"region <to> is gone", "region " + wild + " is gone", true},
2336 {"a <role> does not move", "a replica does not move", true},
2337 {"S2: the next spawn of this host is not strict", "the next spawn of this host is not strict", true},
2338 {"T11: the cut budget admits no probe attempt", "T11: " + wild, true},
2339 {"S2: the next spawn is not strict", "the next spawn is not strict now", false},
2340 {"the source did not answer", "S1: the source did not answer", false},
2341 }
2342 for _, c := range cases {
2343 if got := movesRowMatches(c.row, c.pattern); got != c.want {
2344 t.Errorf("movesRowMatches(%q, %q) = %v, want %v", c.row, c.pattern, got, c.want)
2345 }
2346 }
2347}
2348
2349// Approved placeholder text is matched inside one expression. Fragments in
2350// unrelated literals are never joined.
2351func TestMovesApprovedPlaceholderTextIsOneExpression(t *testing.T) {
2352 root := filepath.Join("testdata", "moves-copy", "assembled")
2353 rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
2354 if err != nil {
2355 t.Fatal(err)
2356 }
2357 got := checkMovesApplied(rows, root)
2358 if len(got) != 2 {
2359 t.Fatalf("problems = %q, want exactly MV-N1 (unrelated fragments) and MV-N4 (only the start of the sentence)", got)
2360 }
2361 sort.Strings(got)
2362 if !strings.HasPrefix(got[0], "MV-N1:") || !strings.HasPrefix(got[1], "MV-N4:") {
2363 t.Errorf("problems = %q", got)
2364 }
2365}
2366
2367// literalPatterns reads the texts of an expression written in place: literals,
2368// concatenations, Sprintf and errors.New formats, and consts of the same file.
2369// What it cannot read is a wildcard.
2370func literalPatterns(e ast.Expr, vars map[string]ast.Expr, depth int) []string {
2371 if depth > 6 {
2372 return []string{wild}
2373 }
2374 switch e := e.(type) {
2375 case *ast.BasicLit:
2376 if v, err := strconv.Unquote(e.Value); err == nil && e.Kind == token.STRING {
2377 return []string{v}
2378 }
2379 case *ast.ParenExpr:
2380 return literalPatterns(e.X, vars, depth)
2381 case *ast.Ident:
2382 if x, ok := vars[e.Name]; ok {
2383 return literalPatterns(x, vars, depth+1)
2384 }
2385 case *ast.BinaryExpr:
2386 if e.Op == token.ADD {
2387 var out []string
2388 for _, l := range literalPatterns(e.X, vars, depth) {
2389 for _, r := range literalPatterns(e.Y, vars, depth) {
2390 out = append(out, l+r)
2391 }
2392 }
2393 return out
2394 }
2395 case *ast.CallExpr:
2396 if isFormatCall(e) {
2397 var out []string
2398 for _, f := range literalPatterns(e.Args[0], vars, depth+1) {
2399 out = append(out, formatPattern(f))
2400 }
2401 return out
2402 }
2403 }
2404 return []string{wild}
2405}
2406
2407// A function that takes a sentence and is referenced other than as a direct
2408// call (`f := send`, a method value, an argument) lets a call through the
2409// value escape the argument checks. The real tree has none, bar the allow-list.
2410func TestMovesFunctionValuesAreNotSinks(t *testing.T) {
2411 u := universeFor(t, ".", movesScanFiles[0])
2412 for _, f := range movesScanFiles {
2413 scanMovesFileIn(t, u, ".", f, false)
2414 }
2415 refs := funcValueRefs(t, u)
2416 reportAll(t, checkFuncValueAllow(movesFuncValueAllow, refs))
2417 for _, r := range refs {
2418 if _, ok := movesFuncValueAllow[r.file][r.name]; !ok {
2419 t.Error(r.msg)
2420 }
2421 }
2422}
2423
2424// An allow-list entry needs a real reason and must still exempt a reference.
2425func checkFuncValueAllow(allow map[string]map[string]string, refs []funcRef) []string {
2426 used := map[string]bool{}
2427 for _, r := range refs {
2428 used[r.file+" "+r.name] = true
2429 }
2430 var out []string
2431 for file, names := range allow {
2432 for name, why := range names {
2433 if len(strings.Fields(why)) < 8 {
2434 out = append(out, fmt.Sprintf("%s %s: the allow-list entry needs a real reason", file, name))
2435 }
2436 if !used[file+" "+name] {
2437 out = append(out, fmt.Sprintf("%s %s: the allow-list entry exempts nothing; drop it", file, name))
2438 }
2439 }
2440 }
2441 sort.Strings(out)
2442 return out
2443}
2444
2445// Fix round 2 of Plan 6 Task 1a, ruling 1: text the console wrote reaches an
2446// operator only as serverText, made in one function that reads an HTTP
2447// response body and said only through its one sink. Every other value at a
2448// sink is traced to a copy row. The re-review's three ways round that, and a
2449// forged serverText, each fail the gate.
2450func TestMovesProvenanceBypassesFail(t *testing.T) {
2451 file := filepath.Join("testdata", "moves-copy", "sinks", "provenance", "emit.go")
2452 saved := movesLocalSinks[file]
2453 movesLocalSinks[file] = map[string]int{"moveSay": 1, "sayError": 1, "sayRecorded": 1}
2454 defer func() {
2455 if saved == nil {
2456 delete(movesLocalSinks, file)
2457 } else {
2458 movesLocalSinks[file] = saved
2459 }
2460 }()
2461 u := newUniverse(t, ".", []string{file})
2462 ems, vios := scanMovesFileIn(t, u, ".", file, false)
2463 failed := map[string]bool{}
2464 for _, e := range findUnrecorded(ems, nil) {
2465 failed[e.fn] = true
2466 }
2467 for _, v := range vios {
2468 failed[v.fn] = true
2469 }
2470 spec := movesServerTextSpec{Type: "serverText", Field: "spoken", Maker: "roundTrip", Sink: "saySpoken"}
2471 for _, p := range checkServerText(t, file, spec) {
2472 failed[p.fn] = true
2473 }
2474 for _, fn := range []string{"literalJSON", "forgedStuck", "sayRecorded", "forgedServer", "leaked"} {
2475 if !failed[fn] {
2476 t.Errorf("%s gets round the gate", fn)
2477 }
2478 }
2479}
2480
2481// move.go's serverText has one maker, roundTrip, which reads the body of a
2482// response it got from the HTTP client, and one sink, saySpoken.
2483func TestMovesServerTextHasOneMaker(t *testing.T) {
2484 for file, spec := range movesServerText {
2485 for _, p := range checkServerText(t, file, spec) {
2486 t.Errorf("%s: %s", file, p)
2487 }
2488 }
2489 if _, ok := movesServerText["move.go"]; !ok {
2490 t.Fatal("move.go declares no serverText")
2491 }
2492}
2493
2494// Fix round 3 of Plan 6 Task 1a: two cheap holes in server text's provenance.
2495// Server text may not be a package-level value (a var or const of a type that
2496// is or holds serverText, or a composite literal outside a function body), and
2497// inside its one maker its field is a value read from the decoded response,
2498// never a string literal or a named constant.
2499func TestMovesServerTextRefusesForgedValues(t *testing.T) {
2500 spec := movesServerTextSpec{Type: "serverText", Field: "spoken", Maker: "mover.roundTrip", Sink: "saySpoken"}
2501 for _, c := range []struct {
2502 dir string
2503 want []string
2504 }{
2505 {"server-text-var", []string{
2506 "<package>: declares forged, a package-level serverText",
2507 "<package>: declares forgedList, a package-level serverText",
2508 }},
2509 {"server-text-maker", []string{
2510 `roundTrip: makes a serverText from "unreviewed literal in the maker", not a value read from the response`,
2511 "roundTrip: makes a serverText from unreviewed, not a value read from the response",
2512 "roundTrip: makes a serverText from local, not a value read from the response",
2513 }},
2514 } {
2515 file := filepath.Join("testdata", "moves-copy", "sinks", c.dir, "emit.go")
2516 var got []string
2517 for _, p := range checkServerText(t, file, spec) {
2518 got = append(got, p.String())
2519 }
2520 sort.Strings(got)
2521 want := append([]string(nil), c.want...)
2522 sort.Strings(want)
2523 if strings.Join(got, "\n") != strings.Join(want, "\n") {
2524 t.Errorf("%s:\n got %q\nwant %q", c.dir, got, want)
2525 }
2526 }
2527}
2528
2529// Every movesDiagnosticWrites entry leaves out exactly the writes it counts,
2530// and the reason it gives is a sentence.
2531func TestMovesDiagnosticWritesAreReal(t *testing.T) {
2532 u := newUniverse(t, ".", movesScanFiles)
2533 for f, keys := range movesDiagnosticWrites {
2534 for key, w := range keys {
2535 if len(w.Why) < 40 {
2536 t.Errorf("%s %s: the reason is too short to be one", f, key)
2537 }
2538 if got := u.diagSkipped[f+"|"+key]; got != w.Count {
2539 t.Errorf("%s %s leaves out %d writes, want exactly %d", f, key, got, w.Count)
2540 }
2541 }
2542 }
2543}
2544