carlos / platform Public

copy gate fix round 1: testdata out of the file inventory, sinks must take readable sentences, whole-sentence matching, placeholder text from one expression

Each has a negative fixture under testdata/moves-copy. TestMovesSinksAreSentences
and TestMovesStringsAreRecorded fail on moves code that forwards a variable or
an annotated row; those are listed in the Task 15 report, not fixed here.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8358c0a096de41ef2e19bd4f04b87ef5e2a55a0f parent 9518889
12 files changed, +924 −118
  • approved_moves_copy_test.go +776 −118
  • testdata/moves-copy/assembled/formatted.go +7 −0
  • testdata/moves-copy/assembled/fragments.go +7 −0
  • testdata/moves-copy/assembled/joined.go +5 −0
  • testdata/moves-copy/assembled/prefix.go +7 −0
  • testdata/moves-copy/assembled/record.md +8 −0
  • testdata/moves-copy/sinks/forwarded-error/emit.go +19 −0
  • testdata/moves-copy/sinks/local-variable/emit.go +22 −0
  • testdata/moves-copy/sinks/ok/emit.go +36 −0
  • testdata/moves-copy/sinks/unresolved/emit.go +23 −0
  • testdata/moves-copy/substring/emit.go +9 −0
  • testdata/moves-copy/substring/record.md +5 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 15d3c3bf1..71275867a 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -2,7 +2,6 @@
