// Package archguard holds the architectural invariants as go/analysis analyzers, run by `go vet // -vettool` from `make battery` and CI. // // The defect they exist for was never "somebody forgot a key" — it was the SHAPE of the code. Engine // -internal calls each hand-built their own config.Stage, and a Go literal silently zero-fills every // field it does not name, so adding the reasoning knob left three sites riding the vendor default // (D39.87). Adding the field in three places would have left the fourth site, tomorrow, just as broken. // The literals are gone; these analyzers are what keeps them gone. // // WHY TYPES AND NOT SYNTAX. The predecessors walked the AST and resolved types by IMPORT PATH, which // works but has to enumerate the ways a type can be SPELLED — and each enumeration was found by planting // a bypass and watching the guard report ok (a byte scan was defeated five ways, the first AST version // two more). types.Info closes that axis: an alias import, a dot-import, a `type X = config.Stage` and // the elided element type in `[]config.Stage{{…}}` all resolve to the same types.Type. // // WHAT IS NOT CAUGHT. These guards are a floor, not a proof. Twice now the thing that broke them was a // COMMENT claiming closure the code did not have, so the rule here is: a gap is named below and carries // a fixture in testdata with no expectation comment, which makes analysistest go red the day it starts // being reported. The list is what has been planted and measured — not a proof of exhaustiveness. // // - Node kind, both seams: a Stage or Request obtained by struct EMBEDDING (`type w struct{ // config.Stage }`), by `make([]config.Stage, 1)` then assigning fields, or through a generic zero // value (`zeroOf[config.Stage]()`) is not reported. Each yields the zero value with Reasoning // empty, which is exactly the D39.87 defect. Fixtures: stagesites.go, "documented misses". The // construction that ENDS this class is a type change — making the zero value unusable outside its // package — not a longer switch, which is why the switch is not being extended further. // - Type reach, egress: typeReaches walks type CONSTRUCTORS, and stops at a named type. A signature // that hides the pair inside one — `type batch struct{ reqs []llm.LLMRequest }`, then // `Complete(b batch) error` — is not reported. Descending into every named type's underlying would // reach half the module through context.Context and make the reports hard to predict. // - Name and shape, egress: a provider call is recognised either by carrying the llm.LLMRequest → // llm.LLMResponse pair (name-independent) or by being spelled `Complete` against internal/llm. A // helper that is called something else AND launders the pair through its own types — `func Ask(ctx, // prompt string) (string, error)` building the request inside — is invisible to both halves. // - Reflection, egress: a call made through reflect.Value.Call is spelled `Call` and typed // func([]reflect.Value) []reflect.Value, so neither half can see the provider behind it. No static // analyzer closes this one; it is named so that nobody has to find it twice. // // WHAT WAS GIVEN UP. // // The predecessors walked the whole REPOSITORY, reaching eval/ — Go files outside the module, where the // original defect's fourth call site lived. go/analysis cannot: it needs a package the loader can build, // and eval/ has no go.mod (`go build` there fails with "cannot find main module"). Measured before // dropping it: eval/ holds three programs, one of which imports config and pipeline, and NONE constructs // a Stage or a Request. So the reach guarded nothing, and the polygon is scratch space by design // (CLAUDE.md) — gating it is not worth a constraint on the guard's form. If a harness there ever needs // to be covered, the honest fix is a go.mod in eval/, not a syntax-only walk here. // // They also read every .go file regardless of BUILD TAGS, which go/analysis cannot do either: a pass // sees only the files its build configuration selects. `make battery` therefore vets two configurations // — default and `live` — and a file behind any third tag is not covered by anything. That is a real // reach the byte scan had; a new tag in this module needs a matching vet line in the Makefile. package archguard import ( "go/ast" "go/types" "path/filepath" "strings" "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/ast/inspector" ) const ( configPkg = "textmachine/backend/internal/config" pipelinePkg = "textmachine/backend/internal/pipeline" llmPkg = "textmachine/backend/internal/llm" // The provider-neutral pair every real egress carries: a request goes in, a response comes out. llmRequestType = "LLMRequest" llmResponseType = "LLMResponse" ) // Analyzers is every invariant this package enforces. Both drivers — cmd/tmvet and the // shipping-tree test — take the whole slice, and that test asserts its length, so an analyzer // dropped from here fails loudly instead of silently enforcing nothing. func Analyzers() []*analysis.Analyzer { return []*analysis.Analyzer{StageSeam, RequestSeam, EgressSeam} } // exempt reports whether the file holding n is one of the named exceptions, identified by IMPORT PATH // plus base name and compared exactly. // // The obvious spelling — HasSuffix over the absolute filename — was wrong in both directions, and both // were reproduced: any nested directory or symlink whose tail happened to replay // ".../backend/internal/pipeline/stagerun.go" INHERITED the exemption, while the intended files LOST it // on any checkout where the module directory is not literally named "backend". An import path is // canonical, so neither can happen. PositionFor(…, false) is deliberate: the adjusted position honours // //line directives, which would let a generated file claim another file's identity. func exempt(pass *analysis.Pass, n ast.Node, files []string) bool { id := strings.TrimSuffix(pass.Pkg.Path(), "_test") + "/" + filepath.Base(pass.Fset.PositionFor(n.Pos(), false).Filename) for _, f := range files { if f == id { return true } } return false } // inPackage reports whether the pass is analyzing pkgPath, counting its EXTERNAL test package (path // "_test") as the same package. // // Callers use this to SKIP a package, so the TrimSuffix widens an exemption rather than closing a hole // — an earlier comment here claimed the opposite, and the measurement is unambiguous: with a // `package config_test` file building a Stage, dropping the TrimSuffix makes it report (exit 1) and // keeping it makes it silent (exit 0). That is intended — a package's external tests are the seam's own // test surface — but it is an exemption, and the cost is that any file declaring `package config_test` // or `package llm_test` may build Stages and call providers without a word. func inPackage(pass *analysis.Pass, pkgPath string) bool { return strings.TrimSuffix(pass.Pkg.Path(), "_test") == pkgPath } // isConversion reports whether call is a type conversion T(x) rather than a function call: the callee // expression denotes a TYPE, which go/types records in Types[fun].IsType(). func isConversion(pass *analysis.Pass, call *ast.CallExpr) bool { tv, ok := pass.TypesInfo.Types[call.Fun] return ok && tv.IsType() } // isNamed reports whether t is exactly pkgPath.name, seeing through aliases. It does NOT unwrap // pointers, slices or maps: `[]config.Stage` as a TYPE is a legitimate field, while each ELEMENT // literal inside it types as config.Stage on its own and is caught there. // // The pointer unwrap this used to do was load-bearing in ONE place and a false positive everywhere // else (measured both ways): `[]*config.Stage{{…}}` types its elided element as *config.Stage, so a // literal needs the deref — but `var s *config.Stage` and `(*config.Stage)(p)` construct no Stage at // all, and go vet has no per-line suppression, so reporting them made the gate unarguable. Only the // literal branch derefs now. func isNamed(t types.Type, pkgPath, name string) bool { named, ok := types.Unalias(t).(*types.Named) if !ok { return false } obj := named.Obj() return obj != nil && obj.Pkg() != nil && obj.Pkg().Path() == pkgPath && obj.Name() == name } // deref removes one pointer level, for the composite-literal branch only. func deref(t types.Type) types.Type { if p, ok := types.Unalias(t).(*types.Pointer); ok { return p.Elem() } return t } // typeReaches reports whether t, or any type reachable from it through the type CONSTRUCTORS — pointer, // slice, array, map, channel, signature, tuple, anonymous struct/interface, generic type argument — // is a named type satisfying want. // // The recursion is what the flat version lacked: `Complete(ctx, reqs []llm.LLMRequest) // ([]*llm.LLMResponse, error)` unwrapped to a slice and stopped, so the batch form of our own client // passed the egress guard in silence (D39.96 §3в-2). It stops at a NAMED type — see the package doc. func typeReaches(t types.Type, want func(*types.Named) bool) bool { return reaches(t, want, map[types.Type]bool{}) } func reaches(t types.Type, want func(*types.Named) bool, seen map[types.Type]bool) bool { t = types.Unalias(t) if t == nil || seen[t] { return false // a recursive type (`type node struct{ next *node }`) would not terminate } seen[t] = true switch v := t.(type) { case *types.Named: if want(v) { return true } // Type ARGUMENTS are still constructors: `Result[llm.LLMResponse]` carries the response. for args, i := v.TypeArgs(), 0; args != nil && i < args.Len(); i++ { if reaches(args.At(i), want, seen) { return true } } case *types.Pointer: return reaches(v.Elem(), want, seen) case *types.Slice: return reaches(v.Elem(), want, seen) case *types.Array: return reaches(v.Elem(), want, seen) case *types.Chan: return reaches(v.Elem(), want, seen) case *types.Map: return reaches(v.Key(), want, seen) || reaches(v.Elem(), want, seen) case *types.Tuple: for i := 0; i < v.Len(); i++ { if reaches(v.At(i).Type(), want, seen) { return true } } case *types.Signature: return reaches(v.Params(), want, seen) || reaches(v.Results(), want, seen) case *types.Struct: // reachable only while ANONYMOUS — a named struct stops the walk above for i := 0; i < v.NumFields(); i++ { if reaches(v.Field(i).Type(), want, seen) { return true } } case *types.Interface: // likewise anonymous: `interface{ Complete(llm.LLMRequest) error }` for i := 0; i < v.NumMethods(); i++ { if reaches(v.Method(i).Type(), want, seen) { return true } } for i := 0; i < v.NumEmbeddeds(); i++ { if reaches(v.EmbeddedType(i), want, seen) { return true } } case *types.TypeParam: // Inside a generic body the request can travel as the parameter itself; what pins it down is // the CONSTRAINT. Underlying() is deliberate and is the one place this walk goes THROUGH a // named type: a constraint is usually named (`type reqBound interface{ … }`), and stopping // there — the rule everywhere else — reaches nothing at all. It is safe here precisely because // a constraint is a type-level bound rather than a data structure: it cannot drag in the // module the way descending into every named struct would. return reaches(v.Constraint().Underlying(), want, seen) case *types.Union: // the term list of such a constraint for i := 0; i < v.Len(); i++ { if reaches(v.Term(i).Type(), want, seen) { return true } } } return false } func fromPkg(pkgPath string) func(*types.Named) bool { return func(n *types.Named) bool { o := n.Obj() return o != nil && o.Pkg() != nil && o.Pkg().Path() == pkgPath } } func namedIs(pkgPath, name string) func(*types.Named) bool { return func(n *types.Named) bool { o := n.Obj() return o != nil && o.Pkg() != nil && o.Pkg().Path() == pkgPath && o.Name() == name } } // seamRule is one "there is exactly ONE place this type may be built" invariant. Stage and Request // differ only in the type protected, the files allowed to build it and the sentence printed — sharing // the walk is what stopped the two from drifting apart: Request used to inspect ONE node kind against // Stage's four, so `var r pipeline.Request` and `new(pipeline.Request)` passed both generations of the // guard (D39.96 §3б). type seamRule struct { pkgPath string // package declaring the protected type name string // its name ownPkg string // a package that may build it freely ("" — none; exempt files carry it instead) exempt []string // file path suffixes allowed to build it msg string } func (s seamRule) run(pass *analysis.Pass) (any, error) { if s.ownPkg != "" && inPackage(pass, s.ownPkg) { return nil, nil // the type's OWN package: the seam lives here } ins := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) ins.Preorder([]ast.Node{ (*ast.CompositeLit)(nil), (*ast.ValueSpec)(nil), (*ast.CallExpr)(nil), (*ast.TypeSpec)(nil), (*ast.FuncType)(nil), }, func(n ast.Node) { if exempt(pass, n, s.exempt) { return } is := func(t types.Type) bool { return isNamed(t, s.pkgPath, s.name) } hit := false switch v := n.(type) { case *ast.CompositeLit: // TypeOf resolves the ELIDED element type too, so []T{{…}} and map[k]T{k:{…}} are caught // on their elements; []*T{{…}} types that element as *T, which is why this one derefs. hit = is(deref(pass.TypesInfo.TypeOf(v))) case *ast.ValueSpec: // var s T — then assign field by field, no literal anywhere hit = v.Type != nil && is(pass.TypesInfo.TypeOf(v.Type)) case *ast.FuncType: // A NAMED RESULT is the same zero-value declaration as `var s T`, one keystroke apart: // `func f() (s config.Stage) { s.Name = …; return s }`. Params are not declarations — // the caller supplies them — so only results count. if v.Results != nil { for _, f := range v.Results.List { if len(f.Names) > 0 && is(pass.TypesInfo.TypeOf(f.Type)) { hit = true } } } case *ast.CallExpr: if id, ok := v.Fun.(*ast.Ident); ok && len(v.Args) == 1 && pass.TypesInfo.Uses[id] == types.Universe.Lookup("new") { hit = is(pass.TypesInfo.TypeOf(v.Args[0])) // new(T) } else if isConversion(pass, v) { // T(twin{…}) — a conversion from a structurally identical type builds a T just as // a literal does, and drops exactly the same fields. hit = is(pass.TypesInfo.TypeOf(v)) } case *ast.TypeSpec: // type myT = T / type myT T hit = is(pass.TypesInfo.TypeOf(v.Type)) } if hit { pass.Reportf(n.Pos(), "%s", s.msg) } }) return nil, nil } // StageSeam: outside the config package there is exactly ONE way to obtain a Stage for an engine-internal // call — config.InternalCall.Stage() — so a fifth call site inherits every decision the seam took instead // of re-taking them by omission. var StageSeam = &analysis.Analyzer{ Name: "stageseam", Doc: "config.Stage must be built only through config.InternalCall.Stage()", Requires: []*analysis.Analyzer{inspect.Analyzer}, Run: stageSeam.run, } var stageSeam = seamRule{ pkgPath: configPkg, name: "Stage", ownPkg: configPkg, exempt: []string{ "textmachine/backend/internal/pipeline/fewshot_test.go", // pure predicate test: no wire, no ledger, no checkpoint // Its subject IS stage inheritance: it builds a PARENT stage to assert what repair derives from it. "textmachine/backend/internal/pipeline/synthetic_stage_seam_test.go", }, msg: "an engine-internal call must derive its stage through config.InternalCall.Stage(), " + "not by building one — a hand-built stage silently drops reasoning/temperature (D39.87)", } // RequestSeam: the tuple RequestHash addresses a checkpoint by is assembled in exactly ONE place // (Runner.attemptRequest). Two assemblies agreeing is not a property anyone maintains by eye — it held // only while the omitted fields happened to be zero, and the effort knob makes them non-zero. The guard // is "no Request BUILT outside the seam", not "no RequestHash(Request{…})": the second form is one // refactor away from invisible (hoist the literal into a variable), and the construction is the hazard. var RequestSeam = &analysis.Analyzer{ Name: "requestseam", Doc: "pipeline.Request must be assembled only in Runner.attemptRequest", Requires: []*analysis.Analyzer{inspect.Analyzer}, Run: requestSeam.run, } var requestSeam = seamRule{ pkgPath: pipelinePkg, name: "Request", // No ownPkg: pipeline is where Request lives AND where every drifting re-assembly would live, so // the exemption is per-file rather than per-package. exempt: []string{ "textmachine/backend/internal/pipeline/stagerun.go", // owns attemptRequest — the seam itself // render.go — where Request is DEFINED and hashed — used to be exempt too. Measured: with the // exemption removed the tree is still clean, because declaring the type and hashing a // parameter construct nothing. An exemption that covers no existing code is not neutral; it // silently pre-approves the next literal somebody adds there. // The tests OF the hashing contract legitimately build Requests — that is their subject. "textmachine/backend/internal/pipeline/render_test.go", "textmachine/backend/internal/pipeline/render_memory_test.go", }, msg: "a call's request identity must come from Runner.attemptRequest, not a re-assembled " + "Request literal — a probe that drifts from the attempt turns the role sub-budget off", } // EgressSeam: exactly ONE production call site may reach a provider (stagerun.go runAttempt, behind // clientFor's label assert), plus the live-gated conformance probe. A new egress path — a Ф2 judge, an // annotator — must extend the routing assert, not slip past it. // // The predecessor matched the BYTES `.Complete(`, which cannot tell our client's method from any other // type's method of that name and had to exempt this guard's own source from matching its own needle. // Two halves replace it, and a call is reported if EITHER fires: // // - SHAPE (isEgressShaped) — the callee carries the llm.LLMRequest → llm.LLMResponse pair. Name // -independent, so a helper called Do or Ask is caught exactly like Complete, and it reads through // slices and pointers, so the batch form is caught too. // - PROVENANCE (isCompleteAgainstLLM) — the callee is spelled Complete and either is declared in // internal/llm or mentions an internal/llm type at all. This is the parity half: the byte scan // matched every `.Complete(`, and a client whose request/response types get renamed would slip // past the shape half alone. var EgressSeam = &analysis.Analyzer{ Name: "egressseam", Doc: "provider egress must stay in one place (stagerun.go runAttempt)", Requires: []*analysis.Analyzer{inspect.Analyzer}, Run: runEgressSeam, } var egressExempt = []string{ "textmachine/backend/internal/pipeline/stagerun.go", // runAttempt — the single production egress "textmachine/backend/internal/pipeline/live_conformance_test.go", // live-gated probe: calls the real catalog on purpose } func runEgressSeam(pass *analysis.Pass) (any, error) { if inPackage(pass, llmPkg) { return nil, nil // the adapters ARE the implementation of Complete — the seam, not a bypass } const msg = "provider egress must stay in ONE place (stagerun.go runAttempt, behind clientFor's " + "label assert) — route this through runAttempt or extend the routing assert deliberately" ins := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) ins.Preorder([]ast.Node{(*ast.CallExpr)(nil)}, func(n ast.Node) { call := n.(*ast.CallExpr) if exempt(pass, n, egressExempt) { return } tv, ok := pass.TypesInfo.Types[call.Fun] if !ok || tv.IsType() { return // a conversion calls nothing } // The callee's signature is NOT always available: through a type PARAMETER, Underlying() is // the constraint interface, and a guard that required a signature returned right here — a // whole generic provider path was invisible (found by planting `runJudge[F providerFn](…, // ask F, …)` and watching `go vet -vettool` exit 0). The shape half therefore reads the CALL // SITE, which go/types resolves in every spelling; the signature is only what provenance // needs, and it may be nil. sig, _ := tv.Type.Underlying().(*types.Signature) if isEgressShaped(pass, call, sig) || isCompleteAgainstLLM(pass, call.Fun, sig) { pass.Reportf(call.Pos(), "%s", msg) } }) return nil, nil } // isEgressShaped reports whether this call has the shape every provider call has: an llm.LLMRequest // goes in and an llm.LLMResponse comes out. A wrapper declared OUTSIDE internal/llm cannot launder // this: to present its own types it has to call the real client somewhere, and that inner call carries // the pair. A wrapper declared INSIDE internal/llm can, because this analyzer skips that package. // // Arguments and the call's own result type are read from the call site, so the answer does not depend // on how the callee is spelled — a plain func, a method, a func-typed field, a type parameter. The // declared signature is consulted as well, which covers the one case the call site cannot express: a // request arriving as a variadic tail that this call happens to leave empty. func isEgressShaped(pass *analysis.Pass, call *ast.CallExpr, sig *types.Signature) bool { wantsRequest := func() bool { for _, a := range call.Args { if typeReaches(pass.TypesInfo.TypeOf(a), namedIs(llmPkg, llmRequestType)) { return true } } return sig != nil && typeReaches(sig.Params(), namedIs(llmPkg, llmRequestType)) } yieldsResponse := func() bool { if typeReaches(pass.TypesInfo.TypeOf(call), namedIs(llmPkg, llmResponseType)) { return true } return sig != nil && typeReaches(sig.Results(), namedIs(llmPkg, llmResponseType)) } return wantsRequest() && yieldsResponse() } // isCompleteAgainstLLM reports whether the callee is spelled Complete and belongs to internal/llm by // declaration or by signature. // // Resolving the callee through Uses is what the previous version got wrong: it read Selections, which // go/types does not populate for a QUALIFIED IDENTIFIER, so a package-level `llm.Complete(…)` — a second // egress reachable from anywhere, since internal/llm is skipped wholesale — returned nil and the // analyzer walked away (D39.96 §3в-1). Uses is populated for both forms, and taking the callee ident // covers a dot-imported `Complete(…)` as well. func isCompleteAgainstLLM(pass *analysis.Pass, fun ast.Expr, sig *types.Signature) bool { id := calleeIdent(fun) if id == nil || id.Name != "Complete" { return false } obj := pass.TypesInfo.Uses[id] declaredInLLM := obj != nil && obj.Pkg() != nil && obj.Pkg().Path() == llmPkg return declaredInLLM || typeReaches(sig.Params(), fromPkg(llmPkg)) || typeReaches(sig.Results(), fromPkg(llmPkg)) } // calleeIdent is the name a call is written under: the selector's tail for x.f() and llm.f(), the // identifier itself for a dot-imported or local f(). func calleeIdent(fun ast.Expr) *ast.Ident { switch v := fun.(type) { case *ast.Ident: return v case *ast.SelectorExpr: return v.Sel } return nil }