copy gate: list templates, quoted %q and File-cell notes; MV-N54, N129, N146 approved
Paul adopted three notation rules on 2026-10-10 (preflight L14). The gate now reads a final `<element>, ...` as one runtime list placeholder: the head must be spoken with the list as the code's own final fill-in, and the element is checked where the code builds it (movesListElementFiles), which must join the list with ", ". formatPattern renders %q as a quoted fill-in. MV-N146's note moves to its File cell and its text becomes the fill-in. MV-N54, MV-N129 and MV-N146 had their wording approved in the 2026-10-09 review and were pending only because the gate could not read them; they flip to approved verbatim. Negative fixtures for each rule, each shown to catch a mutation of the rule it guards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+433
−23
approved_moves_copy_test.go+289 −16docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+7 −7testdata/moves-copy/file-note/readiness.go+13 −0testdata/moves-copy/file-note/record.md+11 −0testdata/moves-copy/list-join/head.go+11 −0testdata/moves-copy/list-join/record.md+5 −0testdata/moves-copy/list-template/elem.go+17 −0testdata/moves-copy/list-template/head.go+42 −0testdata/moves-copy/list-template/record.md+15 −0testdata/moves-copy/quoted-verb/quoted.go+13 −0testdata/moves-copy/quoted-verb/record.md+10 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index db587b302..e74242707 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -214,6 +214,12 @@ var copyPlaceholder = regexp.MustCompile(`<[^<>]*>`) |
| // short fixed text must be a whole literal, as in copyApplied) or raw in any |
| // other file. |
| func checkMovesApplied(rows []copyRow, root string) []string { |
| + return checkMovesAppliedWith(rows, root, movesListElementFiles) |
| +} |
| + |
| +// checkMovesAppliedWith is checkMovesApplied with the map of where each list |
| +// template's element is built (movesListElementFiles for the real record). |
| +func checkMovesAppliedWith(rows []copyRow, root string, elementFiles map[string]string) []string { |
| var out []string |
| for _, r := range rows { |
| if r.Status != "approved" { |
| @@ -224,6 +230,12 @@ func checkMovesApplied(rows []copyRow, root string) []string { |
| out = append(out, fmt.Sprintf("%s: %v", r.ID, err)) |
| continue |
| } |
| + if strings.HasSuffix(r.Path(), ".go") { |
| + if _, _, list, _ := movesListTemplate(r.Text); list { |
| + out = append(out, checkMovesListApplied(r, root, body, elementFiles)...) |
| + continue |
| + } |
| + } |
| if !movesTextApplied(r.Path(), r.Text, body) { |
| out = append(out, fmt.Sprintf("%s: %s does not contain the approved text %q", r.ID, r.Path(), r.Text)) |
| } |
| @@ -231,6 +243,131 @@ func checkMovesApplied(rows []copyRow, root string) []string { |
| return out |
| } |
| +// ---- list templates (preflight L14, adopted by Paul 2026-10-10) ---- |
| +// |
| +// A row whose text ends in `<element>, ...` describes a list the code builds |
| +// at run time: "pass --account-id with one of: <sqid> ("<name>"), ...". No |
| +// literal in the code ends in ", ...", so the gate reads the template as two |
| +// things and checks each where the code makes it: |
| +// |
| +// - the head, the row's text with the whole list as one placeholder, is |
| +// spoken by one expression of the row's file, whose final part is the |
| +// code's own fill-in (the list is never fixed text); |
| +// - the element is built where the code builds it (the row's file, or the |
| +// file movesListElementFiles names), and that file joins the list with |
| +// ", ", which is what the row's ", ..." shows. |
| +// |
| +// The list follows the head's last ": ", as both template rows do; a row |
| +// that ends in ", ..." with no such colon, or whose element does not start |
| +// with a placeholder, cannot be read and is refused. An element that is one |
| +// placeholder (MV-N129's <NAME (kind)>) has no fixed text to check: only its |
| +// join is. |
| + |
| +// movesListTail is how a row's text ends when its last part is a list. |
| +const movesListTail = ", ..." |
| + |
| +// movesListElementFiles names, per row, the file that builds the element of |
| +// its list when that is not the row's own file. MV-N54's sentence is the move |
| +// door's (apiinstancemove.go), and its alternatives are built and joined by |
| +// the account gate it shares with every CLI door (apicli.go, main's own). |
| +var movesListElementFiles = map[string]string{ |
| + "MV-N54": "internal/console/apicli.go", |
| +} |
| + |
| +// movesListTemplate splits a row's text that ends in a list template into |
| +// its head (through the colon that introduces the list) and its element. |
| +// list reports whether the text ends in one at all; err says why one cannot |
| +// be read. |
| +func movesListTemplate(text string) (head, element string, list bool, err error) { |
| + if !strings.HasSuffix(text, movesListTail) { |
| + return "", "", false, nil |
| + } |
| + body := strings.TrimSuffix(text, movesListTail) |
| + i := strings.LastIndex(body, ": ") |
| + if i < 0 { |
| + return "", "", true, fmt.Errorf("no \": \" introduces the list") |
| + } |
| + head, element = body[:i+2], body[i+2:] |
| + if !strings.HasPrefix(element, "<") { |
| + return "", "", true, fmt.Errorf("the element %q does not start with a placeholder", element) |
| + } |
| + return head, element, true, nil |
| +} |
| + |
| +// checkMovesListApplied checks a list-template row: its head in its own |
| +// file, and its element and join where the element is built. |
| +func checkMovesListApplied(r copyRow, root string, body []byte, elementFiles map[string]string) []string { |
| + head, element, _, err := movesListTemplate(r.Text) |
| + if err != nil { |
| + return []string{fmt.Sprintf("%s: the gate cannot read the list template in %q: %v", r.ID, r.Text, err)} |
| + } |
| + var out []string |
| + headSpoken := false |
| + for _, p := range goTextPatterns(r.Path(), body) { |
| + if informative(p) && strings.HasSuffix(p, wild) && movesRowSpoken(head+"<list>", p) { |
| + headSpoken = true |
| + break |
| + } |
| + } |
| + if !headSpoken { |
| + 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>")) |
| + } |
| + elemFile := r.Path() |
| + if f, ok := elementFiles[r.ID]; ok { |
| + elemFile = f |
| + } |
| + elemBody, err := os.ReadFile(filepath.Join(root, elemFile)) |
| + if err != nil { |
| + return append(out, fmt.Sprintf("%s: the list's element file: %v", r.ID, err)) |
| + } |
| + if copyPlaceholder.ReplaceAllString(element, "") != "" { |
| + built := false |
| + for _, p := range goTextPatterns(elemFile, elemBody) { |
| + if movesRowSpoken(element, p) { |
| + built = true |
| + break |
| + } |
| + } |
| + if !built { |
| + out = append(out, fmt.Sprintf("%s: %s does not build the list's element %q", r.ID, elemFile, element)) |
| + } |
| + } |
| + if !joinsWithComma(elemFile, elemBody) { |
| + out = append(out, fmt.Sprintf("%s: %s does not join the list with \", \"", r.ID, elemFile)) |
| + } |
| + return out |
| +} |
| + |
| +// joinsWithComma reports whether a Go file calls strings.Join with the |
| +// literal separator ", ". |
| +func joinsWithComma(file string, body []byte) bool { |
| + f, err := parser.ParseFile(token.NewFileSet(), file, body, 0) |
| + if err != nil { |
| + return false |
| + } |
| + found := false |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + c, ok := n.(*ast.CallExpr) |
| + if !ok || found || len(c.Args) != 2 { |
| + return !found |
| + } |
| + sel, ok := c.Fun.(*ast.SelectorExpr) |
| + if !ok || sel.Sel.Name != "Join" { |
| + return true |
| + } |
| + if id, ok := sel.X.(*ast.Ident); !ok || id.Name != "strings" { |
| + return true |
| + } |
| + if lit, ok := c.Args[1].(*ast.BasicLit); ok && lit.Kind == token.STRING { |
| + if v, err := strconv.Unquote(lit.Value); err == nil && v == ", " { |
| + found = true |
| + } |
| + } |
| + return !found |
| + }) |
| + return found |
| +} |
| + |
| // movesTextApplied reports whether file's body speaks the row's text. In a Go |
| // file the text must be one expression: a string literal, a concatenation of |
| // literals, or one Sprintf or errors.New format. The row's <placeholders> |
| @@ -249,9 +386,22 @@ func movesTextApplied(file, text string, body []byte) bool { |
| } |
| return true |
| } |
| + for _, p := range goTextPatterns(file, body) { |
| + if informative(p) && movesRowSpoken(text, p) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// goTextPatterns returns the pattern of every expression in a Go file that |
| +// could hold a sentence whole: a string literal, a concatenation, or one |
| +// Sprintf or errors.New format, with the file's consts and vars read in |
| +// place. A file that does not parse has none. |
| +func goTextPatterns(file string, body []byte) []string { |
| f, err := parser.ParseFile(token.NewFileSet(), file, body, 0) |
| if err != nil { |
| - return false |
| + return nil |
| } |
| vars := map[string]ast.Expr{} |
| for _, d := range f.Decls { |
| @@ -265,11 +415,8 @@ func movesTextApplied(file, text string, body []byte) bool { |
| } |
| } |
| } |
| - found := false |
| + var out []string |
| ast.Inspect(f, func(n ast.Node) bool { |
| - if found { |
| - return false |
| - } |
| var e ast.Expr |
| switch n := n.(type) { |
| case *ast.BasicLit: |
| @@ -285,17 +432,12 @@ func movesTextApplied(file, text string, body []byte) bool { |
| e = n |
| } |
| } |
| - if e == nil { |
| - return true |
| - } |
| - for _, p := range literalPatterns(e, vars, 0) { |
| - if informative(p) && movesRowSpoken(text, p) { |
| - found = true |
| - } |
| + if e != nil { |
| + out = append(out, literalPatterns(e, vars, 0)...) |
| } |
| - return !found |
| + return true |
| }) |
| - return found |
| + return out |
| } |
| // checkPendingKeepsSwitchOff is D13: a pending row means the compiled default |
| @@ -521,6 +663,131 @@ func TestMovesCopyGateRefusesTheNegativeFixtures(t *testing.T) { |
| t.Errorf("problems = %q, want exactly MV-N2", got) |
| } |
| }) |
| + |
| + // The three notation rules Paul adopted for the list templates |
| + // (preflight L14, 2026-10-10). Each fixture holds a row the rule lets |
| + // through and rows it must refuse, each refused for its own reason. |
| + t.Run("list templates: the head with the list as its fill-in, the element where it is built", func(t *testing.T) { |
| + root := filepath.Join(dir, "list-template") |
| + rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkMovesAppliedWith(rows, root, map[string]string{"MV-N2": "elem.go", "MV-N3": "elem.go"}) |
| + sort.Strings(got) |
| + want := []struct{ id, why string }{ |
| + {"MV-N3", "does not build the list's element \"<id> [<zone>]\""}, |
| + {"MV-N4", "with the list as its final fill-in"}, |
| + {"MV-N5", "cannot read the list template"}, |
| + {"MV-N6", "with the list as its final fill-in"}, |
| + } |
| + if len(got) != len(want) { |
| + t.Fatalf("problems = %q, want exactly MV-N3 to MV-N6 (MV-N1 and MV-N2 are spoken)", got) |
| + } |
| + for i, w := range want { |
| + if !strings.HasPrefix(got[i], w.id+":") || !strings.Contains(got[i], w.why) { |
| + t.Errorf("problem %d = %q, want %s refused because it %s", i, got[i], w.id, w.why) |
| + } |
| + } |
| + }) |
| + |
| + t.Run("a list template joined with anything but a comma", func(t *testing.T) { |
| + root := filepath.Join(dir, "list-join") |
| + rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkMovesAppliedWith(rows, root, nil) |
| + if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], `does not join the list with ", "`) { |
| + t.Errorf("problems = %q, want exactly MV-N1, for its join", got) |
| + } |
| + }) |
| + |
| + t.Run("%q prints quotes, so a row shows them and a %s does not satisfy them", func(t *testing.T) { |
| + root := filepath.Join(dir, "quoted-verb") |
| + rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkMovesApplied(rows, root) |
| + if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N2:") { |
| + t.Errorf("problems = %q, want exactly MV-N2 (its row shows quotes the code's %%s does not print)", got) |
| + } |
| + }) |
| + |
| + t.Run("a note belongs in the File cell, never the Text cell", func(t *testing.T) { |
| + root := filepath.Join(dir, "file-note") |
| + rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := checkMovesApplied(rows, root) |
| + if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], "does not contain the approved text") { |
| + t.Errorf("problems = %q, want exactly MV-N1 (its note is in the Text cell)", got) |
| + } |
| + }) |
| +} |
| + |
| +func TestMovesFormatPatternQuotesQ(t *testing.T) { |
| + cases := map[string]string{ |
| + "account %q is ambiguous": `account "` + wild + `" is ambiguous`, |
| + "%s (%q)": wild + ` ("` + wild + `")`, |
| + "%-10q left": `"` + wild + `" left`, |
| + "took %d of %d": "took " + wild + " of " + wild, |
| + "85%% full": "85% full", |
| + "%v and %s": wild + " and " + wild, |
| + } |
| + for in, want := range cases { |
| + if got := formatPattern(in); got != want { |
| + t.Errorf("formatPattern(%q) = %q, want %q", in, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestMovesListTemplate(t *testing.T) { |
| + cases := []struct { |
| + text, head, element string |
| + list, readable bool |
| + }{ |
| + {`account "<segment>" is ambiguous. pass --account-id with one of: <sqid> ("<name>"), ...`, |
| + `account "<segment>" is ambiguous. pass --account-id with one of: `, `<sqid> ("<name>")`, true, true}, |
| + {"the launch environment differs from the source's: <NAME (kind)>, ...", |
| + "the launch environment differs from the source's: ", "<NAME (kind)>", true, true}, |
| + {"no list here", "", "", false, true}, |
| + {"a list without a colon <a>, ...", "", "", true, false}, |
| + {"a colon then words: the <a>, ...", "", "", true, false}, |
| + {"an empty element: , ...", "", "", true, false}, |
| + } |
| + for _, c := range cases { |
| + head, element, list, err := movesListTemplate(c.text) |
| + if list != c.list || (err == nil) != c.readable || head != c.head || element != c.element { |
| + t.Errorf("movesListTemplate(%q) = %q, %q, %v, %v; want %q, %q, list %v, readable %v", |
| + c.text, head, element, list, err, c.head, c.element, c.list, c.readable) |
| + } |
| + } |
| +} |
| + |
| +// Every entry of movesListElementFiles is a list-template row of the real |
| +// record whose element file exists, so the map never names a row that has no |
| +// element to check. |
| +func TestMovesListElementFilesAreHonest(t *testing.T) { |
| + byID := map[string]copyRow{} |
| + for _, r := range mustParseMovesRecord(t) { |
| + byID[r.ID] = r |
| + } |
| + for id, file := range movesListElementFiles { |
| + r, ok := byID[id] |
| + if !ok { |
| + t.Errorf("movesListElementFiles names %s, which the record has no row for", id) |
| + continue |
| + } |
| + if _, _, list, err := movesListTemplate(r.Text); !list || err != nil { |
| + t.Errorf("movesListElementFiles names %s, whose text %q is not a readable list template", id, r.Text) |
| + } |
| + if _, err := os.Stat(file); err != nil { |
| + t.Errorf("movesListElementFiles: %s's element file: %v", id, err) |
| + } |
| + } |
| } |
| func TestMovesTextAppliedPlaceholders(t *testing.T) { |
| @@ -830,11 +1097,17 @@ const wild = "\x00" |
| var formatVerb = regexp.MustCompile(`%[-+# 0-9.*]*[a-zA-Z%]`) |
| -// splitVerbs turns a format string into a pattern. |
| +// formatPattern turns a format string into a pattern. A verb is the code's |
| +// fill-in, except that %q prints its quotes, so it is a quoted fill-in and a |
| +// row shows the quotes ("<segment>"), preflight L14 as Paul adopted it on |
| +// 2026-10-10. %#q may print backquotes instead, so it stays a bare fill-in. |
| func formatPattern(f string) string { |
| return formatVerb.ReplaceAllStringFunc(f, func(v string) string { |
| - if v == "%%" { |
| + switch { |
| + case v == "%%": |
| return "%" |
| + case strings.HasSuffix(v, "q") && !strings.Contains(v, "#"): |
| + return `"` + wild + `"` |
| } |
| return wild |
| }) |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index fa112854c..5f0aabc70 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -93,7 +93,7 @@ Columns: id, file, text, status. |
| | MV-N51 | internal/console/apiinstancemove.go | a non-hibernating instance runs outside the activator. nothing manages its data, so nothing can move it | approved | |
| | MV-N52 | internal/console/apiinstancemove.go | too many instance requests. wait a minute and try again | approved | |
| | MV-N53 | internal/console/apiinstancemove.go | this console has no deployment bucket, so it cannot answer this | approved | |
| -| MV-N54 | internal/console/apiinstancemove.go | account "<segment>" is ambiguous. pass --account-id with one of: <sqid> ("<name>"), ... | pending | |
| +| MV-N54 | internal/console/apiinstancemove.go | account "<segment>" is ambiguous. pass --account-id with one of: <sqid> ("<name>"), ... | approved | |
| | MV-N55 | internal/moves/readiness.go | the last round moved <bytes> bytes and the bar is <bytes> | approved | |
| | MV-N56 | internal/moves/readiness.go | the target has not published a readiness report yet | approved | |
| | MV-N57 | internal/moves/readiness.go | the target's readiness report is stale | approved | |
| @@ -168,7 +168,7 @@ Columns: id, file, text, status. |
| | MV-N126 | internal/host/movesreport.go | this box has no secrets key | approved | |
| | MV-N127 | internal/host/movesreport.go | this box's secrets key is not the console's | approved | |
| | MV-N128 | internal/host/movesreport.go | this box could not compose the launch environment of the held row | approved | |
| -| MV-N129 | internal/host/movesreport.go | the launch environment differs from the source's: <NAME (kind)>, ... | pending | |
| +| MV-N129 | internal/host/movesreport.go | the launch environment differs from the source's: <NAME (kind)>, ... | approved | |
| | MV-N130 | internal/host/movesreport.go (a T3 note, not a failure) | <NAME> in the carried .env overrides the rendered configuration | approved | |
| | MV-N131 | internal/host/movesreport.go (a T3 note for an advisory variable) | <NAME> (<kind>) | approved | |
| | MV-N132 | internal/host/movesreport.go | the store bound as <PREFIX> did not answer the probe from this box | approved | |
| @@ -185,7 +185,7 @@ Columns: id, file, text, status. |
| | MV-N143 | internal/host/movesreport.go | this box cannot write the return lane in the source's bucket | approved | |
| | MV-N144 | internal/host/movesreport.go | no replica has been staged yet | approved | |
| | MV-N145 | internal/host/movesreport.go | the instance left <region> recently and can move back after <RFC 3339 time> | approved | |
| -| MV-N146 | internal/moves/readiness.go | no fresh rate measurement for the disk's per-file sync (MV-N68 with a new <which>) | pending | |
| +| MV-N146 | internal/moves/readiness.go (the <which> of MV-N68) | the disk's per-file sync | approved | |
| | MV-N147 | internal/console/apiinstancemove.go | invalid region | approved | |
| | MV-N148 | internal/host/movesphase1.go | the app's readiness path could not be read | approved | |
| | MV-N149 | internal/moves/readiness.go | the target has not checked a release yet | approved | |
| @@ -370,10 +370,10 @@ Not built yet (18): |
| - M-C20 (`internal/host/movespaging.go`): the paging sentence is Plan 5, and no `movespaging.go` exists. |
| - MV-N11 (`internal/console/apiinstancemove.go`): the declare-lost door is Plan 4 Task 10. `resolve` answers "nothing to resolve yet" to anything but a retry. |
| -Built, but the gate cannot read the row (3), so the row stays pending until Paul rules on how it is gated: |
| +Built, but the gate could not read the row (3). Paul adopted three notation rules for them on 2026-10-10 (preflight L14), and Plan 6 Task 7 put the rules in the gate and flipped all three to approved, verbatim: |
| -- MV-N54: the row ends in a list template, `<sqid> ("<name>"), ...`. The code builds that list at run time (`apicli.go`) and joins it into the sentence, so the sentence is never one literal ending in `, ...`. Making it so would print a literal `...`. |
| -- MV-N129: the same, `<NAME (kind)>, ...`; `t3Differs` in `internal/host/movesreport.go` joins the names it found. |
| -- MV-N146: the row's text carries a note, `(MV-N68 with a new <which>)`. The code says `no fresh rate measurement for the disk's per-file sync` through MV-N68's `NoFreshRate`, which is correct; the note is not a part of any sentence the code could say. |
| +- MV-N54: the row ends in a list template, `<sqid> ("<name>"), ...`. The code builds that list at run time (`apicli.go`) and joins it into the sentence. Rule: a final `<element>, ...` is one runtime list placeholder. The gate checks the head with the list as the code's own fill-in, and checks the element where the code builds it (`movesListElementFiles` names `apicli.go`). Rule: `%q` prints quotes, so the gate reads it as a quoted fill-in and the row's `"<segment>"` matches. |
| +- MV-N129: the same list rule, `<NAME (kind)>, ...`; `t3Differs` in `internal/host/movesreport.go` joins the names it found. Its element is one placeholder, so only the head and the join are checked. |
| +- MV-N146: the note moved from the Text cell to the File cell (`internal/moves/readiness.go (the <which> of MV-N68)`), and the text is the fill-in `the disk's per-file sync`, which `readiness.go` says as one literal. |
| The 16 rows added after that review (MV-N184 to MV-N186, MV-N300 to MV-N312) were approved by Paul on 2026-10-09 with no edits. |
diff --git a/testdata/moves-copy/file-note/readiness.go b/testdata/moves-copy/file-note/readiness.go| new file mode 100644 |
| index 000000000..6153b3eea |
| --- /dev/null |
| +++ b/testdata/moves-copy/file-note/readiness.go |
| @@ -0,0 +1,13 @@ |
| +package fixture |
| + |
| +// Not compiled: testdata is ignored by the go tool. |
| + |
| +import "fmt" |
| + |
| +type noFreshRate struct{ which string } |
| + |
| +func (e *noFreshRate) Error() string { |
| + return fmt.Sprintf("no fresh rate measurement for %s", e.which) |
| +} |
| + |
| +var errFsync = &noFreshRate{which: "the disk's per-file sync"} |
diff --git a/testdata/moves-copy/file-note/record.md b/testdata/moves-copy/file-note/record.md| new file mode 100644 |
| index 000000000..51334314b |
| --- /dev/null |
| +++ b/testdata/moves-copy/file-note/record.md |
| @@ -0,0 +1,11 @@ |
| +# Negative fixture: a note belongs in the File cell, not the Text cell (MV-N146, preflight L14) |
| + |
| +MV-N1 carries its note in the Text cell, so its text is not a sentence the |
| +code says (a looser match would let the note hide under the code's |
| +fill-in). MV-N2 is the same sentence with the note in the File cell, and is |
| +spoken. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N1 | readiness.go | no fresh rate measurement for the disk's per-file sync (MV-N9 with a new <which>) | approved | |
| +| MV-N2 | readiness.go (the <which> of MV-N9) | the disk's per-file sync | approved | |
diff --git a/testdata/moves-copy/list-join/head.go b/testdata/moves-copy/list-join/head.go| new file mode 100644 |
| index 000000000..45327b32c |
| --- /dev/null |
| +++ b/testdata/moves-copy/list-join/head.go |
| @@ -0,0 +1,11 @@ |
| +package fixture |
| + |
| +// Not compiled: testdata is ignored by the go tool. |
| + |
| +import "strings" |
| + |
| +// The head is spoken, but the list is joined with " and ", so the row's |
| +// ", ..." is not what an operator reads. |
| +func differs(names []string) string { |
| + return "the environment differs from the source's: " + strings.Join(names, " and ") |
| +} |
diff --git a/testdata/moves-copy/list-join/record.md b/testdata/moves-copy/list-join/record.md| new file mode 100644 |
| index 000000000..1d9e6955b |
| --- /dev/null |
| +++ b/testdata/moves-copy/list-join/record.md |
| @@ -0,0 +1,5 @@ |
| +# Negative fixture: a list template whose list is joined with something other than ", " |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N1 | head.go | the environment differs from the source's: <NAME (kind)>, ... | approved | |
diff --git a/testdata/moves-copy/list-template/elem.go b/testdata/moves-copy/list-template/elem.go| new file mode 100644 |
| index 000000000..876bc3012 |
| --- /dev/null |
| +++ b/testdata/moves-copy/list-template/elem.go |
| @@ -0,0 +1,17 @@ |
| +package fixture |
| + |
| +// Not compiled: testdata is ignored by the go tool. |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// The elements of MV-N2 and MV-N3, and the join both lists use. |
| +func alts(sqids, names []string) string { |
| + out := make([]string, len(sqids)) |
| + for i := range sqids { |
| + out[i] = fmt.Sprintf("%s (%q)", sqids[i], names[i]) |
| + } |
| + return strings.Join(out, ", ") |
| +} |
diff --git a/testdata/moves-copy/list-template/head.go b/testdata/moves-copy/list-template/head.go| new file mode 100644 |
| index 000000000..41add6b8b |
| --- /dev/null |
| +++ b/testdata/moves-copy/list-template/head.go |
| @@ -0,0 +1,42 @@ |
| +package fixture |
| + |
| +// Not compiled: testdata is ignored by the go tool. |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// MV-N1: the head, with the joined list as its last fill-in. The element is |
| +// one placeholder, built here. |
| +func differs(names, kinds []string) string { |
| + var out []string |
| + for i := range names { |
| + out = append(out, names[i]+" ("+kinds[i]+")") |
| + } |
| + return "the environment differs from the source's: " + strings.Join(out, ", ") |
| +} |
| + |
| +// MV-N2: %q prints the quotes the row shows. The element is built in |
| +// elem.go, where the record's element file map points. |
| +func ambiguous(seg, alts string) string { |
| + return fmt.Sprintf("account %q is ambiguous. pick one of: %s", seg, alts) |
| +} |
| + |
| +// MV-N3: the head is spoken, but elem.go builds "<id> (<zone>)", not the |
| +// row's "<id> [<zone>]". |
| +func several(region, alts string) string { |
| + return fmt.Sprintf("the region %q has several boxes: %s", region, alts) |
| +} |
| + |
| +// MV-N4: the list is a fixed word, not the code's fill-in. |
| +const noBox = "no box answered the probe: none" |
| + |
| +// MV-N5: the row has no colon before its list, so the gate cannot tell |
| +// where the element starts. The code says it anyway. |
| +func boxes(list string) string { return "the boxes " + list } |
| + |
| +// MV-N6's head is not spoken: the code says "configuration", the row "store". |
| +func storeDiffers(list string) string { |
| + return "the configuration differs from the source's: " + list |
| +} |
diff --git a/testdata/moves-copy/list-template/record.md b/testdata/moves-copy/list-template/record.md| new file mode 100644 |
| index 000000000..527c4f1d9 |
| --- /dev/null |
| +++ b/testdata/moves-copy/list-template/record.md |
| @@ -0,0 +1,15 @@ |
| +# Negative fixture: list templates (preflight L14, Paul 2026-10-10) |
| + |
| +A final `<element>, ...` is one runtime list placeholder: the head is spoken |
| +with the list as the code's own fill-in, and the element is checked |
| +separately where the code builds it. MV-N1 and MV-N2 are spoken; MV-N3 to |
| +MV-N6 are not, each for one reason. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N1 | head.go | the environment differs from the source's: <NAME (kind)>, ... | approved | |
| +| MV-N2 | head.go | account "<segment>" is ambiguous. pick one of: <sqid> ("<name>"), ... | approved | |
| +| MV-N3 | head.go | the region "<region>" has several boxes: <id> [<zone>], ... | approved | |
| +| MV-N4 | head.go | no box answered the probe: <box>, ... | approved | |
| +| MV-N5 | head.go | the boxes <box>, ... | approved | |
| +| MV-N6 | head.go | the store differs from the source's: <NAME (kind)>, ... | approved | |
diff --git a/testdata/moves-copy/quoted-verb/quoted.go b/testdata/moves-copy/quoted-verb/quoted.go| new file mode 100644 |
| index 000000000..b2c70244d |
| --- /dev/null |
| +++ b/testdata/moves-copy/quoted-verb/quoted.go |
| @@ -0,0 +1,13 @@ |
| +package fixture |
| + |
| +// Not compiled: testdata is ignored by the go tool. |
| + |
| +import "fmt" |
| + |
| +func region(r string) string { |
| + return fmt.Sprintf("region %q is not one of this app's regions", r) |
| +} |
| + |
| +func zone(z string) string { |
| + return fmt.Sprintf("zone %s is not one of this region's zones", z) |
| +} |
diff --git a/testdata/moves-copy/quoted-verb/record.md b/testdata/moves-copy/quoted-verb/record.md| new file mode 100644 |
| index 000000000..618311df6 |
| --- /dev/null |
| +++ b/testdata/moves-copy/quoted-verb/record.md |
| @@ -0,0 +1,10 @@ |
| +# Negative fixture: %q prints quotes (preflight L14, Paul 2026-10-10) |
| + |
| +The gate renders %q as a quoted fill-in. MV-N1's row shows the quotes the |
| +code prints, so it is spoken. MV-N2's row shows quotes the code's %s does |
| +not print, so it is not. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N1 | quoted.go | region "<name>" is not one of this app's regions | approved | |
| +| MV-N2 | quoted.go | zone "<name>" is not one of this region's zones | approved | |