496559c9dd
Migration #22 adds done_when and blocked_on to tasks, both NOT NULL DEFAULT ''. "He has not written one" and "there is nothing to write" are the same state here, so no caller has to tell NULL from empty. blocked_on is a canonical Nexus entity id, never a name. It names a person and identity lives in Nexus, so free text here would be a second answer to a question Nexus already owns. The caller resolves before it writes. Both columns round-trip through ipc.TaskAPI: on ipc.Task, settable at intake through CaptureTaskReq, and writable afterwards through the new SetTaskFields, which is deliberately not one-way — he may sharpen a criterion, and a blocker clears when the person answers. SetTaskStatus now refuses candidate → open when done_when is empty (ErrTaskNoDoneWhen, mapped across the wire), the same refusal ParseTaskCapture makes for a capture marker with nothing after it: confirming work whose finish line nobody wrote is how a board fills with rows that can never leave it. Dropping such a candidate stays legal, and the /tasks confirm button now says what is missing instead of surfacing a not-found. One caller skips the gate. CaptureTask promoting a candidate he stated out loud would otherwise be denied intake rather than asked for a criterion, and a direct open capture never carried one either. The gate belongs to the deliberate promotion on /tasks, where V-511 puts a form.
156 lines
5.5 KiB
Go
156 lines
5.5 KiB
Go
package ipc
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// mapErr turns a store sentinel into its wire twin so a module can errors.Is
|
|
// without importing internal/store. The design is right and the failure mode is
|
|
// quiet: add a sentinel to store, forget the switch, and the client gets an
|
|
// untyped error that no caller can branch on. These two tests are the alarm.
|
|
|
|
// mapErrPairs — every store sentinel that has a wire twin, and the twin.
|
|
var mapErrPairs = []struct {
|
|
name string // the store identifier, for the coverage test below
|
|
from error
|
|
wants error
|
|
}{
|
|
{"ErrNoFact", store.ErrNoFact, ErrNoFact},
|
|
{"ErrConfidence", store.ErrConfidence, ErrConfidence},
|
|
{"ErrVoidsMissing", store.ErrVoidsMissing, ErrVoidsMissing},
|
|
{"ErrNudgeNotFound", store.ErrNudgeNotFound, ErrNudgeNotFound},
|
|
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
|
|
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
|
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
|
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
|
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
|
}
|
|
|
|
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
|
// each with the reason it stays store-side. A new sentinel is in neither list
|
|
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
|
|
// module can branch on an error is a decision, not a default.
|
|
var unmappedStoreErrors = map[string]string{
|
|
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
|
|
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
|
|
|
|
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
|
|
|
|
"ErrProposedRoutineNotFound": "no module branches on a routine id that vanished; accept and dismiss are owner clicks",
|
|
"ErrProposedRoutineExists": "the propose path already reports 'nothing new' through its bool return",
|
|
"ErrRoutineStatus": "an unknown status is a caller bug, not a state a module recovers from",
|
|
|
|
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
|
|
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
|
|
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
|
|
|
|
// The shopping and packing lists follow the task verdicts exactly, and for
|
|
// the same reasons. No module branches on any of the three today.
|
|
"ErrListItemNotFound": "the surface re-lists rather than branch on an item id that vanished",
|
|
"ErrListItemEmpty": "input validation — an empty item is refused before it reaches the store",
|
|
"ErrListItemStatus": "an unknown status is a caller bug, not a state a module recovers from",
|
|
}
|
|
|
|
// The mapping itself, through a wrap, because every real caller wraps.
|
|
func TestMapErrMapsEveryPair(t *testing.T) {
|
|
for _, p := range mapErrPairs {
|
|
got := mapErr(fmt.Errorf("storeapi: %w", p.from))
|
|
if got != p.wants {
|
|
t.Errorf("mapErr(store.%s) = %v, want %v", p.name, got, p.wants)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Anything mapErr does not recognise passes through untouched. A module that
|
|
// cannot branch on an error must still see the original text.
|
|
func TestMapErrPassesUnknownThrough(t *testing.T) {
|
|
if mapErr(nil) != nil {
|
|
t.Error("mapErr(nil) must stay nil")
|
|
}
|
|
own := fmt.Errorf("socket closed")
|
|
if got := mapErr(own); got != own {
|
|
t.Errorf("mapErr(%v) = %v, want the same error back", own, got)
|
|
}
|
|
}
|
|
|
|
// The parity half: every exported sentinel in internal/store is either mapped
|
|
// or listed with a reason. Read off the source, so a sentinel added in a file
|
|
// this package never touches still trips it.
|
|
func TestMapErrCoversEveryStoreSentinel(t *testing.T) {
|
|
mapped := map[string]bool{}
|
|
for _, p := range mapErrPairs {
|
|
mapped[p.name] = true
|
|
}
|
|
|
|
for _, name := range storeSentinelNames(t) {
|
|
if mapped[name] || unmappedStoreErrors[name] != "" {
|
|
continue
|
|
}
|
|
t.Errorf("store.%s is a new sentinel with no verdict: add it to mapErr and mapErrPairs, "+
|
|
"or to unmappedStoreErrors with the reason a module cannot branch on it", name)
|
|
}
|
|
}
|
|
|
|
// storeSentinelNames reads internal/store for exported package-level error
|
|
// values: `var ErrX = errors.New(...)`, inside a block or on its own.
|
|
func storeSentinelNames(t *testing.T) []string {
|
|
t.Helper()
|
|
fset := token.NewFileSet()
|
|
pkgs, err := parser.ParseDir(fset, "../store", func(fi fs.FileInfo) bool {
|
|
return !strings.HasSuffix(fi.Name(), "_test.go")
|
|
}, 0)
|
|
if err != nil {
|
|
t.Fatalf("parse internal/store: %v", err)
|
|
}
|
|
var out []string
|
|
for _, pkg := range pkgs {
|
|
for _, f := range pkg.Files {
|
|
for _, d := range f.Decls {
|
|
gd, ok := d.(*ast.GenDecl)
|
|
if !ok || gd.Tok != token.VAR {
|
|
continue
|
|
}
|
|
for _, spec := range gd.Specs {
|
|
vs, ok := spec.(*ast.ValueSpec)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for i, n := range vs.Names {
|
|
if !strings.HasPrefix(n.Name, "Err") || !n.IsExported() {
|
|
continue
|
|
}
|
|
if i < len(vs.Values) && isErrorsNew(vs.Values[i]) {
|
|
out = append(out, n.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(out) < len(mapErrPairs) {
|
|
t.Fatalf("found %d sentinels in internal/store, fewer than the %d already mapped — the scan is broken, not the store", len(out), len(mapErrPairs))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isErrorsNew(e ast.Expr) bool {
|
|
call, ok := e.(*ast.CallExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok || sel.Sel.Name != "New" {
|
|
return false
|
|
}
|
|
id, ok := sel.X.(*ast.Ident)
|
|
return ok && id.Name == "errors"
|
|
}
|