Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 240d53a96a | |||
| d8efb667c7 | |||
| 25ed201c4d | |||
| a926383827 | |||
| 557f5a3acc | |||
| 17e6195aeb | |||
| 14f2725452 | |||
| f8beee8416 | |||
| 634f82717c | |||
| 353b8f5a16 | |||
| 9bb342569b | |||
| 5596cdddbc | |||
| 1c13d2265b | |||
| 95e7427153 |
@@ -54,8 +54,15 @@ carries `-count=1` and sets `MAVEN_ONNX_LIB`. Without that variable the four
|
||||
make build # all 11 binaries. make build-web for one (web/waked/poll/caldav skip CGO)
|
||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race
|
||||
make analyze # staticcheck, deadcode and govulncheck. Not in `test`: all three need the network
|
||||
```
|
||||
|
||||
**The static gates pass against a baseline, not against zero**
|
||||
(`scripts/analyzers/*.baseline`, reasoning in `docs/workflow.md`). A fix must
|
||||
delete its baseline entry, because the gate also fails on an entry whose finding
|
||||
is gone. **`make audit` is a git-grep inventory, not analysis.** Do not cite it
|
||||
as a reachability check.
|
||||
|
||||
## The daemons
|
||||
|
||||
Eleven binaries under `cmd/`, wired socket-to-socket over `internal/ipc`, not
|
||||
@@ -108,13 +115,15 @@ classifier. Every stage may decline and the next one answers.
|
||||
|
||||
- **The classifier is the floor, not dead code.** It answers when the resident
|
||||
model is off, absent, or erroring. **Any model error falls through.**
|
||||
- **`baselineGrammars` in `eval_test.go` mirrors `buildRouter`.** A grammar
|
||||
added to one belongs in both, or the fixture scores a set nobody runs.
|
||||
- **The stage 0 set lives in `router.StageZeroGrammars`**, and both `buildRouter`
|
||||
and the eval fixture call it. Add a grammar there, in the right place, and read
|
||||
the comment above the line you insert after. Do not restate the list anywhere.
|
||||
- **Go's `\b` is ASCII-only** and never fires after a Cyrillic letter. A Russian
|
||||
pattern needs an explicit `(\s|[?!.]|$)`.
|
||||
- **`PraxisGrammars()` is the only path to Praxis**, not a faster one.
|
||||
- **`voice.embedder.heads_path` must never point at `model_path`.** Recall
|
||||
depends on the resident e5-small scoring what it scored. Fine-tune a copy.
|
||||
Refused at config load since V-692, symlinks included.
|
||||
- **Routing traces are retained 14 days**, enforced on write and again on start.
|
||||
- **Bump `tokenizerRev` on any change to what `encodeWord` emits**, so a
|
||||
tokenizer fix triggers `ReembedAll` the way swapping the model file does.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# `test` below fail on the two packages that have no test files. deps-go builds
|
||||
# the missing tools in, so the vendored tree is self-sufficient. Keep the version
|
||||
# here in step with the `go` directive in go.mod.
|
||||
GO_VERSION := 1.25.5
|
||||
GO_VERSION := 1.25.12
|
||||
GO := $(shell pwd)/deps/go/go/bin/go
|
||||
export GOTOOLCHAIN := local
|
||||
GOFLAGS :=
|
||||
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
.PHONY: t audit simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
.PHONY: t audit simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel deps-vuln vuln deps-lint lint deadcode analyze tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
|
||||
all: build
|
||||
|
||||
@@ -73,7 +73,7 @@ run-web: build-web
|
||||
# builds them on demand, but `go test -coverprofile` calls covdata through
|
||||
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
||||
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||
GO_SHA256 := 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
|
||||
deps-go: deps-sentinel
|
||||
@mkdir -p deps/go
|
||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||
@@ -95,6 +95,70 @@ deps-sentinel:
|
||||
@mkdir -p deps
|
||||
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
|
||||
|
||||
# vuln — the advisory gate the 2026-08-10 audit found missing (V-682). It reads
|
||||
# the published database over the network, so it is not part of `test`, which
|
||||
# has to pass on a box with no route out. Run it before a toolchain or
|
||||
# dependency bump lands, because that is what it grades: on 2026-08-11 the
|
||||
# pinned Go 1.25.5 and x/text 0.14.0 carried 20 reachable advisories and the
|
||||
# bumped pair carries none.
|
||||
#
|
||||
# govulncheck is a tool and not a dependency, so it is installed into deps/ like
|
||||
# the toolchain rather than added to go.mod. The version is pinned here for the
|
||||
# same reason GO_VERSION is: a gate that moves on its own is not a gate.
|
||||
GOVULNCHECK_VERSION := v1.6.0
|
||||
GOVULNCHECK := $(shell pwd)/deps/bin/govulncheck
|
||||
|
||||
deps-vuln: deps-sentinel
|
||||
@mkdir -p deps/bin
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
|
||||
|
||||
# The CGO env is the same one `test` carries: govulncheck loads the packages,
|
||||
# and the four CGO daemons do not load without it.
|
||||
vuln: deps-vuln
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local $(GOVULNCHECK) ./...
|
||||
|
||||
# lint and deadcode — the other two analyzers the 2026-08-10 audit asked for
|
||||
# (V-694). They are not part of `test` for the same reason `vuln` is not: they
|
||||
# install over the network, and they are slow enough that a change to one Go
|
||||
# file should not pay for them.
|
||||
#
|
||||
# Neither reports zero, so neither fails on its own output. The accepted set
|
||||
# lives in scripts/analyzers/*.baseline and scripts/analyzer-gate.sh decides.
|
||||
# What is new fails, and so does a baseline entry whose finding is gone.
|
||||
#
|
||||
# deadcode runs with -test, so a test file is a root. Without it the report is
|
||||
# 172 lines, most of internal/router/eval, and none of it is a mistake.
|
||||
STATICCHECK_VERSION := v0.7.0
|
||||
DEADCODE_VERSION := v0.48.0
|
||||
STATICCHECK := $(shell pwd)/deps/bin/staticcheck
|
||||
DEADCODE := $(shell pwd)/deps/bin/deadcode
|
||||
|
||||
deps-lint: deps-sentinel
|
||||
@mkdir -p deps/bin
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION)
|
||||
|
||||
# Both load the packages, so both carry the CGO env `test` carries. Without it
|
||||
# the four CGO daemons do not load and the analyzer reports a build error
|
||||
# instead of a finding -- which analyzer-gate.sh fails on rather than filters.
|
||||
ANALYZER_ENV = CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" \
|
||||
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local
|
||||
|
||||
lint: deps-lint
|
||||
@$(ANALYZER_ENV) $(STATICCHECK) ./... | scripts/analyzer-gate.sh staticcheck
|
||||
|
||||
deadcode: deps-lint
|
||||
@$(ANALYZER_ENV) $(DEADCODE) -test ./... | scripts/analyzer-gate.sh deadcode
|
||||
|
||||
# Every static gate in one command. Not `check`, because it is not the thing to
|
||||
# run before a commit: vuln reads the network and all three are slow.
|
||||
analyze: lint deadcode vuln
|
||||
|
||||
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
|
||||
# go.mod, and a build target that edits the module file is a surprise.
|
||||
# vendor/ is committed, so a tidy that drops a requirement must be followed by
|
||||
|
||||
+9
-40
@@ -386,8 +386,13 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm
|
||||
return resident, nil
|
||||
}
|
||||
ws := cfg.Workstation
|
||||
remote := llm.New(ws.URL, time.Duration(ws.Timeout))
|
||||
remote.SetToken(ws.Token)
|
||||
if ws.Token == "" {
|
||||
log.Printf("voice: no workstation.token — mavgpud refuses an unauthenticated request, so this reads as a card that is always busy")
|
||||
}
|
||||
pair := llm.NewPair(
|
||||
llm.New(ws.URL, time.Duration(ws.Timeout)),
|
||||
remote,
|
||||
resident,
|
||||
ws.Health,
|
||||
time.Duration(ws.Probe),
|
||||
@@ -464,45 +469,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
llmR *router.LLMRouter, heads *router.RouterHeads) *router.Router {
|
||||
cls := router.NewClassifier(emb)
|
||||
seedClassifier(cls)
|
||||
grammars := router.DefaultGrammars(acts)
|
||||
grammars = append(grammars, router.SystemTimeDateGrammars()...)
|
||||
// After the time/date rules on purpose: "какой сегодня день" is a clock
|
||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||
// is an agenda question and must not.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||
// routed system and answered "пока не умею" (Vikunja #474).
|
||||
// After the agenda rules, which are the narrower claim, and BEFORE the feed
|
||||
// and list rules, which are not: "что такое лента" is a definition question
|
||||
// and the feed rule would take it on the noun alone (V-655).
|
||||
grammars = append(grammars, router.WorldQueryGrammars()...)
|
||||
grammars = append(grammars, router.FeedQueryGrammar())
|
||||
// The list side of the same exposure: a phrasing with no possessive in it
|
||||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||
grammars = append(grammars, router.TaskListGrammar())
|
||||
grammars = append(grammars, router.ListGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
// Before the capture marker, because "отметь" is a capture verb and "отметь
|
||||
// второй пункт" is not a note. The Praxis rules are the narrower claim — a
|
||||
// lifecycle verb AND an item named — so they get first refusal (Vikunja #516).
|
||||
grammars = append(grammars, router.PraxisGrammars()...)
|
||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||
// explicit capture marker beats the model, which called it an act and
|
||||
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||
// marker never collides with a clock or agenda question.
|
||||
// After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the
|
||||
// board noun), and before the capture marker, which would otherwise read
|
||||
// "убери из задач купить молоко" as a new task (Vikunja #512).
|
||||
grammars = append(grammars, router.TaskStatusGrammar())
|
||||
// Before the capture markers, which all need an object. A capture verb
|
||||
// alone is a fact with no key, and the clarify path asks for it rather than
|
||||
// letting the model invent an answer (Vikunja #557).
|
||||
grammars = append(grammars, router.BareCaptureGrammar()...)
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||
// last overall because it matches on the first word alone: "расскажи про
|
||||
// X" is a world question the model called a fact (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
// The stage 0 set, in the router package, so the eval fixture runs the rules
|
||||
// the daemon runs (V-693). Order and reasoning live with the list.
|
||||
grammars := router.StageZeroGrammars(acts)
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
Classifier: cls,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The boundary in front of the card.
|
||||
//
|
||||
// mavgpud has to listen on the LAN, because homesrv is the client and a
|
||||
// loopback default takes the model arm down. That makes this the one hop on the
|
||||
// workstation anything on the network could reach, and until 2026-08-11 it
|
||||
// reverse-proxied every path to llama-server unauthenticated: any client could
|
||||
// spend the card, hold the model resident by touching the idle clock, and read
|
||||
// /slots, which carries the prompts of whoever else was using it.
|
||||
//
|
||||
// So: a bearer token every request must carry, read from a file, and a path
|
||||
// allowlist so a token that leaks buys the model API and not the admin one. The
|
||||
// CW2 transcriber beside this daemon has worked this way since it shipped; this
|
||||
// is the same arrangement, not a new one.
|
||||
|
||||
// readToken loads the bearer token. The file holds the token and nothing else,
|
||||
// trailing newline allowed. A path that is set and unreadable is fatal to the
|
||||
// caller: a supervisor that silently ran without its boundary is the failure
|
||||
// this exists to prevent.
|
||||
func readToken(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token_file: %w", err)
|
||||
}
|
||||
tok := strings.TrimSpace(string(b))
|
||||
if tok == "" {
|
||||
return "", fmt.Errorf("token_file %s is empty", path)
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// loopbackListen reports whether addr can only be reached from this machine.
|
||||
// An empty or wildcard host is not loopback, which is the case that matters:
|
||||
// ":8080" is the shipped default and it answers the whole LAN.
|
||||
func loopbackListen(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// allowed is what a token buys. Everything llama-server exposes beyond this is
|
||||
// refused, because the endpoints Maven does not call are the expensive ones to
|
||||
// hand out: /slots returns other callers' prompts, and its save/restore actions
|
||||
// write files chosen by the request.
|
||||
//
|
||||
// Adding a caller means adding its path here. That is deliberate — the list is
|
||||
// short because Maven's use of the workstation is.
|
||||
var allowed = map[string]string{
|
||||
"/v1/chat/completions": http.MethodPost,
|
||||
"/v1/completions": http.MethodPost,
|
||||
"/v1/embeddings": http.MethodPost,
|
||||
"/v1/models": http.MethodGet,
|
||||
"/props": http.MethodGet,
|
||||
}
|
||||
|
||||
// requireToken authenticates, then bounds. Order matters: an unauthenticated
|
||||
// client must not be able to make this daemon allocate a body buffer.
|
||||
//
|
||||
// /health is not exempt. It reports whether the card is loaded and free, which
|
||||
// is exactly what someone deciding whether to take it from him would ask.
|
||||
func requireToken(token string, maxBody int64, next http.Handler) http.Handler {
|
||||
want := sha256.Sum256([]byte(token))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got := sha256.Sum256([]byte(bearer(r)))
|
||||
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// bearer pulls the credential out of the header. A malformed header yields the
|
||||
// empty string, which fails the comparison like any other wrong token — there
|
||||
// is no separate error for it, because telling a caller *how* it was wrong is
|
||||
// the only thing a probe learns from a 401.
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
|
||||
// allowlist refuses a path the model arm does not use. It answers 404 rather
|
||||
// than 403 so a scan cannot map llama-server's surface through this hop.
|
||||
func allowlist(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method, ok := allowed[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != method {
|
||||
w.Header().Set("Allow", method)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// limitInflight caps concurrent proxied requests. A waiter leaves when its own
|
||||
// context ends, so a client that gave up does not keep a slot: llama-server
|
||||
// runs with -np 1 and queueing here is cheaper than queueing inside the child
|
||||
// with a body held in memory on both sides.
|
||||
func limitInflight(n int, next http.Handler) http.Handler {
|
||||
if n <= 0 {
|
||||
return next
|
||||
}
|
||||
slots := make(chan struct{}, n)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
defer func() { <-slots }()
|
||||
next.ServeHTTP(w, r)
|
||||
case <-r.Context().Done():
|
||||
http.Error(w, "client went away", http.StatusServiceUnavailable)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ok is what the boundary is protecting: anything that reaches it has spent
|
||||
// the card.
|
||||
func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }
|
||||
|
||||
func TestRequireTokenRefusesEveryWrongCredential(t *testing.T) {
|
||||
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
|
||||
cases := []struct {
|
||||
name string
|
||||
auth string
|
||||
want int
|
||||
}{
|
||||
{"no header", "", http.StatusUnauthorized},
|
||||
{"wrong token", "Bearer wrong", http.StatusUnauthorized},
|
||||
{"prefix of the token", "Bearer s3cre", http.StatusUnauthorized},
|
||||
{"token with no scheme", "s3cret", http.StatusUnauthorized},
|
||||
{"basic auth", "Basic czNjcmV0", http.StatusUnauthorized},
|
||||
{"right token", "Bearer s3cret", http.StatusTeapot},
|
||||
{"scheme is case-insensitive", "bearer s3cret", http.StatusTeapot},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
if tc.auth != "" {
|
||||
r.Header.Set("Authorization", tc.auth)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != tc.want {
|
||||
t.Errorf("status %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The 401 must not say which part was wrong. A probe that can tell a malformed
|
||||
// header from a wrong token learns the header shape for free.
|
||||
func TestUnauthorizedSaysNothingUseful(t *testing.T) {
|
||||
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if got := strings.TrimSpace(w.Body.String()); got != "unauthorized" {
|
||||
t.Errorf("body %q, want %q", got, "unauthorized")
|
||||
}
|
||||
if got := w.Header().Get("WWW-Authenticate"); got != "Bearer" {
|
||||
t.Errorf("WWW-Authenticate %q, want Bearer", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The body cap applies to an authenticated request. An unauthenticated one
|
||||
// never gets far enough to allocate anything.
|
||||
func TestRequireTokenCapsTheBody(t *testing.T) {
|
||||
var read error
|
||||
h := requireToken("t", 8, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buf := make([]byte, 64)
|
||||
for read == nil {
|
||||
if _, read = r.Body.Read(buf); read != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(strings.Repeat("x", 4096)))
|
||||
r.Header.Set("Authorization", "Bearer t")
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
if read == nil || !strings.Contains(read.Error(), "too large") {
|
||||
t.Errorf("read error %v, want the body cap", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistRefusesWhatMavenDoesNotCall(t *testing.T) {
|
||||
h := allowlist(http.HandlerFunc(ok))
|
||||
cases := []struct {
|
||||
method, path string
|
||||
want int
|
||||
}{
|
||||
{http.MethodPost, "/v1/chat/completions", http.StatusTeapot},
|
||||
{http.MethodGet, "/v1/models", http.StatusTeapot},
|
||||
// /slots returns the prompts of whoever else is using the card, and
|
||||
// its actions write files the request names.
|
||||
{http.MethodGet, "/slots", http.StatusNotFound},
|
||||
{http.MethodPost, "/slots/0?action=save", http.StatusNotFound},
|
||||
{http.MethodGet, "/", http.StatusNotFound},
|
||||
{http.MethodGet, "/v1/chat/completions", http.StatusMethodNotAllowed},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
|
||||
if w.Code != tc.want {
|
||||
t.Errorf("status %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitInflightCapsConcurrency(t *testing.T) {
|
||||
const cap = 2
|
||||
var mu sync.Mutex
|
||||
now, peak := 0, 0
|
||||
release := make(chan struct{})
|
||||
h := limitInflight(cap, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
now++
|
||||
if now > peak {
|
||||
peak = now
|
||||
}
|
||||
mu.Unlock()
|
||||
<-release
|
||||
mu.Lock()
|
||||
now--
|
||||
mu.Unlock()
|
||||
}))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil))
|
||||
}()
|
||||
}
|
||||
// Let the first wave arrive, then drain. The assertion is the peak, and a
|
||||
// peak that never reached the cap still cannot exceed it.
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if peak > cap {
|
||||
t.Errorf("%d requests in flight at once, cap is %d", peak, cap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackListen(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
":8080": false, // the shipped default, and the whole LAN
|
||||
"0.0.0.0:8080": false,
|
||||
"[::]:8080": false,
|
||||
"192.168.1.105:8080": false,
|
||||
"127.0.0.1:8080": true,
|
||||
"[::1]:8080": true,
|
||||
"localhost:8080": true,
|
||||
}
|
||||
for addr, want := range cases {
|
||||
if got := loopbackListen(addr); got != want {
|
||||
t.Errorf("loopbackListen(%q) = %v, want %v", addr, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
good := filepath.Join(dir, "tok")
|
||||
if err := os.WriteFile(good, []byte(" abc123\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readToken(good)
|
||||
if err != nil || got != "abc123" {
|
||||
t.Errorf("readToken = %q, %v; want abc123", got, err)
|
||||
}
|
||||
|
||||
blank := filepath.Join(dir, "blank")
|
||||
if err := os.WriteFile(blank, []byte("\n\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readToken(blank); err == nil {
|
||||
t.Error("an empty token file is not a token")
|
||||
}
|
||||
if _, err := readToken(filepath.Join(dir, "absent")); err == nil {
|
||||
t.Error("a missing token file is not a token")
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,11 @@ func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
|
||||
s := &supervisor{run: newRunner("fake", "/bin/true", nil, "")}
|
||||
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
|
||||
|
||||
for _, path := range []string{"/health", "/v1/chat/completions"} {
|
||||
// The completion is a POST because the allowlist is in front of the
|
||||
// readiness check now, and it answers 405 to a method it never serves.
|
||||
for path, method := range map[string]string{"/health": http.MethodGet, "/v1/chat/completions": http.MethodPost} {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
h.ServeHTTP(w, httptest.NewRequest(method, path, nil))
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s with no model: got %d, want 503", path, w.Code)
|
||||
}
|
||||
|
||||
+47
-3
@@ -36,6 +36,21 @@ type config struct {
|
||||
Listen string `json:"listen"` // what Maven talks to
|
||||
LlamaAddr string `json:"llama_addr"` // where llama-server binds
|
||||
LlamaBin string `json:"llama_bin"`
|
||||
|
||||
// TokenFile holds the bearer token every request must carry. It is a path
|
||||
// and never the token itself, the rule mavpoll, mavmaild and the CW2
|
||||
// transcriber already follow: a secret in a committed config is a secret
|
||||
// in the history. Empty is allowed only on a loopback Listen, and
|
||||
// requireToken is where that is decided.
|
||||
TokenFile string `json:"token_file,omitempty"`
|
||||
|
||||
// MaxBody bounds a proxied request body. A completion is a prompt, and a
|
||||
// prompt that does not fit here would not fit the context window either.
|
||||
MaxBody int64 `json:"max_body_bytes,omitempty"`
|
||||
// MaxInflight bounds how many proxied requests reach llama-server at once.
|
||||
// It runs with -np 1, so anything above a handful only queues inside the
|
||||
// child while holding a connection and a body in memory here.
|
||||
MaxInflight int `json:"max_inflight,omitempty"`
|
||||
// LlamaArgs must include the flags that bind LlamaAddr. They are passed
|
||||
// through untouched so the model, context size and layer count stay the
|
||||
// owner's business and not this daemon's schema.
|
||||
@@ -88,6 +103,8 @@ func defaults() config {
|
||||
MinFreeVRAM: 15 << 30,
|
||||
EvictAfter: 2,
|
||||
StartAfter: 5,
|
||||
MaxBody: 8 << 20,
|
||||
MaxInflight: 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +140,20 @@ func main() {
|
||||
log.Fatal("mavgpud: llama_bin is required")
|
||||
}
|
||||
|
||||
// A LAN listener with no token is refused rather than downgraded to
|
||||
// loopback. Downgrading would look like a safe default and would take the
|
||||
// model arm down instead: homesrv is the client and it is on the LAN.
|
||||
var token string
|
||||
if cfg.TokenFile != "" {
|
||||
var err error
|
||||
if token, err = readToken(cfg.TokenFile); err != nil {
|
||||
log.Fatalf("mavgpud: %v", err)
|
||||
}
|
||||
} else if !loopbackListen(cfg.Listen) {
|
||||
log.Fatalf("mavgpud: listen %s is reachable from the network and token_file is unset — "+
|
||||
"set token_file, or listen on 127.0.0.1 and accept that Maven cannot reach it", cfg.Listen)
|
||||
}
|
||||
|
||||
base := "http://" + cfg.LlamaAddr
|
||||
run := newRunner("llama-server", cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
|
||||
sup := &supervisor{
|
||||
@@ -145,7 +176,20 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("mavgpud: llama_addr: %v", err)
|
||||
}
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: sup.handler(target)}
|
||||
var h http.Handler = sup.handler(target)
|
||||
if token != "" {
|
||||
h = requireToken(token, cfg.MaxBody, h)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: h,
|
||||
// A slow-loris client holds a connection and a header buffer for free
|
||||
// otherwise. No ReadTimeout or WriteTimeout: a completion legitimately
|
||||
// takes minutes on this card, and either one would cut it off.
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 1 << 16,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("mavgpud: listening on %s, model %s", cfg.Listen, cfg.LlamaBin)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
@@ -203,14 +247,14 @@ func (s *supervisor) handler(target *url.URL) http.Handler {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("/", allowlist(limitInflight(s.cfg.MaxInflight, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.run.isReady() {
|
||||
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
s.touch()
|
||||
proxy.ServeHTTP(w, r)
|
||||
})
|
||||
}))))
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
],
|
||||
"workstation": {
|
||||
"url": "http://192.168.1.105:8080",
|
||||
"token": "${MAVEN_GPU_TOKEN}",
|
||||
"probe": "15s",
|
||||
"timeout": "90s",
|
||||
"stt": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
{
|
||||
"listen": ":8080",
|
||||
"//token_file": [
|
||||
"The bearer token every request must carry. homesrv is the client and it",
|
||||
"is on the LAN, so this port cannot be loopback and the token is what",
|
||||
"stops anything else on the network spending the card or reading /slots.",
|
||||
"A path, never the token: mavgpud refuses to start when listen is",
|
||||
"reachable from the network and this is unset.",
|
||||
"Same value as MAVEN_GPU_TOKEN in homesrv's deploy/telegram.env."
|
||||
],
|
||||
"token_file": "/home/kami/.config/mavgpud.token",
|
||||
"llama_addr": "127.0.0.1:10000",
|
||||
"llama_bin": "llama-server",
|
||||
"//llama_args": [
|
||||
|
||||
@@ -10,3 +10,12 @@ TELEGRAM_CHAT_ID=
|
||||
# ntfy token add --expires=never maven
|
||||
# Read access is not needed — mavend publishes and never subscribes.
|
||||
NTFY_TOKEN=
|
||||
|
||||
# Bearer token for mavgpud, the workstation's GPU supervisor (V-673). It fronts
|
||||
# the big model on a LAN port, so the token is the whole boundary in front of
|
||||
# the card. Any long random string; mint one with:
|
||||
# openssl rand -hex 32
|
||||
# The same value goes in a file on workpc, named by token_file in
|
||||
# deploy/mavgpud.json. Unset here and every workstation turn falls back to the
|
||||
# resident model, because mavgpud answers 401 and Maven reads that as down.
|
||||
MAVEN_GPU_TOKEN=
|
||||
|
||||
@@ -21,12 +21,16 @@ caveat is the pointer between them plus the trigger.
|
||||
## Index
|
||||
|
||||
Every entry below came from the 2026-08-10 deep audit
|
||||
(`docs/evals/2026-08-10-repo-audit.md`). One of the twenty findings, the
|
||||
unauthenticated mavgpud proxy, was fixed as V-673 and has no entry.
|
||||
(`docs/evals/2026-08-10-repo-audit.md`), except the last, which came from wiring
|
||||
the gate the audit asked for. Five of the twenty findings are fixed and have no
|
||||
entry. The unauthenticated mavgpud proxy was V-673. The 20 reachable advisories
|
||||
in the toolchain and `x/text` were V-682. The missing analyzers were V-694, and
|
||||
what they now report is the baseline entry under V-701. The two unguarded
|
||||
invariants were V-692 and V-693, and their guards are described in
|
||||
`docs/routing.md`.
|
||||
|
||||
| limit | severity |
|
||||
| --- | --- |
|
||||
| [Go 1.25.5 and x/text 0.14.0 carry 20 reachable advisories](dependencies.md#toolchain) | high |
|
||||
| [Anyone past the proxy can enroll a passkey](security.md#enrollment) | high |
|
||||
| [Passkey credentials are rewritten in place](security.md#credentials) | medium |
|
||||
| [An empty STT transcript reads as a successful one](external-inputs.md#stt) | medium |
|
||||
@@ -39,9 +43,8 @@ unauthenticated mavgpud proxy, was fixed as V-673 and has no entry.
|
||||
| [mavweb errors cannot be traced](transport.md#errors) | medium |
|
||||
| [Fact enrichment is a 20-call serial waterfall](workers.md#enrichment) | medium |
|
||||
| [A suppressed nudge is phrased anyway](workers.md#nudges) | medium |
|
||||
| [heads_path may equal model_path](invariants.md#heads) | medium |
|
||||
| [baselineGrammars is mirrored by hand](invariants.md#grammars) | medium |
|
||||
| [Committed absolute paths pin the build to this box](config.md#paths) | medium |
|
||||
| [The env example omits deployed variables](config.md#secrets) | medium |
|
||||
| [The analyzers pass against a baseline, not zero](dependencies.md#baseline) | medium |
|
||||
| [Domain packages depend on store and IPC types](layering.md#dtos) | low |
|
||||
| [Eleven symbols are unreachable](layering.md#deadcode) | low |
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
# Dependencies
|
||||
|
||||
## Go 1.25.5 and x/text 0.14.0 carry 20 reachable advisories [#682] {#toolchain}
|
||||
## The analyzers pass against a baseline, not against zero [#701] {#baseline}
|
||||
|
||||
Costs: `govulncheck` found 20 reachable advisories, one in `x/text` and 19 in
|
||||
the standard library. They include template XSS, parser denial of service and
|
||||
TLS issues. Reachable traces run through the ONNX embedder's normalization,
|
||||
mavweb's HTML rendering, email header decoding and the mavgpud proxy. The
|
||||
vendored toolchain was built 2025-11-26.
|
||||
Revisit when: now. This is the highest-severity open entry and the fix is
|
||||
mechanical, so it ages badly for no reason.
|
||||
Workaround: none.
|
||||
|
||||
None of `staticcheck`, `govulncheck` or `deadcode` is installed on this box or
|
||||
wired into a make target. `make audit` is a git-grep inventory over loc, todo,
|
||||
stubs, docs, tests and gaps. **Do not read it as a static-analysis gate.** That
|
||||
gate is part of this entry.
|
||||
Costs: `make lint` and `make deadcode` are wired and green (V-694), but green
|
||||
means "nothing new since 2026-08-11". The accepted set is 19 staticcheck
|
||||
findings and 13 unreachable symbols, listed with a reason each in
|
||||
`scripts/analyzers/*.baseline`. Three of the unreachable symbols must stay:
|
||||
[layering.md](layering.md#deadcode). One accepted staticcheck finding is V-687.
|
||||
Revisit when: V-701 sweeps the baseline, or a fix deletes an entry. The gate
|
||||
fails on an entry whose finding is gone, so the deletion is not optional.
|
||||
Workaround: none needed. Reachability claims are checkable now. Read the
|
||||
baseline before trusting that a target reporting clean means the tree is clean.
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Unguarded invariants
|
||||
|
||||
`CLAUDE.md` names these as load-bearing. Nothing enforces either one. A rule
|
||||
that lives only in prose gets broken by whoever did not read the prose. Both of
|
||||
these fail silently when broken.
|
||||
|
||||
`tokenizerRev` and `preRouteLadder` were checked and need nothing. The rev is
|
||||
baked into the embedder key, so a bump triggers re-embedding. A missing ladder
|
||||
rung is observable in the decision record.
|
||||
|
||||
## heads_path may equal model_path [#692] {#heads}
|
||||
|
||||
Costs: the routing heads then score with the same graph the resident e5-small
|
||||
uses, and recall degrades. There is no error and no log line, so it reads as
|
||||
ordinary drift rather than a misconfiguration.
|
||||
Revisit when: `deploy/mavend.json` is edited by hand, or a fine-tuned heads
|
||||
graph is swapped in.
|
||||
Workaround: check the two keys by eye. That is the whole guard today.
|
||||
|
||||
## baselineGrammars is mirrored by hand [#693] {#grammars}
|
||||
|
||||
Costs: the eval fixture restates the stage 0 rule set in the daemon's order,
|
||||
and its own comment says so. Three test files score against it. A grammar added
|
||||
to `buildRouter` alone means every routing measurement scores a set nobody
|
||||
runs. `CLAUDE.md` warns about this failure by name.
|
||||
Revisit when: the next stage 0 grammar is added. That is when it bites.
|
||||
Workaround: add to both lists, which is what the rule already says.
|
||||
+1
-1
@@ -102,7 +102,7 @@ Text-to-speech has not moved. piper on homesrv is the only synthesizer.
|
||||
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. |
|
||||
| `mavcaldav` | CalDAV calendar sync. |
|
||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. |
|
||||
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. |
|
||||
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. Its LAN port needs a bearer token in the file named by `token_file`, matching `MAVEN_GPU_TOKEN` on homesrv, or it refuses to start (V-673). |
|
||||
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
|
||||
|
||||
Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts
|
||||
|
||||
@@ -105,6 +105,19 @@ how we find out whether the blind spot is real.
|
||||
untouched. The model, the context size, the layer count and the MTP flags are the
|
||||
owner's business and not this daemon's schema.
|
||||
|
||||
**The port carries a bearer token and cannot be loopback** (V-673). homesrv is
|
||||
the client, so this hop is on the LAN. Until 2026-08-11 anything on the network
|
||||
could spend the card, hold the model resident by touching the idle clock, and
|
||||
read `/slots`, which returns other callers' prompts. mavgpud now reads
|
||||
`token_file` and refuses to start when the listen address is reachable from the
|
||||
network without one. Downgrading to loopback instead would look safe and take
|
||||
the model arm down. Maven sends the same token from `workstation.token`, on the
|
||||
completion and on the `/health` probe alike. An unsigned probe answers 401,
|
||||
which Pair reads as a busy card, so a missing token degrades to the resident
|
||||
model rather than breaking a turn. The proxy also
|
||||
allowlists the five paths Maven calls, so a leaked token buys the model API and
|
||||
not llama-server's admin surface.
|
||||
|
||||
**Every GPU service on that box belongs under this supervisor**, added to
|
||||
`cmd/mavgpud` rather than to systemd beside it. The rule was learned on
|
||||
2026-08-09. The CW2 transcriber ran as its own user unit and registered on the
|
||||
|
||||
+17
-5
@@ -1,6 +1,6 @@
|
||||
# Routing
|
||||
|
||||
*Last verified: 2026-08-09 @ 31b5093*
|
||||
*Last verified: 2026-08-11 @ 25ed201*
|
||||
|
||||
How an utterance becomes a `Decision`, why each stage exists, and what every
|
||||
stage has measured. `CLAUDE.md` carries the rules an agent must not break. This
|
||||
@@ -137,10 +137,15 @@ below.
|
||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter. A pattern needs
|
||||
an explicit `(\s|[?!.]|$)`.
|
||||
|
||||
`baselineGrammars` in `eval_test.go` mirrors `buildRouter` and has drifted before.
|
||||
`WorldQueryGrammars` was wired into the daemon by V-655 and not into the mirror,
|
||||
so the fixture scored a grammar set nobody runs. Fixed by V-659, worth 3 points
|
||||
of destination.
|
||||
The stage 0 set lives in `router.StageZeroGrammars` (`internal/router/stagezero.go`).
|
||||
Both `buildRouter` and the eval fixture call it. The daemon and the measurement
|
||||
cannot disagree about which rules exist, or in what order.
|
||||
|
||||
It was two lists until V-693 and it drifted twice. V-655 wired
|
||||
`WorldQueryGrammars` into the daemon and not into the fixture. That cost 3 points
|
||||
of destination and V-659 fixed it. `BareCaptureGrammar` then did the same thing,
|
||||
from V-557 until V-693 found it. That one moved no number, which is the point:
|
||||
the fixture had been scoring a set nobody ran and nothing said so.
|
||||
|
||||
### Praxis lifecycle rules
|
||||
|
||||
@@ -270,6 +275,13 @@ Three rules around it, each measured:
|
||||
means the heads are nil. The cascade is then byte-for-byte what shipped before
|
||||
them.
|
||||
|
||||
Pointing it at `model_path` is refused at config load (V-692). An unloadable
|
||||
weights file is not fatal, because the heads are an accelerator. A working file
|
||||
in the wrong role is a different thing. The heads then score with the graph the
|
||||
resident embedder scored with, and recall degrades with no log line. The check
|
||||
cleans and absolutises both paths, then compares them with `os.SameFile`, so a
|
||||
symlinked copy is caught too.
|
||||
|
||||
### The tokenizer bug the heads found
|
||||
|
||||
`encodeWord` in `onnxembedder.go` read every long word backwards until 2026-08-08.
|
||||
|
||||
+38
-1
@@ -1,6 +1,6 @@
|
||||
# Session workflow: the five stores and the guards
|
||||
|
||||
*Last verified: 2026-08-09 @ a9b480a*
|
||||
*Last verified: 2026-08-11 @ 557f5a3*
|
||||
|
||||
How a session starts, where each kind of writing belongs, and what the hooks
|
||||
refuse. `CLAUDE.md` carries the commands. This file carries the reasoning.
|
||||
@@ -78,3 +78,40 @@ blocks further edits past 600 changed lines on a `task/` branch.
|
||||
budget read high.
|
||||
|
||||
`--no-verify` exists. Using it means saying why in the commit body.
|
||||
|
||||
## Static gates
|
||||
|
||||
Three analyzers, one target each, and `make analyze` for all three. The
|
||||
2026-08-10 audit asked for them because none was installed on the box and
|
||||
`make audit` is a git-grep inventory, not analysis. Do not read `make audit` as
|
||||
a gate.
|
||||
|
||||
- `make vuln`, govulncheck over `./...` (V-682).
|
||||
- `make lint`, staticcheck over `./...` (V-694).
|
||||
- `make deadcode`, deadcode with `-test` over `./...` (V-694).
|
||||
|
||||
None of the three joins `make test`. All three install over the network, and
|
||||
`test` has to pass on a box with no route out. `vuln` reads the advisory
|
||||
database at run time as well. Run `make analyze` before a dependency or
|
||||
toolchain bump lands, and before calling a symbol unreachable.
|
||||
|
||||
Each tool is pinned in the Makefile beside `GO_VERSION`. A gate that moves on
|
||||
its own is not a gate. Each installs into `deps/bin`, because a tool is not a
|
||||
dependency of the module.
|
||||
|
||||
**staticcheck and deadcode pass against a baseline, not against zero.** The
|
||||
accepted findings live in `scripts/analyzers/*.baseline`, one line each. A key
|
||||
holds file, check id and message, never a line number. A line number goes stale
|
||||
on the next edit above it. The output then reports moved findings as new ones,
|
||||
and the reader learns to skip it.
|
||||
|
||||
`scripts/analyzer-gate.sh` gives the verdict. A finding absent from the baseline
|
||||
fails. So does a baseline entry whose finding is gone, which is what stops the
|
||||
accepted set from outliving the repo. Deleting the entry is part of each fix.
|
||||
|
||||
`deadcode` runs with `-test` because a test is a caller. Without the flag the
|
||||
report is 172 lines, most of `internal/router/eval`, none of it a mistake.
|
||||
|
||||
A baseline entry carries the reason it stays. Three reasons appear. Another task
|
||||
owns the finding. The check cannot see through a false positive. A cosmetic
|
||||
finding waits for a sweep.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/kami/maven
|
||||
|
||||
go 1.25.5
|
||||
go 1.25.12
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.12
|
||||
@@ -22,7 +22,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/text v0.14.0
|
||||
golang.org/x/text v0.40.0
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -25,13 +25,13 @@ github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV
|
||||
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
|
||||
@@ -2,6 +2,9 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -141,10 +144,55 @@ func (c *Config) validateVoice() error {
|
||||
if e.ModelPath == "" || e.TokenizerPath == "" || e.LibPath == "" {
|
||||
return errors.New("voice.embedder: all three of model_path, tokenizer_path, lib_path must be set, or remove embedder to use the floor stub")
|
||||
}
|
||||
if err := e.checkHeadsDistinct(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkHeadsDistinct refuses a heads graph that is the embedder's own file
|
||||
// (V-692). The rule is stated on HeadsPath above and in CLAUDE.md, and until
|
||||
// now nothing enforced it: the daemon loaded whatever the key pointed at, so
|
||||
// pointing both keys at one file cost recall with no error and no log line. It
|
||||
// reads as ordinary drift, which is the worst kind of misconfiguration.
|
||||
//
|
||||
// Refusing to start is the right trade here. The heads are an accelerator and a
|
||||
// broken weights file is deliberately not fatal in voicewire.go, but this is not
|
||||
// a broken file. It is a working file in the wrong role, and a daemon that
|
||||
// cannot route well should say so rather than answer worse.
|
||||
//
|
||||
// Cleaned and made absolute first, so "./m.onnx" and "$PWD/m.onnx" are one
|
||||
// path. Then SameFile, which catches the copy that is a symlink or a hard link
|
||||
// to the original. A path that does not stat is left to the loader, which fails
|
||||
// on it with a better message than this can give.
|
||||
func (e *EmbedderConfig) checkHeadsDistinct() error {
|
||||
if e.HeadsPath == "" || e.ModelPath == "" {
|
||||
return nil
|
||||
}
|
||||
heads, model := absClean(e.HeadsPath), absClean(e.ModelPath)
|
||||
same := heads == model
|
||||
if !same {
|
||||
hi, herr := os.Stat(heads)
|
||||
mi, merr := os.Stat(model)
|
||||
same = herr == nil && merr == nil && os.SameFile(hi, mi)
|
||||
}
|
||||
if same {
|
||||
return fmt.Errorf("voice.embedder: heads_path and model_path are the same file (%s) — the heads graph is a fine-tuned copy, and scoring recall with it degrades what the resident embedder already stored", heads)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// absClean — the comparable form of a path. Abs fails only when the working
|
||||
// directory is unreadable, and a cleaned relative path is still worth comparing,
|
||||
// so the error falls back rather than propagating.
|
||||
func absClean(p string) string {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
return abs
|
||||
}
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
|
||||
// VoiceConfig — the client↔core TCP surface + the stt/tts worker-module
|
||||
// seams.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The heads graph is a fine-tuned copy of the embedder, and pointing both keys
|
||||
// at one file degrades recall with no error and no log line (V-692). These are
|
||||
// the shapes that used to boot clean.
|
||||
func TestHeadsPathMustNotBeTheModelFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
model := filepath.Join(dir, "model.onnx")
|
||||
heads := filepath.Join(dir, "heads.onnx")
|
||||
for _, p := range []string{model, heads} {
|
||||
if err := os.WriteFile(p, []byte("onnx"), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
link := filepath.Join(dir, "link.onnx")
|
||||
if err := os.Symlink(model, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
// A path that stats and one that does not, because the guard compares the
|
||||
// cleaned string before it stats anything.
|
||||
for name, headsPath := range map[string]string{
|
||||
"the same path": model,
|
||||
"a symlink to it": link,
|
||||
"an uncleaned path": filepath.Join(dir, ".", "sub", "..", "model.onnx"),
|
||||
"a path on no disk": filepath.Join(dir, "absent.onnx"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
same := headsPath != filepath.Join(dir, "absent.onnx")
|
||||
err := voiceConfigWith(t, model, headsPath)
|
||||
if same && err == nil {
|
||||
t.Fatal("want a startup error, got a daemon that routes worse in silence")
|
||||
}
|
||||
if same && !strings.Contains(err.Error(), "heads_path") {
|
||||
t.Fatalf("the error does not name the key: %v", err)
|
||||
}
|
||||
if !same && err != nil {
|
||||
t.Fatalf("a distinct heads_path was refused: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := voiceConfigWith(t, model, heads); err != nil {
|
||||
t.Fatalf("two distinct files were refused: %v", err)
|
||||
}
|
||||
if err := voiceConfigWith(t, model, ""); err != nil {
|
||||
t.Fatalf("no heads at all was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// voiceConfigWith loads a minimal enabled voice block through the real Load, so
|
||||
// the test exercises the startup path and not just the check in isolation.
|
||||
func voiceConfigWith(t *testing.T, model, heads string) error {
|
||||
t.Helper()
|
||||
body := `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{` +
|
||||
`"model_path":"` + model + `","tokenizer_path":"/t.json","lib_path":"/l.so"`
|
||||
if heads != "" {
|
||||
body += `,"heads_path":"` + heads + `"`
|
||||
}
|
||||
body += `}}}`
|
||||
_, err := Load(writeConfig(t, body))
|
||||
return err
|
||||
}
|
||||
@@ -27,6 +27,13 @@ type WorkstationConfig struct {
|
||||
// signal, so it must be the supervisor's endpoint and not llama-server's.
|
||||
Health string `json:"health,omitempty"`
|
||||
|
||||
// Token — the bearer credential mavgpud requires, expanded from the
|
||||
// environment like every other secret here. It is what stops anything on
|
||||
// the LAN spending the card, so a URL that is not loopback needs one.
|
||||
// Wrong or missing reads as a workstation that is down, and Maven falls
|
||||
// back to the resident model.
|
||||
Token string `json:"token,omitempty"`
|
||||
|
||||
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||
// Nothing on the hot path waits for it: the answer is cached and read
|
||||
// atomically, so this only sets how late Maven notices the card came back.
|
||||
|
||||
@@ -51,6 +51,10 @@ type Client struct {
|
||||
base string
|
||||
swap SwapGate
|
||||
http *http.Client
|
||||
// token — the bearer credential for a server that asks for one. Empty for
|
||||
// the resident model, which is reached over loopback on the same box.
|
||||
// mavgpud on the workstation requires it: that hop is on the LAN.
|
||||
token string
|
||||
|
||||
// gate / background — priority on the single llama-server slot. Set once
|
||||
// at wiring time (SetGate), read on every request. nil gate ⇒ no gating,
|
||||
@@ -101,6 +105,27 @@ func New(baseURL string, timeout time.Duration) *Client {
|
||||
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
|
||||
}
|
||||
|
||||
// SetToken installs the bearer credential this client sends. Wiring-time, like
|
||||
// SetGate: an empty token means the server is not asking for one.
|
||||
func (c *Client) SetToken(t string) {
|
||||
c.mu.Lock()
|
||||
c.token = t
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// authorize adds the credential when there is one. Exported to the package so
|
||||
// the Pair prober signs /health with the same token as the completion — a
|
||||
// probe that answers 401 would otherwise read as a workstation that is down,
|
||||
// and Maven would fall back forever without saying why.
|
||||
func (c *Client) authorize(req *http.Request) {
|
||||
c.mu.RLock()
|
||||
t := c.token
|
||||
c.mu.RUnlock()
|
||||
if t != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+t)
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL re-points the client at another llama-server. Safe to call while
|
||||
// requests are in flight: a request that already read the old base finishes
|
||||
// against the old base (or fails, and every caller of Complete has a fallback),
|
||||
@@ -196,6 +221,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.authorize(req)
|
||||
httpResp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -132,6 +132,9 @@ func (p *Pair) probe(ctx context.Context) {
|
||||
p.set(false)
|
||||
return
|
||||
}
|
||||
if p.remote != nil {
|
||||
p.remote.authorize(req)
|
||||
}
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
p.set(false)
|
||||
|
||||
@@ -250,3 +250,35 @@ func TestNoFloorIsAnError(t *testing.T) {
|
||||
t.Fatalf("err = %v, want ErrNoFloor", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The workstation is behind mavgpud, which requires a bearer token on the
|
||||
// completion and on /health alike. A probe that did not carry it would answer
|
||||
// 401, Pair would read that as a card that is busy, and every turn would fall
|
||||
// back to the resident model with nothing in the log naming why.
|
||||
func TestPairSignsTheProbeAndTheCompletion(t *testing.T) {
|
||||
seen := make(chan string, 2)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen <- r.Header.Get("Authorization")
|
||||
if r.URL.Path == "/health" {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
remote := New(srv.URL, time.Second)
|
||||
remote.SetToken("s3cret")
|
||||
p := NewPair(remote, New(srv.URL, time.Second), srv.URL+"/health", time.Hour)
|
||||
p.probe(context.Background())
|
||||
if !p.Available() {
|
||||
t.Fatal("the probe did not admit an answering workstation")
|
||||
}
|
||||
if _, err := p.Complete(context.Background(), Req{User: "привет"}); err != nil {
|
||||
t.Fatalf("complete: %v", err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if got := <-seen; got != "Bearer s3cret" {
|
||||
t.Errorf("request %d carried %q, want the bearer token", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,35 +255,12 @@ func newBaselineClassifier(t *testing.T, emb router.Embedder) *router.Classifier
|
||||
return cls
|
||||
}
|
||||
|
||||
// baselineGrammars — the stage-0 rule set in the daemon's order (buildRouter in
|
||||
// cmd/mavend/voicewire.go). Split out of newBaselineRouter so the claim
|
||||
// measurement can run the same rules one at a time and see which of them
|
||||
// contend for the same utterance, which the cascade hides by stopping at the
|
||||
// first match.
|
||||
// baselineGrammars — the stage 0 rule set the daemon runs, from the one place
|
||||
// it is written down (V-693). It used to restate the list by hand, and by the
|
||||
// time the guard was written the two had already drifted by one grammar.
|
||||
// Kept as a name because the claim measurement reads it as "the baseline set".
|
||||
func baselineGrammars(acts router.ActMatcher) []router.Grammar {
|
||||
grammars := router.DefaultGrammars(acts)
|
||||
grammars = append(grammars, router.SystemTimeDateGrammars()...)
|
||||
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||||
// anything while its grammar set is the daemon's grammar set.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
// After the agenda rules and before the feed and list rules, same as
|
||||
// voicewire.go: "что такое лента" is a definition question and the feed
|
||||
// rule would claim it on the noun alone (V-655). Missing here until V-659,
|
||||
// so the fixture was scoring a grammar set the daemon does not run.
|
||||
grammars = append(grammars, router.WorldQueryGrammars()...)
|
||||
grammars = append(grammars, router.FeedQueryGrammar())
|
||||
// The list side of the same exposure: a phrasing with no possessive in it
|
||||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||
grammars = append(grammars, router.TaskListGrammar())
|
||||
grammars = append(grammars, router.ListGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
grammars = append(grammars, router.PraxisGrammars()...)
|
||||
grammars = append(grammars, router.TaskStatusGrammar())
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
// "расскажи про X" is a world question the model called a fact, and the
|
||||
// rule goes last because it matches on the first word alone (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
return grammars
|
||||
return router.StageZeroGrammars(acts)
|
||||
}
|
||||
|
||||
// seedOrder — fixed iteration order over the corpus. Not cosmetic: a few
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package router
|
||||
|
||||
// StageZeroGrammars — the stage 0 rule set, in the order the daemon runs it.
|
||||
//
|
||||
// It lives here because it used to live in two places (V-693). `buildRouter` in
|
||||
// cmd/mavend/voicewire.go held the real set and `baselineGrammars` in
|
||||
// internal/router/eval/eval_test.go restated it by hand, in the daemon's order,
|
||||
// with its own comment saying so. Three test files score against the fixture,
|
||||
// and nothing compared the two lists. By 2026-08-11 they had already drifted:
|
||||
// BareCaptureGrammar was in the daemon and not in the fixture, so every routing
|
||||
// measurement scored a set nobody ran. That is the failure CLAUDE.md warned
|
||||
// about by name, and a diff test would have caught it one grammar late. One
|
||||
// list cannot drift from itself.
|
||||
//
|
||||
// The order is the contract, not the membership. Each rule below says why it
|
||||
// sits where it sits, and a rule inserted in the wrong place changes which
|
||||
// utterances the cascade never reaches. Read the comment above a line before
|
||||
// moving it, and read docs/routing.md before adding one.
|
||||
//
|
||||
// The classifier, the extractor, the threshold and the model arm are the
|
||||
// daemon's to assemble. This function returns the rules and nothing else, so
|
||||
// the fixture can also run them one at a time and see which of them contend for
|
||||
// the same utterance, which the cascade hides by stopping at the first match.
|
||||
func StageZeroGrammars(acts ActMatcher) []Grammar {
|
||||
grammars := DefaultGrammars(acts)
|
||||
grammars = append(grammars, SystemTimeDateGrammars()...)
|
||||
// After the time/date rules on purpose: "какой сегодня день" is a clock
|
||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||
// is an agenda question and must not.
|
||||
grammars = append(grammars, AgendaQueryGrammars()...)
|
||||
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||
// routed system and answered "пока не умею" (Vikunja #474).
|
||||
// After the agenda rules, which are the narrower claim, and BEFORE the feed
|
||||
// and list rules, which are not: "что такое лента" is a definition question
|
||||
// and the feed rule would take it on the noun alone (V-655).
|
||||
grammars = append(grammars, WorldQueryGrammars()...)
|
||||
grammars = append(grammars, FeedQueryGrammar())
|
||||
// The list side of the same exposure: a phrasing with no possessive in it
|
||||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||
grammars = append(grammars, TaskListGrammar())
|
||||
grammars = append(grammars, ListGrammars()...)
|
||||
grammars = append(grammars, ReminderGrammar())
|
||||
// Before the capture marker, because "отметь" is a capture verb and "отметь
|
||||
// второй пункт" is not a note. The Praxis rules are the narrower claim — a
|
||||
// lifecycle verb AND an item named — so they get first refusal (Vikunja #516).
|
||||
grammars = append(grammars, PraxisGrammars()...)
|
||||
// After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the
|
||||
// board noun), and before the capture marker, which would otherwise read
|
||||
// "убери из задач купить молоко" as a new task (Vikunja #512).
|
||||
grammars = append(grammars, TaskStatusGrammar())
|
||||
// Before the capture markers, which all need an object. A capture verb
|
||||
// alone is a fact with no key, and the clarify path asks for it rather than
|
||||
// letting the model invent an answer (Vikunja #557).
|
||||
grammars = append(grammars, BareCaptureGrammar()...)
|
||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||
// explicit capture marker beats the model, which called it an act and
|
||||
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||
// marker never collides with a clock or agenda question.
|
||||
grammars = append(grammars, TaskCaptureGrammar())
|
||||
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||
// last overall because it matches on the first word alone: "расскажи про
|
||||
// X" is a world question the model called a fact (Vikunja #498).
|
||||
grammars = append(grammars, NarrativeQueryGrammars()...)
|
||||
return grammars
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# analyzer-gate.sh — turn an analyzer's output into a pass/fail verdict.
|
||||
#
|
||||
# The 2026-08-10 audit asked for staticcheck, govulncheck and deadcode
|
||||
# (V-694). govulncheck needed no gate of this shape because it already
|
||||
# reported zero after the toolchain bump. The other two do not: staticcheck
|
||||
# reports 20 findings today and deadcode reports 11 unreachable symbols, and
|
||||
# three of those eleven are deliberate. A target that fails on the first run
|
||||
# is not a gate, it is a target nobody runs. So the accepted set is written
|
||||
# down, and only what is NOT in it fails.
|
||||
#
|
||||
# staticcheck ./... | scripts/analyzer-gate.sh staticcheck
|
||||
# deadcode -test ./... | scripts/analyzer-gate.sh deadcode
|
||||
#
|
||||
# The baseline is keyed on file, check id and message, never on line number.
|
||||
# A key carrying a line number goes stale on the next edit above it and then
|
||||
# reports moved findings as new ones, which trains the reader to ignore it.
|
||||
# The cost of dropping the line is that two identical findings in one file
|
||||
# share one key, so the second is accepted with the first. That is the right
|
||||
# way round: the same check firing twice on the same file is one thing to fix.
|
||||
#
|
||||
# A baseline entry with no finding left also fails. Fixing something and
|
||||
# leaving its entry behind is how the accepted set stops describing the repo.
|
||||
# The fix is one line: delete the entry the failure names.
|
||||
#
|
||||
# Reads stdin, writes a report, never writes a file.
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
tool="${1:?usage: analyzer-gate.sh <staticcheck|deadcode>}"
|
||||
baseline="scripts/analyzers/$tool.baseline"
|
||||
[ -f "$baseline" ] || { printf 'analyzer-gate: no baseline at %s\n' "$baseline" >&2; exit 2; }
|
||||
|
||||
# Normalise to "<file>\t<id>\t<message>". Anything that does not parse is an
|
||||
# analyzer error, not a finding, and it fails without consulting the baseline.
|
||||
# staticcheck: path.go:12:34: message (SA1234)
|
||||
# deadcode: path.go:12:34: unreachable func: Symbol
|
||||
found=$(mktemp) || exit 2
|
||||
malformed=$(mktemp) || exit 2
|
||||
trap 'rm -f "$found" "$malformed"' EXIT
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
case "$tool" in
|
||||
staticcheck)
|
||||
if [[ "$line" =~ ^([^:]+):[0-9]+:[0-9]+:\ (.*)\ \(([A-Z]+[0-9]+)\)$ ]]; then
|
||||
# SA1019 ends its message with a space. Trim, so no baseline entry
|
||||
# depends on trailing whitespace surviving an editor.
|
||||
msg="${BASH_REMATCH[2]}"
|
||||
printf '%s\t%s\t%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]}" "${msg%"${msg##*[![:space:]]}"}" >>"$found"
|
||||
else
|
||||
printf '%s\n' "$line" >>"$malformed"
|
||||
fi
|
||||
;;
|
||||
deadcode)
|
||||
if [[ "$line" =~ ^([^:]+):[0-9]+:[0-9]+:\ unreachable\ func:\ (.*)$ ]]; then
|
||||
printf '%s\tunreachable\t%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" >>"$found"
|
||||
else
|
||||
printf '%s\n' "$line" >>"$malformed"
|
||||
fi
|
||||
;;
|
||||
*) printf 'analyzer-gate: unknown tool %s\n' "$tool" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -s "$malformed" ]; then
|
||||
printf '%s: the analyzer said something that is not a finding:\n' "$tool" >&2
|
||||
sed 's/^/ /' "$malformed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
accepted=$(mktemp) || exit 2
|
||||
trap 'rm -f "$found" "$malformed" "$accepted"' EXIT
|
||||
grep -v '^[[:space:]]*\(#\|$\)' "$baseline" | sort -u >"$accepted"
|
||||
sort -u "$found" -o "$found"
|
||||
|
||||
new=$(comm -23 "$found" "$accepted")
|
||||
gone=$(comm -13 "$found" "$accepted")
|
||||
status=0
|
||||
|
||||
if [ -n "$new" ]; then
|
||||
printf '%s: %d finding(s) not in %s:\n' "$tool" "$(printf '%s\n' "$new" | wc -l)" "$baseline"
|
||||
printf '%s\n' "$new" | sed 's/^/ /'
|
||||
printf 'Fix it, or add the line to the baseline with the reason it stays.\n'
|
||||
status=1
|
||||
fi
|
||||
|
||||
if [ -n "$gone" ]; then
|
||||
printf '%s: %d baseline entry/entries no longer found:\n' "$tool" "$(printf '%s\n' "$gone" | wc -l)"
|
||||
printf '%s\n' "$gone" | sed 's/^/ /'
|
||||
printf 'Delete them from %s.\n' "$baseline"
|
||||
status=1
|
||||
fi
|
||||
|
||||
[ "$status" -eq 0 ] && printf '%s: clean against %d accepted finding(s)\n' "$tool" "$(wc -l <"$accepted")"
|
||||
exit "$status"
|
||||
@@ -0,0 +1,32 @@
|
||||
# deadcode — the unreachable symbols this repo accepts today.
|
||||
#
|
||||
# Keyed "<file>\t unreachable \t<symbol>", tab separated, no line numbers.
|
||||
# Generated from the first gated run on 2026-08-11 and edited by hand since.
|
||||
# `make deadcode` fails on anything absent here and on any entry left behind
|
||||
# after its symbol is deleted.
|
||||
#
|
||||
# The gate runs with -test, so a test file counts as a root. Without it the
|
||||
# whole of internal/router/eval is unreachable and the report is 172 lines of
|
||||
# fixtures nobody wrote by mistake.
|
||||
#
|
||||
# Eleven of these are V-686, from the 2026-08-10 audit. Three of the eleven
|
||||
# must stay and the audit says why: HisGender is a documented seam tied to
|
||||
# V-399, AudioDuration should call internal/audio rather than be deleted, and
|
||||
# CountWord is a safe delete. Read docs/caveats/layering.md#deadcode before
|
||||
# removing any of them.
|
||||
cmd/mavwaked/vad.go unreachable AudioDuration
|
||||
cmd/mavwaked/vad.go unreachable PCMToF32
|
||||
internal/crawl/watch.go unreachable Watcher.Watches
|
||||
internal/phraser/confirm.go unreachable IsC
|
||||
internal/phraser/eval/checks.go unreachable HisGender
|
||||
internal/phraser/plural.go unreachable CountWord
|
||||
internal/update/update.go unreachable WithClock
|
||||
internal/voice/errors.go unreachable jsonMarshal
|
||||
internal/voice/errors.go unreachable jsonUnmarshal
|
||||
internal/webauthn/cbor.go unreachable cborValue.At
|
||||
internal/worker/server.go unreachable Server.SetSynthesizer
|
||||
|
||||
# Two test helpers the audit did not count, because it listed production
|
||||
# symbols only. A helper no test calls is dead the same way.
|
||||
cmd/mavend/replier_llm_test.go unreachable assertStub
|
||||
cmd/mavwaked/vad_test.go unreachable frameRMSQuick
|
||||
@@ -0,0 +1,46 @@
|
||||
# staticcheck — the findings this repo accepts today.
|
||||
#
|
||||
# Keyed "<file>\t<check>\t<message>", tab separated, no line numbers.
|
||||
# Generated from the first gated run on 2026-08-11 and edited by hand since.
|
||||
# `make lint` fails on anything absent here and on any entry left behind after
|
||||
# its finding is fixed, so emptying this file is done one line at a time.
|
||||
#
|
||||
# The sweep that empties it is V-701, which carries the judgement on each
|
||||
# entry. What follows is the short reason only.
|
||||
|
||||
# V-687. The dedupe check runs after the phraser has already been paid.
|
||||
cmd/mavend/tick_digest.go SA4006 this value of deduped is never used
|
||||
|
||||
# V-686, the eleven unreachable symbols the 2026-08-10 audit listed, seen from
|
||||
# the other side. Three of them must stay: docs/caveats/layering.md#deadcode.
|
||||
cmd/mavend/replier_llm_test.go U1000 func assertStub is unused
|
||||
cmd/mavwaked/vad_test.go U1000 func frameRMSQuick is unused
|
||||
cmd/mavweb/handlers_test.go U1000 field signalErr is unused
|
||||
internal/voice/errors.go U1000 func jsonMarshal is unused
|
||||
internal/voice/errors.go U1000 func jsonUnmarshal is unused
|
||||
|
||||
# False positives, checked. The code is right and the check cannot see why.
|
||||
# RenderICal is called twice because rendering twice is the assertion. The
|
||||
# morning loop reads the first rune after the hedge and breaks on purpose. The
|
||||
# task_phrases line is prose about //go:embed and the real directive is below it.
|
||||
internal/calendar/ical_render_test.go SA4000 identical expressions on the left and right side of the '!=' operator
|
||||
internal/morning/plan_test.go SA4004 the surrounding loop is unconditionally terminated
|
||||
internal/router/task_phrases.go SA9009 ineffectual compiler directive due to extraneous space: "// go:embed, so the single-binary deploy is unchanged: the JSON is compiled into"
|
||||
|
||||
# At EOF the wake loop trims partial and returns, so audio past one frame is
|
||||
# dropped. Harmless where it sits, misleading to read. V-701.
|
||||
cmd/mavwaked/main.go SA4006 this value of partial is never used
|
||||
|
||||
# Cosmetic and mechanical. V-701 sweeps them.
|
||||
cmd/mavweb/voiceproxy.go ST1013 should use constant http.StatusMethodNotAllowed instead of numeric literal 405
|
||||
cmd/mavweb/voiceproxy.go ST1013 should use constant http.StatusServiceUnavailable instead of numeric literal 503
|
||||
internal/ipc/client.go S1016 should convert r (type chatResp) to ChatReply instead of using struct literal
|
||||
internal/ipc/server.go S1016 should convert reply (type ChatReply) to chatResp instead of using struct literal
|
||||
internal/memory/behavior_test.go S1011 should replace loop with obs = append(obs, habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now)...)
|
||||
internal/memory/behavior_test.go S1011 should replace loop with obs = append(obs, habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now)...)
|
||||
cmd/mavend/continuation_test.go SA1012 do not pass a nil Context, even if a function permits it; pass context.TODO if you are unsure about which Context to use
|
||||
|
||||
# Deprecated since Go 1.25. Replacing it means rewriting both guards on
|
||||
# golang.org/x/tools/go/packages, which is a decision and not a sweep.
|
||||
internal/ipc/maperr_test.go SA1019 parser.ParseDir has been deprecated since Go 1.25 and an alternative has been available since Go 1.11: ParseDir does not consider build tags when associating files with packages. For precise information about the relationship between packages and files, use golang.org/x/tools/go/packages, which can also optionally parse and type-check the files too.
|
||||
internal/phraser/persona_floor_test.go SA1019 parser.ParseDir has been deprecated since Go 1.25 and an alternative has been available since Go 1.11: ParseDir does not consider build tags when associating files with packages. For precise information about the relationship between packages and files, use golang.org/x/tools/go/packages, which can also optionally parse and type-check the files too.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
Copyright 2009 The Go Authors.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
@@ -10,7 +10,7 @@ notice, this list of conditions and the following disclaimer.
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
* Neither the name of Google LLC nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
|
||||
+26
-9
@@ -13,15 +13,18 @@ import "encoding/binary"
|
||||
// a rune to a uint16. The values take two forms. For v >= 0x8000:
|
||||
// bits
|
||||
// 15: 1 (inverse of NFD_QC bit of qcInfo)
|
||||
// 13..7: qcInfo (see below). isYesD is always true (no decomposition).
|
||||
// 12..7: qcInfo (see below). isYesD is always true (no decomposition).
|
||||
// 6..0: ccc (compressed CCC value).
|
||||
// For v < 0x8000, the respective rune has a decomposition and v is an index
|
||||
// into a byte array of UTF-8 decomposition sequences and additional info and
|
||||
// has the form:
|
||||
// <header> <decomp_byte>* [<tccc> [<lccc>]]
|
||||
// The header contains the number of bytes in the decomposition (excluding this
|
||||
// length byte). The two most significant bits of this length byte correspond
|
||||
// to bit 5 and 4 of qcInfo (see below). The byte sequence itself starts at v+1.
|
||||
// length byte), with 33 mapped to 31 to fit in 5 bits.
|
||||
// (If any 31- or 32-byte decompositions come along, we could switch to using
|
||||
// use a general lookup table as long as there are at most 32 distinct lengths.)
|
||||
// The three most significant bits of this length byte correspond
|
||||
// to bit 5, 4, and 3 of qcInfo (see below). The byte sequence itself starts at v+1.
|
||||
// The byte sequence is followed by a trailing and leading CCC if the values
|
||||
// for these are not zero. The value of v determines which ccc are appended
|
||||
// to the sequences. For v < firstCCC, there are none, for v >= firstCCC,
|
||||
@@ -32,8 +35,8 @@ import "encoding/binary"
|
||||
|
||||
const (
|
||||
qcInfoMask = 0x3F // to clear all but the relevant bits in a qcInfo
|
||||
headerLenMask = 0x3F // extract the length value from the header byte
|
||||
headerFlagsMask = 0xC0 // extract the qcInfo bits from the header byte
|
||||
headerLenMask = 0x1F // extract the length value from the header byte (31 => 33)
|
||||
headerFlagsMask = 0xE0 // extract the qcInfo bits from the header byte
|
||||
)
|
||||
|
||||
// Properties provides access to normalization properties of a rune.
|
||||
@@ -109,17 +112,21 @@ func (p Properties) BoundaryAfter() bool {
|
||||
return p.isInert()
|
||||
}
|
||||
|
||||
// We pack quick check data in 4 bits:
|
||||
// We pack quick check data in 6 bits:
|
||||
//
|
||||
// 5: Combines forward (0 == false, 1 == true)
|
||||
// 4..3: NFC_QC Yes(00), No (10), or Maybe (11)
|
||||
// 2: NFD_QC Yes (0) or No (1). No also means there is a decomposition.
|
||||
// 1..0: Number of trailing non-starters.
|
||||
//
|
||||
// When all 4 bits are zero, the character is inert, meaning it is never
|
||||
// When all 6 bits are zero, the character is inert, meaning it is never
|
||||
// influenced by normalization.
|
||||
//
|
||||
// We set flags to 0x80 (high bit 7 unused in quick check data) to indicate an invalid rune.
|
||||
type qcInfo uint8
|
||||
|
||||
func (p Properties) isInvalid() bool { return p.flags == 0x80 }
|
||||
|
||||
func (p Properties) isYesC() bool { return p.flags&0x10 == 0 }
|
||||
func (p Properties) isYesD() bool { return p.flags&0x4 == 0 }
|
||||
|
||||
@@ -152,6 +159,9 @@ func (p Properties) Decomposition() []byte {
|
||||
}
|
||||
i := p.index
|
||||
n := decomps[i] & headerLenMask
|
||||
if n == 31 {
|
||||
n = 33
|
||||
}
|
||||
i++
|
||||
return decomps[i : i+uint16(n)]
|
||||
}
|
||||
@@ -241,6 +251,9 @@ func (f Form) PropertiesString(s string) Properties {
|
||||
// to a Properties. See the comment at the top of the file
|
||||
// for more information on the format.
|
||||
func compInfo(v uint16, sz int) Properties {
|
||||
if sz == 0 {
|
||||
return Properties{flags: 0x80, size: 1}
|
||||
}
|
||||
if v == 0 {
|
||||
return Properties{size: uint8(sz)}
|
||||
} else if v >= 0x8000 {
|
||||
@@ -248,7 +261,7 @@ func compInfo(v uint16, sz int) Properties {
|
||||
size: uint8(sz),
|
||||
ccc: uint8(v),
|
||||
tccc: uint8(v),
|
||||
flags: qcInfo(v >> 8),
|
||||
flags: qcInfo(v>>8) & 0x3f,
|
||||
}
|
||||
if p.ccc > 0 || p.combinesBackward() {
|
||||
p.nLead = uint8(p.flags & 0x3)
|
||||
@@ -260,7 +273,11 @@ func compInfo(v uint16, sz int) Properties {
|
||||
f := (qcInfo(h&headerFlagsMask) >> 2) | 0x4
|
||||
p := Properties{size: uint8(sz), flags: f, index: v}
|
||||
if v >= firstCCC {
|
||||
v += uint16(h&headerLenMask) + 1
|
||||
n := uint16(h & headerLenMask)
|
||||
if n == 31 {
|
||||
n = 33
|
||||
}
|
||||
v += n + 1
|
||||
c := decomps[v]
|
||||
p.tccc = c >> 2
|
||||
p.flags |= qcInfo(c & 0x3)
|
||||
|
||||
+2
-6
@@ -376,16 +376,12 @@ func nextComposed(i *Iter) []byte {
|
||||
goto doNorm
|
||||
}
|
||||
prevCC = i.info.tccc
|
||||
sz := int(i.info.size)
|
||||
if sz == 0 {
|
||||
sz = 1 // illegal rune: copy byte-by-byte
|
||||
}
|
||||
p := outp + sz
|
||||
p := outp + int(i.info.size)
|
||||
if p > len(i.buf) {
|
||||
break
|
||||
}
|
||||
outp = p
|
||||
i.p += sz
|
||||
i.p += int(i.info.size)
|
||||
if i.p >= i.rb.nsrc {
|
||||
i.setDone()
|
||||
break
|
||||
|
||||
+10
-10
@@ -148,7 +148,7 @@ func (f Form) IsNormalString(s string) bool {
|
||||
// patched buffer and whether the decomposition is still in progress.
|
||||
func patchTail(rb *reorderBuffer) bool {
|
||||
info, p := lastRuneStart(&rb.f, rb.out)
|
||||
if p == -1 || info.size == 0 {
|
||||
if p == -1 || info.isInvalid() {
|
||||
return true
|
||||
}
|
||||
end := p + int(info.size)
|
||||
@@ -225,7 +225,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte {
|
||||
}
|
||||
fd := &rb.f
|
||||
if doMerge {
|
||||
var info Properties
|
||||
info := Properties{flags: 0x80, size: 1} // invalid rune
|
||||
if p < n {
|
||||
info = fd.info(src, p)
|
||||
if !info.BoundaryBefore() || info.nLeadingNonStarters() > 0 {
|
||||
@@ -235,7 +235,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte {
|
||||
p = decomposeSegment(rb, p, true)
|
||||
}
|
||||
}
|
||||
if info.size == 0 {
|
||||
if info.isInvalid() {
|
||||
rb.doFlush()
|
||||
// Append incomplete UTF-8 encoding.
|
||||
return src.appendSlice(rb.out, p, n)
|
||||
@@ -314,7 +314,7 @@ func (f *formInfo) quickSpan(src input, i, end int, atEOF bool) (n int, ok bool)
|
||||
continue
|
||||
}
|
||||
info := f.info(src, i)
|
||||
if info.size == 0 {
|
||||
if info.isInvalid() {
|
||||
if atEOF {
|
||||
// include incomplete runes
|
||||
return n, true
|
||||
@@ -379,7 +379,7 @@ func (f Form) firstBoundary(src input, nsrc int) int {
|
||||
// CGJ insertion points correctly. Luckily it doesn't have to.
|
||||
for {
|
||||
info := fd.info(src, i)
|
||||
if info.size == 0 {
|
||||
if info.isInvalid() {
|
||||
return -1
|
||||
}
|
||||
if s := ss.next(info); s != ssSuccess {
|
||||
@@ -424,7 +424,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int {
|
||||
}
|
||||
fd := formTable[f]
|
||||
info := fd.info(src, 0)
|
||||
if info.size == 0 {
|
||||
if info.isInvalid() {
|
||||
if atEOF {
|
||||
return 1
|
||||
}
|
||||
@@ -435,7 +435,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int {
|
||||
|
||||
for i := int(info.size); i < nsrc; i += int(info.size) {
|
||||
info = fd.info(src, i)
|
||||
if info.size == 0 {
|
||||
if info.isInvalid() {
|
||||
if atEOF {
|
||||
return i
|
||||
}
|
||||
@@ -465,7 +465,7 @@ func lastBoundary(fd *formInfo, b []byte) int {
|
||||
if p == -1 {
|
||||
return -1
|
||||
}
|
||||
if info.size == 0 { // ends with incomplete rune
|
||||
if info.isInvalid() { // ends with incomplete rune
|
||||
if p == 0 { // starts with incomplete rune
|
||||
return -1
|
||||
}
|
||||
@@ -504,7 +504,7 @@ func lastBoundary(fd *formInfo, b []byte) int {
|
||||
func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int {
|
||||
// Force one character to be consumed.
|
||||
info := rb.f.info(rb.src, sp)
|
||||
if info.size == 0 {
|
||||
if info.isInvalid() {
|
||||
return 0
|
||||
}
|
||||
if s := rb.ss.next(info); s == ssStarter {
|
||||
@@ -528,7 +528,7 @@ func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int {
|
||||
break
|
||||
}
|
||||
info = rb.f.info(rb.src, sp)
|
||||
if info.size == 0 {
|
||||
if info.isInvalid() {
|
||||
if !atEOF {
|
||||
return int(iShortSrc)
|
||||
}
|
||||
|
||||
-7657
File diff suppressed because it is too large
Load Diff
-7693
File diff suppressed because it is too large
Load Diff
-7710
File diff suppressed because it is too large
Load Diff
+1478
-1478
File diff suppressed because it is too large
Load Diff
Generated
Vendored
+3532
-3188
File diff suppressed because it is too large
Load Diff
-7637
File diff suppressed because it is too large
Load Diff
Vendored
+2
-2
@@ -40,8 +40,8 @@ github.com/yalue/onnxruntime_go
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/sys/unix
|
||||
golang.org/x/sys/windows
|
||||
# golang.org/x/text v0.14.0
|
||||
## explicit; go 1.18
|
||||
# golang.org/x/text v0.40.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/text/transform
|
||||
golang.org/x/text/unicode/norm
|
||||
# modernc.org/libc v1.74.1
|
||||
|
||||
Reference in New Issue
Block a user