carlos / platform Public

copy gate fix round 5: error text through helpers and string fields, the scan base from git merge-base

- A placeholder that is a call to a package helper with a string result is
  read through the helper's body: a return carrying an error's text, or one
  the gate cannot read (an unknown shape, an unresolvable receiver, a call
  to itself), is a violation. MV-N2's format with detail(err) now fails.
- A field used as a placeholder is read through its writes, as Reason is:
  a write carrying an error's text, or a multi-value write, is a violation.
- Data the gate cannot derive (region and host names, strings.Join) still
  stands for a placeholder; the strict reading refused 13 real data sinks.
  The documented claim and limits say so.
- Ruling merge-scanbase: the coverage and legacy checks diff from
  git merge-base HEAD origin/main, falling back to the pinned constant
  (now 3407d114). A merge of main needs no edit here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5d75952267ffd3e5645d7178610d261375ac8c19 parent 804623c
6 files changed, +453 −39
  • approved_moves_copy_scan_test.go +265 −21
  • approved_moves_copy_test.go +89 −18
  • testdata/moves-copy/sinks/error-helper/emit.go +60 −0
  • testdata/moves-copy/sinks/error-helper/record.md +6 −0
  • testdata/moves-copy/sinks/ok/emit.go +30 −0
  • testdata/moves-copy/sinks/ok/record.md +3 −0
