Review 31-07: Split the 30-method CoreAPI — fixes the 42-case dispatcher, lockedAPI, and the stub-padded test doubles at once #153
@@ -678,135 +678,6 @@ type acceptProposedRoutineReq struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
||||
// for modules that live in-process for now (router, delivery) and by tests,
|
||||
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
||||
// - the client proxy (client.go) — same interface, over the wire.
|
||||
//
|
||||
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
||||
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
||||
// That swappability is the seam the auth layer will insert into without
|
||||
// touching the module code.
|
||||
type CoreAPI interface {
|
||||
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
||||
LatestFact(ctx context.Context, key string) (Fact, error)
|
||||
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
||||
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
||||
Presence(ctx context.Context) (Presence, error)
|
||||
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
||||
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||
// every status (Vikunja #390).
|
||||
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||
|
||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||
// RecentNotesFromSource — the newest n notes whose source starts with
|
||||
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
||||
// from recall, so this is the only way to reach them, and it keeps the feed
|
||||
// answer from being crowded out of a fixed window by his own notes.
|
||||
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
||||
|
||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||
// destructive and flips status to 'enabled'. DisableTool reverts an
|
||||
// enabled tool back to proposed (it stays in the store, won't run).
|
||||
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
||||
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
||||
// LookupTool/ListTools read them.
|
||||
// scope defaults to "homelab" when empty.
|
||||
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
||||
DisableTool(ctx context.Context, name string) error
|
||||
DeleteTool(ctx context.Context, name string) error
|
||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||
RevertFact(ctx context.Context, key string) (int64, error)
|
||||
|
||||
// ListProposedRoutines returns proposed routines, newest first.
|
||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||
// intake seam for the voice path, the web form and the future email
|
||||
// extractor. Idempotent per live normalised text; the response says
|
||||
// whether a row was actually created.
|
||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||
// "live" is candidate + open (outstanding work).
|
||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||
|
||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
// persisted — it's a daemon-level cache).
|
||||
TickTrace(ctx context.Context) (TickTrace, error)
|
||||
|
||||
// MorningStatus returns each configured morning routine's current
|
||||
// checklist state (see internal/morning): active today/now, which items
|
||||
// are done, which are still missing. The store adapter returns an error
|
||||
// (morning routines are daemon-config, not persisted) — same shape as
|
||||
// TickTrace.
|
||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||
|
||||
// MCPServers reports the configured MCP servers and their health
|
||||
// (Vikunja #251). Read-only introspection for /tools — there is no
|
||||
// "call this tool" method on purpose: an MCP tool runs through the same
|
||||
// allowlist, confirm turn and act path as any other tool, and a second
|
||||
// mutation path would be a second thing to get wrong. Empty when the
|
||||
// mcp config block is absent, which is the default.
|
||||
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
||||
|
||||
// DayPlan returns today's ordered plan — calendar events, pending
|
||||
// reminders and any morning checklist still outstanding (see
|
||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||
// The store adapter returns an error (the plan needs the daemon's routine
|
||||
// config) — same shape as TickTrace and MorningStatus.
|
||||
DayPlan(ctx context.Context) (DayPlan, error)
|
||||
|
||||
// Chat routes a text utterance through the reactive handler's core path
|
||||
// (router → dialogue → action → replier) and returns the reply text.
|
||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||
//
|
||||
// conversation names the thread. A parked clarifying question is held per
|
||||
// conversation, so an unanswered question on one reach cannot eat the next
|
||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||
// tap and is still one conversation of its own, separate from the mic.
|
||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||
|
||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||
// it came from: a relayed notification, a mail candidate, a feed item, a
|
||||
// changed page, a spend, a presence probe.
|
||||
//
|
||||
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
||||
// the store adapter returns an error, because the journal is a bounded
|
||||
// in-memory ring and not a table. Its contents are a window over intake,
|
||||
// never the durable record — that is still the fact, note or task the
|
||||
// intake path wrote.
|
||||
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
||||
}
|
||||
|
||||
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
|
||||
// event.Event field for field; the ipc package does not import internal/event
|
||||
// so the wire shape stays independent of the in-process type.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CoreAPI and the eight domain interfaces it composes (Vikunja #408).
|
||||
//
|
||||
// It used to be one flat block of forty methods, and the cost of that shape
|
||||
// was paid three times over: every method added an arm to the dispatcher, a
|
||||
// stub to the daemon's locked API, and a stub to every test double. Two of
|
||||
// those three are already gone — the dispatcher is a table (methodTable in
|
||||
// server.go), and UnimplementedCoreAPI took the padding out of the doubles and
|
||||
// out of lockedAPI, which no longer exists.
|
||||
//
|
||||
// What was left is the interface itself, and this file is that half. CoreAPI
|
||||
// is unchanged as a type: the same forty methods, in the same order, so the
|
||||
// wire contract, the client proxy and the store adapter are all untouched.
|
||||
// What it gains is a named seam per domain, so a caller that only reads facts
|
||||
// can say FactAPI and a reader can see which cluster a method belongs to
|
||||
// without counting lines.
|
||||
//
|
||||
// Add a method to the domain it belongs to, not to CoreAPI.
|
||||
|
||||
// FactAPI — the fact store: write, read the current value, read history, and
|
||||
// undo. Presence sits here because it is a hysteresis view over presence
|
||||
// facts, not a store of its own.
|
||||
type FactAPI interface {
|
||||
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
||||
LatestFact(ctx context.Context, key string) (Fact, error)
|
||||
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
||||
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
||||
Presence(ctx context.Context) (Presence, error)
|
||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||
RevertFact(ctx context.Context, key string) (int64, error)
|
||||
}
|
||||
|
||||
// ReminderAPI — scheduled sends the owner asked for.
|
||||
type ReminderAPI interface {
|
||||
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
||||
}
|
||||
|
||||
// NudgeAPI — proactive sends Maven proposed, their outcomes, and the outbox
|
||||
// they went out through.
|
||||
type NudgeAPI interface {
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
||||
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||
// every status (Vikunja #390).
|
||||
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||
}
|
||||
|
||||
// NoteAPI — free text he captured, plus the embedded recall over it.
|
||||
type NoteAPI interface {
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||
// RecentNotesFromSource — the newest n notes whose source starts with
|
||||
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
||||
// from recall, so this is the only way to reach them, and it keeps the feed
|
||||
// answer from being crowded out of a fixed window by his own notes.
|
||||
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
||||
}
|
||||
|
||||
// ToolAPI — the capability allowlist and its read side.
|
||||
//
|
||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||
// destructive and flips status to 'enabled'. DisableTool reverts an
|
||||
// enabled tool back to proposed (it stays in the store, won't run).
|
||||
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
||||
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
||||
// LookupTool/ListTools read them.
|
||||
// scope defaults to "homelab" when empty.
|
||||
type ToolAPI interface {
|
||||
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
||||
DisableTool(ctx context.Context, name string) error
|
||||
DeleteTool(ctx context.Context, name string) error
|
||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||
|
||||
// MCPServers reports the configured MCP servers and their health
|
||||
// (Vikunja #251). Read-only introspection for /tools — there is no
|
||||
// "call this tool" method on purpose: an MCP tool runs through the same
|
||||
// allowlist, confirm turn and act path as any other tool, and a second
|
||||
// mutation path would be a second thing to get wrong. Empty when the
|
||||
// mcp config block is absent, which is the default.
|
||||
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
||||
}
|
||||
|
||||
// RoutineAPI — the shapes of his day: routines Maven noticed and proposed, the
|
||||
// morning checklist, and today's plan.
|
||||
//
|
||||
// MorningStatus and DayPlan are daemon-computed rather than stored, so the
|
||||
// store adapter returns an error for both — the same shape as TickTrace.
|
||||
type RoutineAPI interface {
|
||||
// ListProposedRoutines returns proposed routines, newest first.
|
||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// MorningStatus returns each configured morning routine's current
|
||||
// checklist state (see internal/morning): active today/now, which items
|
||||
// are done, which are still missing.
|
||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||
|
||||
// DayPlan returns today's ordered plan — calendar events, pending
|
||||
// reminders and any morning checklist still outstanding (see
|
||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||
DayPlan(ctx context.Context) (DayPlan, error)
|
||||
}
|
||||
|
||||
// TaskAPI — outstanding work, whatever surface it arrived from.
|
||||
type TaskAPI interface {
|
||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||
// intake seam for the voice path, the web form and the future email
|
||||
// extractor. Idempotent per live normalised text; the response says
|
||||
// whether a row was actually created.
|
||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||
// "live" is candidate + open (outstanding work).
|
||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||
}
|
||||
|
||||
// SystemAPI — what the daemon knows about itself, plus the one method that
|
||||
// runs a whole turn.
|
||||
type SystemAPI interface {
|
||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
// persisted — it's a daemon-level cache).
|
||||
TickTrace(ctx context.Context) (TickTrace, error)
|
||||
|
||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||
|
||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||
// it came from: a relayed notification, a mail candidate, a feed item, a
|
||||
// changed page, a spend, a presence probe.
|
||||
//
|
||||
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
||||
// the store adapter returns an error, because the journal is a bounded
|
||||
// in-memory ring and not a table. Its contents are a window over intake,
|
||||
// never the durable record — that is still the fact, note or task the
|
||||
// intake path wrote.
|
||||
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
||||
|
||||
// Chat routes a text utterance through the reactive handler's core path
|
||||
// (router → dialogue → action → replier) and returns the reply text.
|
||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||
//
|
||||
// conversation names the thread. A parked clarifying question is held per
|
||||
// conversation, so an unanswered question on one reach cannot eat the next
|
||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||
// tap and is still one conversation of its own, separate from the mic.
|
||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||
}
|
||||
|
||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||
// - the in-process store adapter (storeapi.go) — used by the daemon
|
||||
// for modules that live in-process for now (router, delivery) and by tests,
|
||||
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
||||
// - the client proxy (client.go) — same interface, over the wire.
|
||||
//
|
||||
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
||||
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
||||
// That swappability is the seam the auth layer inserts into without touching
|
||||
// the module code.
|
||||
type CoreAPI interface {
|
||||
FactAPI
|
||||
ReminderAPI
|
||||
NudgeAPI
|
||||
NoteAPI
|
||||
ToolAPI
|
||||
RoutineAPI
|
||||
TaskAPI
|
||||
SystemAPI
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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},
|
||||
}
|
||||
|
||||
// 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 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"
|
||||
}
|
||||
Reference in New Issue
Block a user