package main
import (
- "bytes"
"fmt"
"go/ast"
"go/parser"
@@ -203,30 +202,16 @@ func checkMovesApplied(rows []copyRow, root string) []string {
return out
}
+// 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>
+// and the code's %s match each other, but fragments in unrelated literals are
+// never joined, and a comment never counts. Any other file is matched raw,
+// the fixed text between placeholders in order.
func movesTextApplied(file, text string, body []byte) bool {
- segs := copyPlaceholder.Split(text, -1)
- if len(segs) == 1 {
- if !strings.HasSuffix(file, ".go") {
- return bytes.Contains(body, []byte(text))
- }
- // In a Go file the text must be inside a string literal's decoded
- // value (a whole literal when it is short), never in a comment.
- return goLiteralMatches(body, func(v string) bool {
- if len(text) >= shortCopyBytes {
- return strings.Contains(v, text)
- }
- return v == text
- })
- }
- var fixed []string
- for _, s := range segs {
- if s != "" {
- fixed = append(fixed, s)
- }
- }
if !strings.HasSuffix(file, ".go") {
rest := string(body)
- for _, s := range fixed {
+ for _, s := range copyPlaceholder.Split(text, -1) {
i := strings.Index(rest, s)
if i < 0 {
return false
@@ -235,19 +220,58 @@ func movesTextApplied(file, text string, body []byte) bool {
}
return true
}
- next := 0
- goLiteralMatches(body, func(v string) bool {
- for next < len(fixed) {
- i := strings.Index(v, fixed[next])
- if i < 0 {
- break
+ f, err := parser.ParseFile(token.NewFileSet(), file, body, 0)
+ if err != nil {
+ return false
+ }
+ idx := &movesPkgIndex{vars: map[string]ast.Expr{}, funcs: map[string]*ast.FuncDecl{}}
+ for _, d := range f.Decls {
+ switch d := d.(type) {
+ case *ast.FuncDecl:
+ if d.Recv == nil {
+ idx.funcs[d.Name.Name] = d
+ }
+ case *ast.GenDecl:
+ for _, sp := range d.Specs {
+ if vs, ok := sp.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) {
+ for i, nm := range vs.Names {
+ idx.vars[nm.Name] = vs.Values[i]
+ }
+ }
+ }
+ }
+ }
+ found := false
+ ast.Inspect(f, func(n ast.Node) bool {
+ if found {
+ return false
+ }
+ var e ast.Expr
+ switch n := n.(type) {
+ case *ast.BasicLit:
+ if n.Kind == token.STRING {
+ e = n
+ }
+ case *ast.BinaryExpr:
+ if n.Op == token.ADD {
+ e = n
+ }
+ case *ast.CallExpr:
+ if isFormatCall(n) {
+ e = n
+ }
+ }
+ if e == nil {
+ return true
+ }
+ for _, p := range patternsOf(e, idx, 0) {
+ if informative(p) && movesRowSpoken(text, p) {
+ found = true
}
- v = v[i+len(fixed[next]):]
- next++
}
- return next == len(fixed)
+ return !found
})
- return next == len(fixed)
+ return found
}
// checkPendingKeepsSwitchOff is D13: a pending row means the compiled default
@@ -558,10 +582,10 @@ const movesScanBase = "c17f39d5"
// (the third argument). A Check literal's Reason emits. A function in
// movesCopyTables is a table of sentences, and every value in it emits. In a
// file in movesLocalSinks, a call to the named local helper emits its
-// string argument (readiness.go's fail and reasonOr). In a file in
-// movesReturnedSentences, the string results of a function are sentences
-// (Ready and Passing return (bool, string), sourceRefusal (string, error));
-// its error results are not.
+// string argument (readiness.go's fail and reasonOr). A function listed in
+// movesReturnedSentences returns sentences in its string results (Ready and
+// Passing return (bool, string), sourceRefusal (string, error)); its error
+// results are not.
var movesLocalSinks = map[string]map[string]int{
"internal/moves/readiness.go": {"fail": 0, "reasonOr": 1},
"internal/host/movesreport.go": {"fail": 0},
@@ -574,11 +598,13 @@ var movesCopyTables = map[string][]string{
"internal/console/apiinstancemove.go": {"phase1MoveCopy"},
}
-var movesReturnedSentences = map[string]bool{
- "internal/moves/readiness.go": true,
- "internal/host/movesphase1.go": true,
- "internal/host/movesreport.go": true,
- "internal/host/movesstage.go": true,
+var movesReturnedSentences = map[string]map[string]bool{
+ "internal/moves/readiness.go": {"Passing": true, "Ready": true, "budgetEvidence": true, "reasonOr": true, "NoFreshRate": true},
+ "internal/host/movesphase1.go": {"sourceRefusal": true, "routeRefusal": true, "publishArchive": true, "archiveFailure": true,
+ "s1NotExec": true, "s1Sidecar": true, "s1Column": true, "s1PrivateStorage": true, "s1SpecialFile": true,
+ "s1OverCap": true, "s1LongPath": true, "s1NoAnswer": true},
+ "internal/host/movesreport.go": {"t1Below": true, "t3Differs": true, "t3Shadowed": true, "t4Failed": true,
+ "t6NoRoom": true, "t8Count": true, "t12NotQuiet": true, "replicaReason": true},
}
// movesLiteralFuncs are functions whose every string literal is a sentence
@@ -636,6 +662,10 @@ type movesPkgIndex struct {
var movesPkgCache = map[string]*movesPkgIndex{}
+// movesFset positions every file the scan parses, so a violation inside a
+// helper in a sibling file can be named file:line.
+var movesFset = token.NewFileSet()
+
func loadMovesPkg(t *testing.T, dir string) *movesPkgIndex {
t.Helper()
if p, ok := movesPkgCache[dir]; ok {
@@ -652,7 +682,7 @@ func loadMovesPkg(t *testing.T, dir string) *movesPkgIndex {
if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
continue
}
- f, err := parser.ParseFile(token.NewFileSet(), filepath.Join(dir, n), nil, 0)
+ f, err := parser.ParseFile(movesFset, filepath.Join(dir, n), nil, 0)
if err != nil {
t.Fatal(err)
}
@@ -748,12 +778,13 @@ func returnsOneString(d *ast.FuncDecl) bool {
}
type movesEmission struct {
- file string
- line int // where the call or literal starts
- endLine int
- fn string // the enclosing function
- kind string
- pattern string
+ file string
+ line int // where the call or literal starts
+ endLine int
+ fn string // the enclosing function
+ kind string
+ pattern string
+ fragment bool // a piece of a sentence, matched by containment
}
func (e movesEmission) String() string {
@@ -785,6 +816,26 @@ func scanMovesFile(t *testing.T, file string) []movesEmission {
// functions as wildcards, so a legacy function's sentence is only the text
// written in that function.
func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmission {
+ t.Helper()
+ ems, _ := scanMovesFileFull(t, root, file, noCalls)
+ return ems
+}
+
+// movesViolation is a sink that does not take a sentence the gate can read.
+type movesViolation struct {
+ file string
+ line int
+ fn string
+ kind string
+ why string
+}
+
+func (v movesViolation) String() string {
+ return fmt.Sprintf("%s:%d (%s in %s): %s", v.file, v.line, v.kind, v.fn, v.why)
+}
+
+// scanMovesFileFull returns a file's emissions and its sink violations.
+func scanMovesFileFull(t *testing.T, root, file string, noCalls bool) ([]movesEmission, []movesViolation) {
t.Helper()
at := func(p string) string {
if root == "." {
@@ -792,7 +843,7 @@ func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmi
}
return filepath.Join(root, p)
}
- fset := token.NewFileSet()
+ fset := movesFset
f, err := parser.ParseFile(fset, at(file), nil, 0)
if err != nil {
t.Fatal(err)
@@ -801,11 +852,24 @@ func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmi
idx.noCalls = noCalls
pkg := &idx
var out []movesEmission
+ var vios []movesViolation
+ seenVio := map[string]bool{}
var fn string
- emit := func(pos token.Pos, kind string, e ast.Expr) {
+ var ctx *sinkCtx
+ emit := func(pos token.Pos, kind string, e ast.Expr, fragment bool) {
for _, p := range patternsOf(e, pkg, 0) {
if informative(p) {
- out = append(out, movesEmission{file, fset.Position(pos).Line, fset.Position(e.End()).Line, fn, kind, p})
+ out = append(out, movesEmission{file, fset.Position(pos).Line, fset.Position(e.End()).Line, fn, kind, p, fragment})
+ }
+ }
+ if fragment {
+ return
+ }
+ if why := ctx.check(e, 0); why != "" {
+ v := movesViolation{file, fset.Position(pos).Line, fn, kind, why}
+ if !seenVio[v.String()] {
+ seenVio[v.String()] = true
+ vios = append(vios, v)
}
}
}
@@ -832,16 +896,30 @@ func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmi
continue
}
fn = fd.Name.Name
+ ctx = newSinkCtx(file, fd, pkg)
inTable := tables[fn]
strs := stringResults(fd)
- returnsSentence := movesReturnedSentences[file] && len(strs) > 0
+ returnsSentence := movesReturnedSentences[file][fn] && len(strs) > 0
+ var stack []ast.Node
+ litDepth := 0
ast.Inspect(fd.Body, func(n ast.Node) bool {
+ if n == nil {
+ if _, ok := stack[len(stack)-1].(*ast.FuncLit); ok {
+ litDepth--
+ }
+ stack = stack[:len(stack)-1]
+ return true
+ }
+ stack = append(stack, n)
switch n := n.(type) {
+ case *ast.FuncLit:
+ litDepth++
case *ast.BasicLit:
// A literal with an em dash is main's wording, which phase1Conflict
- // matches and rewrites; it is never spoken.
+ // matches and rewrites; it is never spoken. A literal here is a
+ // piece of a sentence unless it is compared whole or returned.
if literals[fn] && n.Kind == token.STRING && !strings.Contains(n.Value, `\u2014`) && !strings.ContainsRune(n.Value, '\u2014') {
- emit(n.Pos(), fn, n)
+ emit(n.Pos(), fn, n, true)
}
case *ast.CallExpr:
name := ""
@@ -856,14 +934,23 @@ func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmi
i, sink = 2, true
}
if sink && i < len(n.Args) {
- emit(n.Pos(), name, n.Args[i])
+ emit(n.Pos(), name, n.Args[i], false)
+ }
+ if j, fsink := movesFuncSinks[file][name]; fsink && j < len(n.Args) {
+ if why := ctx.checkFuncArg(n.Args[j]); why != "" {
+ v := movesViolation{file, fset.Position(n.Pos()).Line, fn, name, why}
+ if !seenVio[v.String()] {
+ seenVio[v.String()] = true
+ vios = append(vios, v)
+ }
+ }
}
case *ast.CompositeLit:
if n.Type != nil && isCheck(n.Type) {
for _, el := range n.Elts {
if kv, ok := el.(*ast.KeyValueExpr); ok {
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "Reason" {
- emit(kv.Pos(), "Check.Reason", kv.Value)
+ emit(kv.Pos(), "Check.Reason", kv.Value, false)
}
}
}
@@ -871,25 +958,38 @@ func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmi
if inTable {
for _, el := range n.Elts {
if kv, ok := el.(*ast.KeyValueExpr); ok {
- emit(kv.Pos(), fd.Name.Name, kv.Value)
+ switch kv.Value.(type) {
+ case *ast.FuncLit, *ast.CompositeLit:
+ // closures are read through their returns; nested tables by descent
+ default:
+ emit(kv.Pos(), fn, kv.Value, false)
+ }
}
}
}
case *ast.AssignStmt:
if len(n.Lhs) == 1 && len(n.Rhs) == 1 {
if s, ok := n.Lhs[0].(*ast.SelectorExpr); ok && s.Sel.Name == "Reason" && isCheck(s.X) {
- emit(n.Pos(), "Check.Reason", n.Rhs[0])
+ emit(n.Pos(), "Check.Reason", n.Rhs[0], false)
}
}
case *ast.ReturnStmt:
- if inTable {
+ switch {
+ case inTable:
for _, r := range n.Results {
- emit(r.Pos(), fd.Name.Name, r)
+ if _, nested := r.(*ast.CompositeLit); !nested {
+ emit(r.Pos(), fn, r, false)
+ }
}
- } else if returnsSentence {
+ case literals[fn]:
+ // phase1Conflict's returns are sentences too.
+ for _, r := range n.Results {
+ emit(r.Pos(), fn, r, false)
+ }
+ case returnsSentence && litDepth == 0:
for i, r := range n.Results {
- if strs[i] && !isIdentOrCall(r) {
- emit(r.Pos(), "returned sentence", r)
+ if strs[i] {
+ emit(r.Pos(), "returned sentence", r, false)
}
}
}
@@ -897,68 +997,397 @@ func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmi
return true
})
}
- return out
+ return out, vios
}
-// stringResults is the set of a function's result positions that are strings.
-func stringResults(d *ast.FuncDecl) map[int]bool {
- out := map[int]bool{}
- if d.Type.Results == nil {
+// ---- sinks must take sentences the gate can read ----
+
+// movesFuncSinks are calls that take a sentence-producing function as an
+// argument (the index is where): the argument must be a recognised sentence
+// helper, and the parameter that receives it may be called inside the callee.
+var movesFuncSinks = map[string]map[string]int{
+ "internal/console/apiinstancemove.go": {"moveTransitionSaying": 7},
+}
+
+// movesSinkAllow exempts a function's sinks from the readable-sentence rule.
+// An entry is only for a value that never reaches an operator, and says why.
+// A value that does reach an operator is mapped to a recorded sentence in the
+// code instead. TestMovesSinkAllowListIsHonest holds the list to this.
+var movesSinkAllow = map[string]map[string]string{}
+
+// sinkCtx classifies what a sink argument is, inside one function.
+type sinkCtx struct {
+ file string
+ fd *ast.FuncDecl
+ pkg *movesPkgIndex
+ fwd map[string]bool // string parameters whose values are sinks at the call sites
+ fwdFn map[string]bool // function parameters that are sentence helpers at the call sites
+}
+
+func paramNames(ft *ast.FuncType) []string {
+ var out []string
+ if ft.Params == nil {
return out
}
- i := 0
- for _, f := range d.Type.Results.List {
- n := len(f.Names)
- if n == 0 {
- n = 1
+ for _, f := range ft.Params.List {
+ if len(f.Names) == 0 {
+ out = append(out, "_")
}
- if id, ok := f.Type.(*ast.Ident); ok && id.Name == "string" {
- for k := 0; k < n; k++ {
- out[i+k] = true
- }
+ for _, n := range f.Names {
+ out = append(out, n.Name)
}
- i += n
}
return out
}
-// isIdentOrCall is true for a returned value that is a variable or a call to
-// something other than fmt.Sprintf: those carry no sentence of their own.
-func isIdentOrCall(e ast.Expr) bool {
- switch e := e.(type) {
- case *ast.Ident, *ast.SelectorExpr:
+func newSinkCtx(file string, fd *ast.FuncDecl, pkg *movesPkgIndex) *sinkCtx {
+ c := &sinkCtx{file: file, fd: fd, pkg: pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}}
+ if fd == nil {
+ return c
+ }
+ if i, ok := movesLocalSinks[file][fd.Name.Name]; ok {
+ if ps := paramNames(fd.Type); i < len(ps) {
+ c.fwd[ps[i]] = true
+ }
+ }
+ if i, ok := movesFuncSinks[file][fd.Name.Name]; ok {
+ if ps := paramNames(fd.Type); i < len(ps) {
+ c.fwdFn[ps[i]] = true
+ }
+ }
+ // A closure that is a local sink (fail := func(why string)) forwards its parameter.
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ as, ok := n.(*ast.AssignStmt)
+ if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
+ return true
+ }
+ id, ok := as.Lhs[0].(*ast.Ident)
+ lit, isLit := as.Rhs[0].(*ast.FuncLit)
+ if !ok || !isLit {
+ return true
+ }
+ if i, ok := movesLocalSinks[file][id.Name]; ok {
+ if ps := paramNames(lit.Type); i < len(ps) {
+ c.fwd[ps[i]] = true
+ }
+ }
return true
+ })
+ return c
+}
+
+// isConstLike reports whether an identifier names a package-level string.
+func (c *sinkCtx) pkgString(name string) (ast.Expr, bool) {
+ x, ok := c.pkg.vars[name]
+ return x, ok
+}
+
+// literalish reports whether e is, on its face, a piece of fixed text: a
+// string literal, a package const, a Sprintf or errors.New with a literal
+// format, or a call to a recognised sentence helper.
+func (c *sinkCtx) literalish(e ast.Expr, depth int) bool {
+ switch e := e.(type) {
+ case *ast.ParenExpr:
+ return c.literalish(e.X, depth)
+ case *ast.BasicLit:
+ return e.Kind == token.STRING
+ case *ast.Ident:
+ _, ok := c.pkgString(e.Name)
+ return ok
case *ast.CallExpr:
- if s, ok := e.Fun.(*ast.SelectorExpr); ok {
- if id, ok := s.X.(*ast.Ident); ok && id.Name == "fmt" {
- return false
+ return isFormatCall(e) && c.formatIsLiteral(e)
+ case *ast.BinaryExpr:
+ if e.Op == token.ADD {
+ return c.literalish(e.X, depth) || c.literalish(e.Y, depth)
+ }
+ }
+ return false
+}
+
+func isFormatCall(e *ast.CallExpr) bool {
+ sel, ok := e.Fun.(*ast.SelectorExpr)
+ if !ok || len(e.Args) == 0 {
+ return false
+ }
+ id, ok := sel.X.(*ast.Ident)
+ return ok && ((id.Name == "fmt" && (sel.Sel.Name == "Sprintf" || sel.Sel.Name == "Errorf")) ||
+ (id.Name == "errors" && sel.Sel.Name == "New"))
+}
+
+func (c *sinkCtx) formatIsLiteral(e *ast.CallExpr) bool {
+ switch a := e.Args[0].(type) {
+ case *ast.BasicLit:
+ return a.Kind == token.STRING
+ case *ast.Ident:
+ _, ok := c.pkgString(a.Name)
+ return ok
+ }
+ return false
+}
+
+// localSentence reports whether a function assigns a string literal (or a
+// literal concatenation or Sprintf) to the local variable name.
+func (c *sinkCtx) localSentence(name string) bool {
+ if c.fd == nil {
+ return false
+ }
+ found := false
+ ast.Inspect(c.fd.Body, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.AssignStmt:
+ for i, l := range n.Lhs {
+ if id, ok := l.(*ast.Ident); ok && id.Name == name && i < len(n.Rhs) && isSentenceLiteral(n.Rhs[i]) {
+ found = true
+ }
+ }
+ case *ast.ValueSpec:
+ for i, id := range n.Names {
+ if id.Name == name && i < len(n.Values) && isSentenceLiteral(n.Values[i]) {
+ found = true
+ }
}
}
- return true
+ return !found
+ })
+ return found
+}
+
+// isSentenceLiteral is true for text written in place: a string literal, a
+// Sprintf or errors.New, or a concatenation with a string literal in it. A
+// reference to a const is not text written in place.
+func isSentenceLiteral(e ast.Expr) bool {
+ switch e := e.(type) {
+ case *ast.ParenExpr:
+ return isSentenceLiteral(e.X)
+ case *ast.BasicLit:
+ return e.Kind == token.STRING
+ case *ast.CallExpr:
+ return isFormatCall(e)
+ case *ast.BinaryExpr:
+ return e.Op == token.ADD && (isSentenceLiteral(e.X) || isSentenceLiteral(e.Y))
}
return false
}
-// movesRowMatches reports whether a row carries an emitted sentence: the
-// sentence's fixed text appears in the row (the parts the code fills in match
-// anything), or the row's own <placeholders> are filled by the sentence (the
-// row says "a <role> does not move" and the code says "a replica does not
-// move"). A prefix a caller adds around a reason ("S2: ") is outside the
-// sentence, so containment is enough.
+// operandProblem is the one thing a placeholder operand may not be: a local
+// variable that was given a sentence.
+func (c *sinkCtx) operandProblem(e ast.Expr) string {
+ if id, ok := e.(*ast.Ident); ok {
+ if _, isPkg := c.pkgString(id.Name); !isPkg && c.localSentence(id.Name) {
+ return fmt.Sprintf("%s holds a sentence assigned before the sink, so the sentence is never read where it is spoken", id.Name)
+ }
+ }
+ return ""
+}
+
+func addOperands(e ast.Expr, out *[]ast.Expr) {
+ if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.ADD {
+ addOperands(b.X, out)
+ addOperands(b.Y, out)
+ return
+ }
+ if p, ok := e.(*ast.ParenExpr); ok {
+ addOperands(p.X, out)
+ return
+ }
+ *out = append(*out, e)
+}
+
+func render(e ast.Expr) string {
+ var b strings.Builder
+ ast.Inspect(e, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.BinaryExpr:
+ b.WriteString(render(n.X) + " " + n.Op.String() + " " + render(n.Y))
+ return false
+ case *ast.Ident:
+ b.WriteString(n.Name)
+ case *ast.SelectorExpr:
+ b.WriteString(render(n.X) + "." + n.Sel.Name)
+ return false
+ case *ast.CallExpr:
+ b.WriteString(render(n.Fun) + "(...)")
+ return false
+ }
+ return true
+ })
+ return b.String()
+}
+
+// check returns why e is not a sentence the gate can read, or "".
+//
+// Taken: a string literal; a package const; a concatenation with a literal
+// part or a Sprintf with a literal format (the other operands are the
+// placeholders the record names); a call to a package function that returns
+// a string and whose own returns are taken; a parameter that is itself a
+// sink at its call sites; a Check's own Reason (every Check is made at a
+// sink). Everything else is refused: a variable, err.Error(), a method's
+// result, a field, an expression the gate cannot read.
+func (c *sinkCtx) check(e ast.Expr, depth int) string {
+ switch e := e.(type) {
+ case *ast.ParenExpr:
+ return c.check(e.X, depth)
+ case *ast.BasicLit:
+ if e.Kind == token.STRING {
+ return ""
+ }
+ return "not a string literal"
+ case *ast.Ident:
+ if c.fwd[e.Name] {
+ return ""
+ }
+ if x, ok := c.pkgString(e.Name); ok {
+ if depth >= 4 {
+ return ""
+ }
+ return (&sinkCtx{pkg: c.pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}}).check(x, depth+1)
+ }
+ return fmt.Sprintf("%s is a variable, not a literal, a const or a sentence helper", e.Name)
+ case *ast.SelectorExpr:
+ if e.Sel.Name == "Reason" {
+ return ""
+ }
+ return fmt.Sprintf("%s is a field the gate cannot read", render(e))
+ case *ast.BinaryExpr:
+ if e.Op != token.ADD {
+ return "an expression that is not a sentence"
+ }
+ var ops []ast.Expr
+ addOperands(e, &ops)
+ lit := false
+ for _, o := range ops {
+ if c.literalish(o, depth) {
+ lit = true
+ if why := c.check(o, depth); why != "" {
+ return why
+ }
+ } else if why := c.operandProblem(o); why != "" {
+ return why
+ }
+ }
+ if !lit {
+ return fmt.Sprintf("%s is a concatenation with no literal part", render(e))
+ }
+ return ""
+ case *ast.CallExpr:
+ if isFormatCall(e) {
+ if !c.formatIsLiteral(e) {
+ return fmt.Sprintf("%s has a format that is not a literal", render(e))
+ }
+ for _, a := range e.Args[1:] {
+ if why := c.operandProblem(a); why != "" {
+ return why
+ }
+ }
+ return ""
+ }
+ if id, ok := e.Fun.(*ast.Ident); ok {
+ if c.fwdFn[id.Name] {
+ return ""
+ }
+ return c.helperProblem(id.Name, depth)
+ }
+ return fmt.Sprintf("%s forwards a call's result", render(e))
+ }
+ return fmt.Sprintf("%s is not a sentence the gate can read", render(e))
+}
+
+// helperProblem checks a package function used as a sentence helper: it must
+// return one string, and every return must itself be taken.
+func (c *sinkCtx) helperProblem(name string, depth int) string {
+ d, ok := c.pkg.funcs[name]
+ if !ok || d.Body == nil {
+ return fmt.Sprintf("%s(...) is not a sentence helper of this package", name)
+ }
+ if !returnsOneString(d) {
+ return fmt.Sprintf("%s(...) does not return a sentence", name)
+ }
+ if depth >= 4 {
+ return ""
+ }
+ hc := &sinkCtx{fd: d, pkg: c.pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}}
+ var why string
+ ast.Inspect(d.Body, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.FuncLit:
+ return false
+ case *ast.ReturnStmt:
+ if why == "" && len(n.Results) == 1 {
+ if w := hc.check(n.Results[0], depth+1); w != "" {
+ why = fmt.Sprintf("helper %s, at %s: %s", name, movesFset.Position(n.Pos()), w)
+ }
+ }
+ }
+ return why == ""
+ })
+ return why
+}
+
+// checkFuncArg checks the function a call hands to a sentence-function
+// parameter: a package function that is a sentence helper.
+func (c *sinkCtx) checkFuncArg(e ast.Expr) string {
+ if id, ok := e.(*ast.Ident); ok {
+ if _, isFn := c.pkg.funcs[id.Name]; isFn {
+ return c.helperProblem(id.Name, 0)
+ }
+ }
+ return fmt.Sprintf("%s is passed where a sentence helper is expected", render(e))
+}
+
+// diagnosticPrefixes are the ids the readiness code puts before a reason
+// ("T11: ", "S2: ") and the record leaves on its row or off it.
+var diagnosticPrefixes = []string{"T11: ", "S1: ", "S2: "}
+
+// movesRowMatches reports whether a row carries an emitted sentence, whole:
+// the sentence equals the row's text, where the parts the code fills in
+// match anything, or the row's own <placeholders> are filled by the sentence
+// (the row says "a <role> does not move" and the code says "a replica does
+// not move"). A diagnostic prefix ("S2: ") may sit on either side. A
+// sentence that only appears inside a row does not match: "valid region" is
+// not "invalid region".
func movesRowMatches(text, pattern string) bool {
+ return rowMatches(text, pattern, false)
+}
+
+// movesRowSpoken is the stricter match for an approved row: the code must say
+// all of the row's fixed text. The code's own fill-ins may only stand where
+// the row has a <placeholder>, never where the row has words.
+func movesRowSpoken(text, pattern string) bool {
+ return rowMatches(text, pattern, true)
+}
+
+func rowMatches(text, pattern string, strict bool) bool {
+ forms := []string{text}
+ for _, p := range diagnosticPrefixes {
+ if strings.HasPrefix(text, p) {
+ forms = append(forms, strings.TrimPrefix(text, p))
+ }
+ }
parts := strings.Split(pattern, wild)
for i := range parts {
parts[i] = regexp.QuoteMeta(parts[i])
}
- if re, err := regexp.Compile(`(?s)` + strings.Join(parts, `.*`)); err == nil && re.MatchString(text) {
- return true
- }
- rowParts := copyPlaceholder.Split(text, -1)
- for i := range rowParts {
- rowParts[i] = regexp.QuoteMeta(rowParts[i])
+ code := regexp.MustCompile(`(?s)^` + strings.Join(parts, `.*`) + `$`)
+ rendered := strings.ReplaceAll(pattern, wild, "%")
+ for _, f := range forms {
+ if !strict && code.MatchString(f) {
+ return true
+ }
+ rowParts := copyPlaceholder.Split(f, -1)
+ for i := range rowParts {
+ rowParts[i] = regexp.QuoteMeta(rowParts[i])
+ }
+ if regexp.MustCompile(`(?s)^` + strings.Join(rowParts, `.*`) + `$`).MatchString(rendered) {
+ return true
+ }
}
- re, err := regexp.Compile(`(?s)^` + strings.Join(rowParts, `.*`) + `$`)
- return err == nil && re.MatchString(strings.ReplaceAll(pattern, wild, "%"))
+ return false
+}
+
+// movesFragmentMatches is the match for a piece of a sentence: a literal
+// phase1Conflict tests with HasPrefix or Contains. Some row must contain it.
+func movesFragmentMatches(text, pattern string) bool {
+ return strings.Contains(text, strings.ReplaceAll(pattern, wild, ""))
}
// movesLegacy reports whether an emission is main's own.
@@ -979,7 +1408,11 @@ func findUnrecorded(ems []movesEmission, rows []copyRow) []movesEmission {
}
found := false
for _, r := range rows {
- if movesRowMatches(r.Text, e.pattern) {
+ match := movesRowMatches
+ if e.fragment {
+ match = movesFragmentMatches
+ }
+ if match(r.Text, e.pattern) {
found = true
break
}
@@ -1059,28 +1492,66 @@ func f(w int, c Check) {
}
}
+// movesProductionGoFile reports whether a path from the diff is a production
+// Go file: not a test, and not under a testdata directory (the go tool
+// ignores those, and the negative fixtures live there).
+func movesProductionGoFile(path string) bool {
+ if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
+ return false
+ }
+ for _, el := range strings.Split(path, "/") {
+ if el == "testdata" {
+ return false
+ }
+ }
+ return true
+}
+
+// movesUncovered returns the production files in changed that listed lacks.
+// exists says whether a path is still in the tree (a deleted file needs no
+// entry).
+func movesUncovered(changed, listed []string, exists func(string) bool) []string {
+ have := map[string]bool{}
+ for _, f := range listed {
+ have[f] = true
+ }
+ var out []string
+ for _, f := range changed {
+ if movesProductionGoFile(f) && exists(f) && !have[f] {
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
+func TestMovesScanFilesInventoryRules(t *testing.T) {
+ exists := func(p string) bool { return p != "internal/gone.go" }
+ changed := []string{
+ "internal/moves/new.go", // production: needs an entry
+ "internal/moves/new_test.go", // a test
+ "testdata/moves-copy/changed-text/applied.go", // a fixture
+ "internal/host/testdata/fixture/emit.go", // a fixture below a package
+ "internal/gone.go", // deleted
+ "docs/superpowers/plans/2026-10-06-copy.md", // not Go
+ "internal/moves/listed.go", // listed
+ }
+ got := movesUncovered(changed, []string{"internal/moves/listed.go"}, exists)
+ if len(got) != 1 || got[0] != "internal/moves/new.go" {
+ t.Errorf("uncovered = %q, want only internal/moves/new.go", got)
+ }
+}
+
// The scan reads the files listed in movesScanFiles. When moves-phase1's diff
-// names a Go file the list does not, the scan has a blind spot: add it. The
-// check is skipped where git or the base commit is not available.
+// names a production Go file the list does not, the scan has a blind spot:
+// add it. The check is skipped where git or the base commit is not available.
func TestMovesScanFilesCoverTheDiff(t *testing.T) {
out, err := exec.Command("git", "diff", "--name-only", movesScanBase+"..HEAD", "--", "*.go").Output()
if err != nil {
t.Skipf("git diff %s..HEAD: %v", movesScanBase, err)
}
- listed := map[string]bool{}
- for _, f := range movesScanFiles {
- listed[f] = true
- }
- for _, f := range strings.Fields(string(out)) {
- if strings.HasSuffix(f, "_test.go") {
- continue
- }
- if _, err := os.Stat(f); err != nil {
- continue // deleted
- }
- if !listed[f] {
- t.Errorf("%s changed since %s and is not in movesScanFiles", f, movesScanBase)
- }
+ exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
+ for _, f := range movesUncovered(strings.Fields(string(out)), movesScanFiles, exists) {
+ t.Errorf("%s changed since %s and is not in movesScanFiles", f, movesScanBase)
}
for _, f := range movesScanFiles {
if _, err := os.Stat(f); err != nil {
@@ -1202,3 +1673,190 @@ func TestMovesGateBitesOnTheRealRecord(t *testing.T) {
t.Error("a default that is on beside pending rows must fail")
}
}
+
+// stringResults is the set of a function's result positions that are strings.
+func stringResults(d *ast.FuncDecl) map[int]bool {
+ out := map[int]bool{}
+ if d.Type.Results == nil {
+ return out
+ }
+ i := 0
+ for _, f := range d.Type.Results.List {
+ n := len(f.Names)
+ if n == 0 {
+ n = 1
+ }
+ if id, ok := f.Type.(*ast.Ident); ok && id.Name == "string" {
+ for k := 0; k < n; k++ {
+ out[i+k] = true
+ }
+ }
+ i += n
+ }
+ return out
+}
+
+// movesViolationExempt reports whether a violation is main's own (legacy) or
+// is allow-listed with a reason.
+func movesViolationExempt(v movesViolation) bool {
+ if _, ok := movesLegacyFiles[v.file]; ok {
+ return true
+ }
+ if _, ok := movesLegacyFuncs[v.file][v.fn]; ok {
+ return true
+ }
+ _, ok := movesSinkAllow[v.file][v.fn]
+ return ok
+}
+
+// Every value that reaches an operator sink in the scanned files is a
+// sentence the gate can read: a literal, a const, a concatenation or Sprintf
+// with a literal format, or a call to a sentence helper. A forwarded
+// err.Error(), a variable holding text, or an expression the gate cannot read
+// fails here, naming file:line, because the record cannot vouch for text the
+// gate never sees.
+func TestMovesSinksAreSentences(t *testing.T) {
+ for _, f := range movesScanFiles {
+ _, vios := scanMovesFileFull(t, ".", f, false)
+ for _, v := range vios {
+ if !movesViolationExempt(v) {
+ t.Errorf("%s", v)
+ }
+ }
+ }
+}
+
+// ---- negative fixtures for the sink rule, the matcher and the placeholder check ----
+
+func sinkFixture(t *testing.T, dir string) []movesViolation {
+ t.Helper()
+ _, v := scanMovesFileFull(t, ".", filepath.Join("testdata", "moves-copy", "sinks", dir, "emit.go"), false)
+ return v
+}
+
+func wantViolations(t *testing.T, name string, got []movesViolation, wants ...string) {
+ t.Helper()
+ if len(got) != len(wants) {
+ t.Errorf("%s: %d violations %v, want %d", name, len(got), got, len(wants))
+ return
+ }
+ for i, w := range wants {
+ if !strings.Contains(got[i].String(), w) {
+ t.Errorf("%s: violation %d = %q, want one containing %q", name, i, got[i], w)
+ }
+ }
+}
+
+func TestMovesSinkFixtures(t *testing.T) {
+ wantViolations(t, "a forwarded error", sinkFixture(t, "forwarded-error"),
+ "emit.go:7 (jsonError in refuse): err.Error(...) forwards a call's result",
+ "emit.go:18 (Check.Reason in report): helper why, at ",
+ )
+ wantViolations(t, "a message assigned to a local before the sink", sinkFixture(t, "local-variable"),
+ "emit.go:8 (jsonError in refuse): msg is a variable",
+ "emit.go:16 (jsonError in refuseLate): text is a variable",
+ "emit.go:21 (jsonError in inside): gone holds a sentence assigned before the sink",
+ )
+ wantViolations(t, "values the gate cannot read", sinkFixture(t, "unresolved"),
+ "emit.go:6 (jsonError in methodResult): s.render(...) forwards a call's result",
+ "emit.go:10 (jsonError in indexed): names is not a sentence the gate can read",
+ "emit.go:14 (Check.Reason in bare): a + b is a concatenation with no literal part",
+ "emit.go:18 (jsonError in dynamicFormat): fmt.Sprintf(...) has a format that is not a literal",
+ "emit.go:22 (jsonError in field): r.Message is a field the gate cannot read",
+ )
+ wantViolations(t, "sentences the gate can read", sinkFixture(t, "ok"))
+}
+
+// An allow-list entry exempts a sink, but it needs a reason and must still
+// exempt something.
+func checkSinkAllow(allow map[string]map[string]string, vios []movesViolation) []string {
+ used := map[string]bool{}
+ for _, v := range vios {
+ used[v.file+" "+v.fn] = true
+ }
+ var out []string
+ for file, fns := range allow {
+ for fn, why := range fns {
+ if len(strings.Fields(why)) < 5 {
+ out = append(out, fmt.Sprintf("%s %s: the allow-list entry needs a comment saying why the value never reaches an operator", file, fn))
+ }
+ if !used[file+" "+fn] {
+ out = append(out, fmt.Sprintf("%s %s: the allow-list entry exempts nothing; drop it", file, fn))
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func TestMovesSinkAllowListIsHonest(t *testing.T) {
+ var all []movesViolation
+ for _, f := range movesScanFiles {
+ _, v := scanMovesFileFull(t, ".", f, false)
+ all = append(all, v...)
+ }
+ reportAll(t, checkSinkAllow(movesSinkAllow, all))
+
+ vios := []movesViolation{{file: "a.go", fn: "f"}}
+ if got := checkSinkAllow(map[string]map[string]string{"a.go": {"f": "because"}}, vios); len(got) != 1 || !strings.Contains(got[0], "needs a comment") {
+ t.Errorf("an entry with no reason: %q", got)
+ }
+ if got := checkSinkAllow(map[string]map[string]string{"a.go": {"g": "this value is only ever logged"}}, vios); len(got) != 1 || !strings.Contains(got[0], "exempts nothing") {
+ t.Errorf("an entry that exempts nothing: %q", got)
+ }
+ if got := checkSinkAllow(map[string]map[string]string{"a.go": {"f": "this value is only ever logged"}}, vios); len(got) != 0 {
+ t.Errorf("a good entry: %q", got)
+ }
+}
+
+// "valid region" is not "invalid region", though the record's text contains it.
+func TestMovesWholeSentencesOnly(t *testing.T) {
+ dir := filepath.Join("testdata", "moves-copy", "substring")
+ rows, err := parseMovesCopyRecord(filepath.Join(dir, "record.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ ems := scanMovesFile(t, filepath.Join(dir, "emit.go"))
+ un := findUnrecorded(ems, rows)
+ if len(un) != 1 || un[0].pattern != "valid region" {
+ t.Errorf("unrecorded = %v, want only \"valid region\"", un)
+ }
+
+ cases := []struct {
+ row, pattern string
+ want bool
+ }{
+ {"invalid region", "invalid region", true},
+ {"invalid region", "valid region", false},
+ {"invalid region", "invalid", false},
+ {"region <to> is gone", "region " + wild + " is gone", true},
+ {"a <role> does not move", "a replica does not move", true},
+ {"S2: the next spawn of this host is not strict", "the next spawn of this host is not strict", true},
+ {"T11: the cut budget admits no probe attempt", "T11: " + wild, true},
+ {"S2: the next spawn is not strict", "the next spawn is not strict now", false},
+ {"the source did not answer", "S1: the source did not answer", false},
+ }
+ for _, c := range cases {
+ if got := movesRowMatches(c.row, c.pattern); got != c.want {
+ t.Errorf("movesRowMatches(%q, %q) = %v, want %v", c.row, c.pattern, got, c.want)
+ }
+ }
+}
+
+// Approved placeholder text is matched inside one expression. Fragments in
+// unrelated literals are never joined.
+func TestMovesApprovedPlaceholderTextIsOneExpression(t *testing.T) {
+ root := filepath.Join("testdata", "moves-copy", "assembled")
+ rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesApplied(rows, root)
+ if len(got) != 2 {
+ t.Fatalf("problems = %q, want exactly MV-N1 (unrelated fragments) and MV-N4 (only the start of the sentence)", got)
+ }
+ sort.Strings(got)
+ if !strings.HasPrefix(got[0], "MV-N1:") || !strings.HasPrefix(got[1], "MV-N4:") {
+ t.Errorf("problems = %q", got)
+ }
+}
diff --git a/testdata/moves-copy/assembled/formatted.go b/testdata/moves-copy/assembled/formatted.go
new file mode 100644
index 000000000..cae92dc8d
--- /dev/null
+++ b/testdata/moves-copy/assembled/formatted.go
@@ -0,0 +1,7 @@
+package fixture
+
+import "fmt"
+
+func gone(to string) string {
+ return fmt.Sprintf("region %s is gone", to)
+}
diff --git a/testdata/moves-copy/assembled/fragments.go b/testdata/moves-copy/assembled/fragments.go
new file mode 100644
index 000000000..2f9ecd23d
--- /dev/null
+++ b/testdata/moves-copy/assembled/fragments.go
@@ -0,0 +1,7 @@
+package fixture
+
+// Neither variable is the sentence. The fragments are in unrelated literals
+// and must not be joined.
+
+var head = "region "
+var tail = " is gone"
diff --git a/testdata/moves-copy/assembled/joined.go b/testdata/moves-copy/assembled/joined.go
new file mode 100644
index 000000000..99bea9ac1
--- /dev/null
+++ b/testdata/moves-copy/assembled/joined.go
@@ -0,0 +1,5 @@
+package fixture
+
+func gone(to string) string {
+ return "region " + to + " is gone"
+}
diff --git a/testdata/moves-copy/assembled/prefix.go b/testdata/moves-copy/assembled/prefix.go
new file mode 100644
index 000000000..0297c27ea
--- /dev/null
+++ b/testdata/moves-copy/assembled/prefix.go
@@ -0,0 +1,7 @@
+package fixture
+
+// Only the start of the sentence is spoken here; the rest is not.
+
+func gone(to string) string {
+ return "region " + to
+}
diff --git a/testdata/moves-copy/assembled/record.md b/testdata/moves-copy/assembled/record.md
new file mode 100644
index 000000000..5db5182ac
--- /dev/null
+++ b/testdata/moves-copy/assembled/record.md
@@ -0,0 +1,8 @@
+# Negative fixture: approved placeholder text from unrelated literals
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | fragments.go | region <to> is gone | approved |
+| MV-N2 | joined.go | region <to> is gone | approved |
+| MV-N3 | formatted.go | region <to> is gone | approved |
+| MV-N4 | prefix.go | region <to> is gone | approved |
diff --git a/testdata/moves-copy/sinks/forwarded-error/emit.go b/testdata/moves-copy/sinks/forwarded-error/emit.go
new file mode 100644
index 000000000..435c6c139
--- /dev/null
+++ b/testdata/moves-copy/sinks/forwarded-error/emit.go
@@ -0,0 +1,19 @@
+package fixture
+
+// Not compiled: testdata is ignored by the go tool. Each sink below forwards
+// text the gate cannot read.
+
+func refuse(w int, err error) {
+ jsonError(w, 502, err.Error())
+}
+
+// A helper that hands an error's text on is not a sentence helper, so a
+// sentence altered in the package that raised the error never reaches the
+// record.
+func why(err error) string {
+ return err.Error()
+}
+
+func report(err error) Check {
+ return Check{Reason: why(err)}
+}
diff --git a/testdata/moves-copy/sinks/local-variable/emit.go b/testdata/moves-copy/sinks/local-variable/emit.go
new file mode 100644
index 000000000..209ab06d0
--- /dev/null
+++ b/testdata/moves-copy/sinks/local-variable/emit.go
@@ -0,0 +1,22 @@
+package fixture
+
+// Not compiled. A sentence assigned to a local before the sink is never read
+// where it is spoken.
+
+func refuse(w int) {
+ msg := "that region is not armed"
+ jsonError(w, 400, msg)
+}
+
+func refuseLate(w int, bad bool) {
+ var text string
+ if bad {
+ text = "the host is already held"
+ }
+ jsonError(w, 409, text)
+}
+
+func inside(w int) {
+ gone := "the host was removed"
+ jsonError(w, 404, "the instance says: "+gone)
+}
diff --git a/testdata/moves-copy/sinks/ok/emit.go b/testdata/moves-copy/sinks/ok/emit.go
new file mode 100644
index 000000000..fe0e5b387
--- /dev/null
+++ b/testdata/moves-copy/sinks/ok/emit.go
@@ -0,0 +1,36 @@
+package fixture
+
+// Not compiled. Every sink here takes a sentence the gate can read.
+
+const noKey = "this box has no secrets key"
+
+func literal(w int) {
+ jsonError(w, 400, "invalid region")
+}
+
+func constant(w int) {
+ jsonError(w, 400, noKey)
+}
+
+func concat(w int, to string) {
+ jsonError(w, 400, "region "+to+" is not armed")
+}
+
+func sprintf(w int, n int, region string) {
+ jsonError(w, 400, fmt.Sprintf("%d boxes publish %s", n, region))
+}
+
+func viaHelper(w int, n int) {
+ jsonError(w, 400, tooMany(n))
+}
+
+func tooMany(n int) string {
+ if n > 1 {
+ return fmt.Sprintf("%d boxes is too many", n)
+ }
+ return "one box is too few"
+}
+
+func checks(c Check) Check {
+ return Check{Reason: c.Reason}
+}
diff --git a/testdata/moves-copy/sinks/unresolved/emit.go b/testdata/moves-copy/sinks/unresolved/emit.go
new file mode 100644
index 000000000..fe3279801
--- /dev/null
+++ b/testdata/moves-copy/sinks/unresolved/emit.go
@@ -0,0 +1,23 @@
+package fixture
+
+// Not compiled. Values the gate cannot read.
+
+func methodResult(w int, s server) {
+ jsonError(w, 400, s.render(1))
+}
+
+func indexed(w int, names []string) {
+ jsonError(w, 400, names[0])
+}
+
+func bare(a, b string) Check {
+ return Check{Reason: a + b}
+}
+
+func dynamicFormat(w int, format string) {
+ jsonError(w, 400, fmt.Sprintf(format, 1))
+}
+
+func field(w int, r record) {
+ jsonError(w, 400, r.Message)
+}
diff --git a/testdata/moves-copy/substring/emit.go b/testdata/moves-copy/substring/emit.go
new file mode 100644
index 000000000..ca884d868
--- /dev/null
+++ b/testdata/moves-copy/substring/emit.go
@@ -0,0 +1,9 @@
+package fixture
+
+// Not compiled. "valid region" is a different sentence from the recorded
+// "invalid region", though the record's text contains it.
+
+func refuse(w int) {
+ jsonError(w, 400, "valid region")
+ jsonError(w, 400, "invalid region")
+}
diff --git a/testdata/moves-copy/substring/record.md b/testdata/moves-copy/substring/record.md
new file mode 100644
index 000000000..7ab705453
--- /dev/null
+++ b/testdata/moves-copy/substring/record.md
@@ -0,0 +1,5 @@
+# Negative fixture: a row that contains the sentence is not the sentence
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | emit.go | invalid region | pending |