diff --git a/approved_moves_copy_scan_test.go b/approved_moves_copy_scan_test.go
index fde592418..ee2b561f9 100644
--- a/approved_moves_copy_scan_test.go
+++ b/approved_moves_copy_scan_test.go
@@ -31,11 +31,30 @@ import (
// A placeholder (an operand of a concatenation, an argument of a Sprintf) may
// be data, such as a host name: the row it is matched against must carry a
// <placeholder> at that spot, and the record is where a human vouches for it.
-// But a placeholder never carries an error's text. A call to Error(), a value
-// whose declared type is error or implements it, a value the gate cannot type
-// that is named err or ends in Err or err, a local or helper parameter that
-// holds any of these, and the argument of a %w verb are all violations there,
-// even when the format matches a row.
+// A placeholder must not carry an error's text that the gate can see. These
+// are violations there, even when the format matches a row (errorText):
+//
+// - a call to Error();
+// - a value whose declared type is error or implements it;
+// - a value the gate cannot type that is named err or ends in Err or err;
+// - a local, or a helper parameter, that holds any of these;
+// - the argument of a %w verb;
+// - a call to a package helper with a string result, any of whose returns
+// carries an error's text, or that the gate cannot read (a return of a
+// shape it does not know, a receiver it cannot resolve, a call to
+// itself);
+// - a field, any of whose writes in the scanned files carries an error's
+// text or takes one result of a multi-value expression;
+// - any of these hitting the analysis depth limit.
+//
+// Data the gate cannot derive (a region read from a request, a list joined by
+// strings.Join, a host name in a field) may stand for a placeholder. That
+// includes a value made from a helper result the gate cannot read (a local
+// assigned it, a field written from it, an argument bound to it) when the
+// result carries no error's text the gate can see. The real tree's region
+// and host placeholders are read that way. The strict reading, where any
+// such value is a violation, refused 13 real Phase 1 sinks whose
+// placeholders are all data.
//
// What it is (ruling T15-scope): a lint against accidental drift into
// operator copy, not a boundary against code written to evade it. The
@@ -47,7 +66,14 @@ import (
// A value of another package's type is an error only by its type's name
// (one ending in Error) or, when its type is unknown, by its own name.
// An error held in a variable of an unrelated name and an unknown type
-// (x := pkg.Do(); Sprintf("%v", x)) is read as data.
+// (x := pkg.Do(); Sprintf("%v", x)) is read as data. So is a helper
+// result of any type other than string or error (a Stringer formatted by
+// %v).
+// - A helper reads its parameters by their call sites only where it is
+// called as a placeholder. A field written from the writer's own
+// parameter (target{Name: n}) is read by n's type, not by what the
+// writer's callers pass, so mk(err.Error()) through such a field is not
+// seen.
// - Receivers are not lexically scoped. A method call whose receiver
// identifier is declared more than once in the function is refused; a
// receiver declared once is resolved by its one declaration.
@@ -341,8 +367,9 @@ type movesUniverse struct {
structFields map[string][]string
fieldTypeOf map[string]map[string]ast.Expr
results map[string]*fieldResult
- used map[string]bool // package functions evaluated as sentence helpers
- paramSinks map[string]bool // functions whose parameter reaches a sink
+ fieldTexts map[string]string // fieldText's answer per field and struct
+ used map[string]bool // package functions evaluated as sentence helpers
+ paramSinks map[string]bool // functions whose parameter reaches a sink
files []string
steps int
}
@@ -357,7 +384,7 @@ func (u *movesUniverse) at(p string) string {
func newUniverse(t *testing.T, root string, files []string) *movesUniverse {
t.Helper()
u := &movesUniverse{t: t, root: root, fieldWrites: map[string][]*fieldWrite{}, fieldTypes: map[string]map[string]bool{},
- structFields: map[string][]string{}, fieldTypeOf: map[string]map[string]ast.Expr{}, results: map[string]*fieldResult{}, used: map[string]bool{},
+ structFields: map[string][]string{}, fieldTypeOf: map[string]map[string]ast.Expr{}, results: map[string]*fieldResult{}, fieldTexts: map[string]string{}, used: map[string]bool{},
paramSinks: map[string]bool{}, files: files}
parsed := map[string]*ast.File{}
for _, f := range files {
@@ -632,7 +659,12 @@ func isConcat(e ast.Expr) bool {
}
func errorTextProblem(e ast.Expr, why string) problem {
- return problem{fmt.Sprintf("%s puts an error's text in a sentence (%s)", render(e), why), true}
+ what := "an error's text"
+ m, rest := whyClass(why)
+ if m != "" {
+ what = "text the gate cannot derive"
+ }
+ return problem{fmt.Sprintf("%s puts %s in a sentence (%s)", render(e), what, rest), true}
}
// errName is the name an error usually goes by: err, or one ending in Err or
@@ -674,11 +706,49 @@ func (c *evalCtx) isErrorType(t ast.Expr) bool {
return false
}
-// errorText says why e is an error's text, or "" when the gate has no reason to
-// think so. It reads the expression as written and the locals it names.
+// The classes of a reason errorText gives, carried as a mark at its front.
+// An unmarked reason is an error's text, and it counts wherever the value
+// goes. An unprovable one (an analysis limit, a multi-value field write) counts
+// wherever the value goes too: the gate does not know what the text is. An
+// underivable one (a helper result the gate cannot read: a return of a shape
+// it does not know, a receiver it cannot resolve, a call to itself) counts only
+// for the value that is that result. A value merely made from it (a local
+// assigned it, an argument bound to it, a field written from it) is data, as
+// a placeholder may be: the real tree's region and host names are read
+// through helpers the gate cannot fully derive.
+const (
+ underivable = "\x03"
+ unprovable = "\x04"
+)
+
+func whyClass(why string) (mark, rest string) {
+ if strings.HasPrefix(why, underivable) || strings.HasPrefix(why, unprovable) {
+ return why[:1], why[1:]
+ }
+ return "", why
+}
+
+// rewrap wraps why in format (why is its last verb), keeping its class.
+func rewrap(why, format string, a ...any) string {
+ m, rest := whyClass(why)
+ return m + fmt.Sprintf(format, append(a, rest)...)
+}
+
+// dataWhy is why as it passes through data: an underivable result drops out.
+func dataWhy(why string) string {
+ if m, _ := whyClass(why); m == underivable {
+ return ""
+ }
+ return why
+}
+
+// errorText says why e is an error's text, or text the gate cannot derive
+// (see the classes above), or "" when the gate has no reason to think either.
+// It reads the expression as written, the locals it names, the bodies of the
+// package helpers it calls, and the writes of the fields it reads.
func (c *evalCtx) errorText(e ast.Expr, depth int) string {
if depth > maxEvalDepth {
- return "the gate ran out of depth deciding whether it is an error's text"
+ return fmt.Sprintf("%sanalysis limit reached at %s: the gate ran out of depth reading what text it carries", unprovable, c.posOf(e))
}
switch e := e.(type) {
case *ast.ParenExpr:
@@ -699,14 +769,20 @@ func (c *evalCtx) errorText(e ast.Expr, depth int) string {
return "its result is an error"
}
// A call out of the package (fmt.Sprint, strings.TrimSpace) carries the
- // text of its arguments. A package helper is read by eval instead.
- if _, p := c.resolveCallees(e); p != nil {
+ // text of its arguments. A package helper is read through its body.
+ decls, p := c.resolveCallees(e)
+ if p != nil && p.hard {
+ return underivable + p.msg // a receiver the gate cannot resolve
+ }
+ if p != nil {
for _, a := range e.Args {
- if why := c.errorText(a, depth+1); why != "" {
- return fmt.Sprintf("it is made from %s, and %s", render(a), why)
+ if why := dataWhy(c.errorText(a, depth+1)); why != "" {
+ return rewrap(why, "it is made from %s, and %s", render(a))
}
}
+ return ""
}
+ return c.helperText(e, decls, 0, depth)
case *ast.Ident:
if why, ok := c.errParams[e.Name]; ok {
return why
@@ -722,14 +798,20 @@ func (c *evalCtx) errorText(e ast.Expr, depth int) string {
for _, a := range c.locals[e.Name] {
switch {
case a.rhs != nil:
- if why := c.errorText(a.rhs, depth+1); why != "" {
- return fmt.Sprintf("it is assigned %s at %s, and %s", render(a.rhs), c.posOf(a.rhs), why)
+ if why := dataWhy(c.errorText(a.rhs, depth+1)); why != "" {
+ if _, rest := whyClass(why); strings.HasPrefix(rest, "it is assigned ") {
+ return why // an alias chain: its innermost step says it
+ }
+ return rewrap(why, "it is assigned %s at %s, and %s", render(a.rhs), c.posOf(a.rhs))
}
case a.call != nil:
if decls, p := c.resolveCallees(a.call); p == nil && len(decls) > 0 && decls[0].Type != nil {
if rt := resultTypeAt(decls[0], a.pos); rt != nil && c.isErrorType(rt) {
return fmt.Sprintf("it is assigned an error result at %s", c.posOf(a.call))
}
+ if why := dataWhy(c.helperText(a.call, decls, a.pos, depth)); why != "" {
+ return rewrap(why, "it is assigned %s at %s, and %s", render(a.call), c.posOf(a.call))
+ }
}
}
}
@@ -745,10 +827,162 @@ func (c *evalCtx) errorText(e ast.Expr, depth int) string {
if t == nil && errName(e.Sel.Name) {
return "the gate cannot type it, and it is named as an error"
}
+ return c.fieldText(e)
+ }
+ return ""
+}
+
+func isStringType(t ast.Expr) bool {
+ id, ok := t.(*ast.Ident)
+ return ok && id.Name == "string"
+}
+
+// bindErrParams marks hc's parameters that call binds to an error's text (or
+// to text the gate cannot prove). They are refused where the helper speaks
+// them, not where it only compares them.
+func (c *evalCtx) bindErrParams(hc *evalCtx, d *ast.FuncDecl, call *ast.CallExpr, depth int) {
+ for i, p := range paramNames(d.Type) {
+ if i < len(call.Args) {
+ if why := dataWhy(c.errorText(call.Args[i], depth)); why != "" {
+ hc.errParams[p] = rewrap(why, "it is bound to %s at %s, and %s", render(call.Args[i]), c.posOf(call.Args[i]))
+ }
+ }
+ }
+}
+
+// helperText reads a call to a package helper whose result at pos is a string,
+// used as a placeholder, through its body: each return is read with errorText
+// in the helper's own context, its parameters marked where the call binds
+// them to an error's text. A return that carries an error's text, or one the
+// gate cannot read, makes the call a violation. A return of data (a parameter,
+// a region read from a request, a list joined by strings.Join) may stand for
+// a <placeholder>, as any placeholder may. A helper with another result type
+// (an int for %d) is not text.
+func (c *evalCtx) helperText(e *ast.CallExpr, decls []*ast.FuncDecl, pos, depth int) string {
+ if c.noCalls || len(decls) == 0 {
+ return ""
+ }
+ for _, d := range decls {
+ if d.Type == nil || d.Body == nil || !isStringType(resultTypeAt(d, pos)) {
+ return ""
+ }
+ }
+ if depth > maxEvalDepth {
+ return fmt.Sprintf("%sanalysis limit reached at %s: helpers nested too deeply", unprovable, c.posOf(e))
+ }
+ for _, d := range decls {
+ key := fmt.Sprintf("errhelper %p %d", d, pos)
+ if c.visiting[key] {
+ return fmt.Sprintf("%shelper %s calls itself at %s", underivable, d.Name.Name, c.posOf(e))
+ }
+ dfile := strings.TrimPrefix(movesFset.Position(d.Pos()).Filename, c.u.root+"/")
+ hc := c.u.newCtx(dfile, d, c.noCalls)
+ hc.visiting = c.visiting
+ c.bindErrParams(hc, d, e, depth+1)
+ c.visiting[key] = true
+ why := hc.returnsText(d, pos, depth+1)
+ delete(c.visiting, key)
+ switch m, _ := whyClass(why); {
+ case why == "":
+ case m == "":
+ return fmt.Sprintf("helper %s returns an error's text: %s", d.Name.Name, why)
+ default:
+ return rewrap(why, "helper %s returns text the gate cannot derive: %s", d.Name.Name)
+ }
}
return ""
}
+// returnsText reads each return of d at pos with errorText (c is d's own
+// context), as callDecl reads them for a sentence.
+func (c *evalCtx) returnsText(d *ast.FuncDecl, pos, depth int) string {
+ nres := resultCount(d)
+ named := namedResults(d)
+ why := ""
+ ast.Inspect(d.Body, func(n ast.Node) bool {
+ if why != "" {
+ return false
+ }
+ switch n := n.(type) {
+ case *ast.FuncLit:
+ return false
+ case *ast.ReturnStmt:
+ var r string
+ switch {
+ case len(n.Results) == nres && pos < len(n.Results):
+ r = c.errorText(n.Results[pos], depth)
+ case len(n.Results) == 1 && nres > 1:
+ cl, ok := n.Results[0].(*ast.CallExpr)
+ if !ok {
+ r = fmt.Sprintf("%sreturn at %s has a shape the gate cannot read", underivable, c.posOf(n))
+ break
+ }
+ decls, p := c.resolveCallees(cl)
+ switch {
+ case p != nil && p.hard:
+ r = underivable + p.msg
+ case p != nil:
+ r = fmt.Sprintf("%sreturn at %s forwards %s, which the gate cannot read", underivable, c.posOf(n), render(cl))
+ default:
+ r = c.helperText(cl, decls, pos, depth)
+ }
+ case len(n.Results) == 0 && pos < len(named):
+ r = c.errorText(&ast.Ident{Name: named[pos], NamePos: n.Pos()}, depth)
+ default:
+ r = fmt.Sprintf("%sreturn at %s has a shape the gate cannot read", underivable, c.posOf(n))
+ }
+ if r != "" {
+ why = rewrap(r, "at %s: %s", c.posOf(n))
+ }
+ }
+ return true
+ })
+ return why
+}
+
+// fieldText reads a field used as a placeholder through its writes, as
+// fieldRead does for a Reason: a write that carries an error's text, or takes
+// one result of a multi-value expression, makes the read one too. A write of
+// data (a parameter, a variable, a helper result the gate cannot fully read)
+// may stand for a <placeholder>. Writes to a same-named field of another known
+// struct are not this field's.
+func (c *evalCtx) fieldText(e *ast.SelectorExpr) string {
+ if c.noCalls {
+ return ""
+ }
+ f := e.Sel.Name
+ rt := lastName(baseTypeName(c.typeExprOf(e.X, 0)))
+ key := f + "|" + rt
+ if why, ok := c.u.fieldTexts[key]; ok {
+ return why // a field read while its own writes are read is clean here
+ }
+ c.u.fieldTexts[key] = ""
+ why := ""
+ for _, w := range c.u.fieldWrites[f] {
+ wc := c.u.newCtx(w.file, w.fd, c.noCalls)
+ wt := lastName(w.ctype)
+ if w.recv != nil {
+ wt = lastName(baseTypeName(wc.typeExprOf(w.recv, 0)))
+ }
+ if rt != "" && wt != "" && wt != rt {
+ continue
+ }
+ if w.multi {
+ why = fmt.Sprintf("%sa write to field %s at %s takes one result of a multi-value expression, which the gate cannot prove is a sentence", unprovable, f, wc.posOf(w.expr))
+ break
+ }
+ // From depth 0: the answer is cached per field, so it must not depend
+ // on how deep the first reader was. A field cycle reads as clean (the
+ // entry above), and the step limit still bounds the work.
+ if r := dataWhy(wc.errorText(w.expr, 0)); r != "" {
+ why = rewrap(r, "a write to field %s at %s: %s", f, wc.posOf(w.expr))
+ break
+ }
+ }
+ c.u.fieldTexts[key] = why
+ return why
+}
+
// wrapArgs returns the argument indexes (into the call's Args, the format at
// 0) of a format's %w verbs. ok is false when the format's arguments cannot be
// counted (an explicit argument index).
@@ -829,6 +1063,16 @@ func (c *evalCtx) evalIdent(id *ast.Ident, depth int) ([]string, []problem) {
var alts []string
var probs []problem
known := false
+ if why, ok := c.errParams[id.Name]; ok {
+ // Spoken, not merely compared: the helper returns or builds text from a
+ // parameter bound to an error's text.
+ what := "an error's text"
+ m, rest := whyClass(why)
+ if m != "" {
+ what = "text the gate cannot derive"
+ }
+ return nil, []problem{{fmt.Sprintf("%s is %s (%s)", id.Name, what, rest), true}}
+ }
if b, ok := c.bind[id.Name]; ok {
known = true
alts = append(alts, b...)
@@ -1304,8 +1548,8 @@ func (c *evalCtx) callDecl(d *ast.FuncDecl, call *ast.CallExpr, pos, depth int)
probs = append(probs, pr...)
// An argument that is an error's text may still only select among
// sentences; it is refused where the helper puts it in one.
- if why := c.errorText(call.Args[i], 0); why != "" {
- hc.errParams[p] = fmt.Sprintf("it is bound to %s at %s, and %s", render(call.Args[i]), c.posOf(call.Args[i]), why)
+ if why := dataWhy(c.errorText(call.Args[i], 0)); why != "" {
+ hc.errParams[p] = rewrap(why, "it is bound to %s at %s, and %s", render(call.Args[i]), c.posOf(call.Args[i]))
}
} else {
hc.bind[p] = []string{wild}
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 25fa929d0..ffcd6f407 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -515,9 +515,10 @@ func TestMovesTextAppliedPlaceholders(t *testing.T) {
// ---- the mechanical scan (F13) ----
// movesScanFiles are the non-test Go files the moves plans touched, from
-// `git diff --name-only <movesScanBase>..HEAD -- '*.go'` (movesScanBase is
-// the main commit moves-phase1 last merged). Each later plan's "Gate and copy" task appends the files it
-// touched; TestMovesScanFilesCoverTheDiff fails when the list falls behind.
+// `git diff --name-only <base>..HEAD -- '*.go'`, where base is the main commit
+// moves-phase1 last merged (movesScanBaseRef). Each later plan's "Gate and
+// copy" task appends the files it touched; TestMovesScanFilesCoverTheDiff
+// fails when the list falls behind.
var movesScanFiles = []string{
"cmd/console/main.go",
"internal/activator/backend_exec.go",
@@ -593,12 +594,41 @@ var movesScanFiles = []string{
"internal/release/materializeheld.go",
}
-// movesScanBase is the main commit moves-phase1 last merged: the diff from
+// The scan's base is the main commit moves-phase1 last merged: the diff from
// it to HEAD is the series' own, and a string literal it holds is main's own
-// sentence. Moved with every merge of main (c17f39d5, the branch point,
-// until the merge of cbc0922e, claim Plans 8/8b, brought main's newer
-// sentences into these files).
-const movesScanBase = "cbc0922e"
+// sentence. Ruling merge-scanbase: the tests ask git for it
+// (`git merge-base HEAD origin/main`), so a merge of main needs no edit here.
+// movesScanBase is only the fallback where git or origin/main is not
+// available: the merge base when this was last written (c17f39d5, the branch
+// point, then cbc0922e, then 3407d114, claim C5/C7).
+const movesScanBase = "3407d114"
+
+// movesMergeBase asks git for the commit HEAD and origin/main last shared. A
+// variable, so the fallback can be tested.
+var movesMergeBase = func() (string, error) {
+ out, err := exec.Command("git", "merge-base", "HEAD", "origin/main").Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// resolveMovesScanBase is the base the scan's git checks diff from: what
+// lookup says, or pinned when it fails or says nothing. from names which.
+func resolveMovesScanBase(lookup func() (string, error), pinned string) (base, from string) {
+ if b, err := lookup(); err == nil && b != "" {
+ return b, "git merge-base HEAD origin/main"
+ }
+ return pinned, "the pinned movesScanBase"
+}
+
+// movesScanBaseRef is the base for this run, logged so a failure says which.
+func movesScanBaseRef(t *testing.T) string {
+ t.Helper()
+ base, from := resolveMovesScanBase(movesMergeBase, movesScanBase)
+ t.Logf("scan base %s, from %s", base, from)
+ return base
+}
// What the scan treats as an emission. A call to jsonError emits its message
// (the third argument). A Check literal's Reason emits. A function in
@@ -904,17 +934,47 @@ func TestMovesScanFilesInventoryRules(t *testing.T) {
}
}
+// Ruling merge-scanbase: git's merge base wins, and the pinned constant is
+// only the fallback. Both paths, with the lookup faked.
+func TestMovesScanBaseResolution(t *testing.T) {
+ cases := []struct {
+ name string
+ lookup func() (string, error)
+ base, from string
+ }{
+ {"git answers", func() (string, error) { return "0123abcd", nil }, "0123abcd", "git merge-base HEAD origin/main"},
+ {"git fails (no git, no origin/main)", func() (string, error) { return "", fmt.Errorf("exit status 128") }, "pinnedsha", "the pinned movesScanBase"},
+ {"git says nothing", func() (string, error) { return "", nil }, "pinnedsha", "the pinned movesScanBase"},
+ }
+ for _, c := range cases {
+ base, from := resolveMovesScanBase(c.lookup, "pinnedsha")
+ if base != c.base || from != c.from {
+ t.Errorf("%s: = %q from %q, want %q from %q", c.name, base, from, c.base, c.from)
+ }
+ }
+ // The real lookup, where git and origin/main are there, names a commit
+ // HEAD contains.
+ b, err := movesMergeBase()
+ if err != nil || b == "" {
+ t.Skipf("git merge-base HEAD origin/main: %v", err)
+ }
+ if err := exec.Command("git", "merge-base", "--is-ancestor", b, "HEAD").Run(); err != nil {
+ t.Errorf("the merge base %s is not an ancestor of HEAD: %v", b, err)
+ }
+}
+
// The scan reads the files listed in movesScanFiles. When moves-phase1's diff
// names a production Go file the list does not, the scan has a blind spot:
// add it. The check is skipped where git or the base commit is not available.
func TestMovesScanFilesCoverTheDiff(t *testing.T) {
- out, err := exec.Command("git", "diff", "--name-only", movesScanBase+"..HEAD", "--", "*.go").Output()
+ base := movesScanBaseRef(t)
+ out, err := exec.Command("git", "diff", "--name-only", base+"..HEAD", "--", "*.go").Output()
if err != nil {
- t.Skipf("git diff %s..HEAD: %v", movesScanBase, err)
+ t.Skipf("git diff %s..HEAD: %v", base, err)
}
exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
for _, f := range movesUncovered(strings.Fields(string(out)), movesScanFiles, exists) {
- t.Errorf("%s changed since %s and is not in movesScanFiles", f, movesScanBase)
+ t.Errorf("%s changed since %s and is not in movesScanFiles", f, base)
}
for _, f := range movesScanFiles {
if _, err := os.Stat(f); err != nil {
@@ -929,6 +989,7 @@ func TestMovesScanFilesCoverTheDiff(t *testing.T) {
// series writes into one of these files must get a row, so this fails and
// the sentence moves out of the exemption.
func TestMovesLegacyExemptionsAreLegacy(t *testing.T) {
+ base := movesScanBaseRef(t)
files := map[string]bool{}
for f := range movesLegacyFiles {
files[f] = true
@@ -945,9 +1006,9 @@ func TestMovesLegacyExemptionsAreLegacy(t *testing.T) {
// The base commit's package, written out so the same scan reads it.
root := t.TempDir()
dir := filepath.Dir(f)
- list, err := exec.Command("git", "ls-tree", "--name-only", movesScanBase, dir+"/").Output()
+ list, err := exec.Command("git", "ls-tree", "--name-only", base, dir+"/").Output()
if err != nil {
- t.Skipf("git ls-tree %s: %v", movesScanBase, err)
+ t.Skipf("git ls-tree %s: %v", base, err)
}
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
t.Fatal(err)
@@ -956,9 +1017,9 @@ func TestMovesLegacyExemptionsAreLegacy(t *testing.T) {
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
- body, err := exec.Command("git", "show", movesScanBase+":"+name).Output()
+ body, err := exec.Command("git", "show", base+":"+name).Output()
if err != nil {
- t.Skipf("git show %s:%s: %v", movesScanBase, name, err)
+ t.Skipf("git show %s:%s: %v", base, name, err)
}
if err := os.WriteFile(filepath.Join(root, name), body, 0o600); err != nil {
t.Fatal(err)
@@ -975,7 +1036,7 @@ func TestMovesLegacyExemptionsAreLegacy(t *testing.T) {
}
exempt++
if !inBase[e.pattern] {
- t.Errorf("%s is exempt as main's own sentence, but %s at %s did not say it", e, f, movesScanBase)
+ t.Errorf("%s is exempt as main's own sentence, but %s at %s did not say it", e, f, base)
}
}
if exempt == 0 {
@@ -1146,7 +1207,7 @@ func TestMovesSinkFixtures(t *testing.T) {
}, 2},
{"param", []string{
"emit.go:7 (jsonError in refuse): err.Error(...) forwards an error's text",
- "emit.go:7 (jsonError in refuse): a value with no text of its own",
+ "emit.go:7 (jsonError in refuse): helper wrap, at testdata/moves-copy/sinks/param/emit.go:11:2: m is an error's text (it is bound to err.Error(...)",
}, 2},
{"substitution", nil, 1},
{"named-result", nil, 1},
@@ -1186,8 +1247,18 @@ func TestMovesSinkFixtures(t *testing.T) {
"emit.go:46 (jsonError in localText): detail puts an error's text in a sentence",
"emit.go:51 (jsonError in viaHelper): helper explain, at testdata/moves-copy/sinks/error-text/emit.go:55:2: m puts an error's text in a sentence",
}, 8},
+ // Fix round 5. MV-N2 matches every format here; the helper's body and
+ // the field's writes are what the gate refuses.
+ {"error-helper", []string{
+ "emit.go:14 (jsonError in viaHelper): detail(...) puts an error's text in a sentence (helper detail returns ",
+ "emit.go:18 (jsonError in viaHelperConcat): detail(...) puts an error's text in a sentence (helper detail returns ",
+ "emit.go:35 (jsonError in viaOpaque): opaque(...) puts text the gate cannot derive in a sentence (helper opaque returns ",
+ "emit.go:44 (jsonError in viaEcho): echo(...) puts an error's text in a sentence (helper echo returns ",
+ "emit.go:55 (jsonError in viaField): f.Detail puts an error's text in a sentence (a write to field Detail at testdata/moves-copy/sinks/error-helper/emit.go:51:25: it calls Error",
+ "emit.go:59 (jsonError in viaFieldConcat): f.Detail puts an error's text in a sentence (a write to field Detail at testdata/moves-copy/sinks/error-helper/emit.go:51:25: it calls Error",
+ }, 6},
{"field-limit", []string{
- "emit.go:26 (jsonError in placeholder): a write to field Reason at testdata/moves-copy/sinks/field-limit/emit.go:22:22 is not a sentence: analysis limit reached at ",
+ "emit.go:26 (jsonError in placeholder): n.Reason puts text the gate cannot derive in a sentence (a write to field Reason at testdata/moves-copy/sinks/field-limit/emit.go:22:22: it is assigned a1 at testdata/moves-copy/sinks/field-limit/emit.go:11:8, and analysis limit reached at ",
}, 2},
// Both reads are of refusal.Reason, so each sees both writes.
{"field-multi", []string{
diff --git a/testdata/moves-copy/sinks/error-helper/emit.go b/testdata/moves-copy/sinks/error-helper/emit.go
new file mode 100644
index 000000000..40bc18509
--- /dev/null
+++ b/testdata/moves-copy/sinks/error-helper/emit.go
@@ -0,0 +1,60 @@
+package fixture
+
+import "fmt"
+
+// Not compiled. MV-N2's format, with its list made by a package helper or
+// read from a string field. Each carries an error's text, or text the gate
+// cannot derive, and no row vouches for that.
+
+func detail(err error) string {
+ return err.Error()
+}
+
+func viaHelper(w int, to string, err error) {
+ jsonError(w, 400, fmt.Sprintf("region %s is not one of this app's regions: %s", to, detail(err)))
+}
+
+func viaHelperConcat(w int, to string, err error) {
+ jsonError(w, 400, "region "+to+" is not one of this app's regions: "+detail(err))
+}
+
+// A helper whose return the gate cannot derive: the receiver is shadowed, so
+// the gate cannot tell which describe runs.
+type local struct{}
+
+func (local) describe() string { return "a recorded sentence" }
+
+func opaque(x local, y thing) string {
+ if x := y; x != nil {
+ return x.describe()
+ }
+ return "a recorded sentence"
+}
+
+func viaOpaque(w int, to string, x local, y thing) {
+ jsonError(w, 400, fmt.Sprintf("region %s is not one of this app's regions: %s", to, opaque(x, y)))
+}
+
+// A helper that returns its parameter, bound to an error's text.
+func echo(m string) string {
+ return m
+}
+
+func viaEcho(w int, to string, err error) {
+ jsonError(w, 400, fmt.Sprintf("region %s is not one of this app's regions: %s", to, echo(fmt.Sprint(err))))
+}
+
+// A string field written from an error's text.
+type failure struct{ Detail string }
+
+func record(err error) failure {
+ return failure{Detail: err.Error()}
+}
+
+func viaField(w int, to string, f failure) {
+ jsonError(w, 400, fmt.Sprintf("region %s is not one of this app's regions: %s", to, f.Detail))
+}
+
+func viaFieldConcat(w int, to string, f failure) {
+ jsonError(w, 400, "region "+to+" is not one of this app's regions: "+f.Detail)
+}
diff --git a/testdata/moves-copy/sinks/error-helper/record.md b/testdata/moves-copy/sinks/error-helper/record.md
new file mode 100644
index 000000000..034949ed6
--- /dev/null
+++ b/testdata/moves-copy/sinks/error-helper/record.md
@@ -0,0 +1,6 @@
+# MV-N2 matches every sentence of the error-helper fixture. The gate must
+# still refuse each sink.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N2 | emit.go | region <to> is not one of this app's regions: <list> | pending |
diff --git a/testdata/moves-copy/sinks/ok/emit.go b/testdata/moves-copy/sinks/ok/emit.go
index d18909e2e..68e8cb734 100644
--- a/testdata/moves-copy/sinks/ok/emit.go
+++ b/testdata/moves-copy/sinks/ok/emit.go
@@ -102,3 +102,33 @@ func (local) Message() string { return "the app is not enabled" }
func viaMethod(w int, m local) {
jsonError(w, 400, m.Message())
}
+
+// A helper that dresses data, and a string field written from data: both
+// stand for a row's <placeholder>.
+func label(host string) string {
+ return "host " + host
+}
+
+func viaLabel(w int, h string) {
+ jsonError(w, 400, fmt.Sprintf("%s has no seat", label(h)))
+}
+
+type target struct{ Name string }
+
+func makeTarget(n string) target {
+ return target{Name: n}
+}
+
+func viaDataField(w int, t target) {
+ jsonError(w, 400, fmt.Sprintf("region %s is gone", t.Name))
+}
+
+// A helper that joins data (stagingCodes' shape): the gate cannot derive it,
+// and it carries no error's text, so it stands for a <placeholder>.
+func joined(codes []string) string {
+ return strings.Join(codes, ", ")
+}
+
+func viaJoined(w int, codes []string) {
+ jsonError(w, 400, fmt.Sprintf("the staging codes are %s", joined(codes)))
+}
diff --git a/testdata/moves-copy/sinks/ok/record.md b/testdata/moves-copy/sinks/ok/record.md
index 4edbaead7..332570eaa 100644
--- a/testdata/moves-copy/sinks/ok/record.md
+++ b/testdata/moves-copy/sinks/ok/record.md
@@ -16,3 +16,6 @@
| MV-N12 | emit.go | the copy is fine | pending |
| MV-N13 | emit.go | the copy is not fine | pending |
| MV-N14 | emit.go | host <host> is not armed | pending |
+| MV-N15 | emit.go | host <host> has no seat | pending |
+| MV-N16 | emit.go | region <to> is gone | pending |
+| MV-N17 | emit.go | the staging codes are <codes> | pending |