From fb278a8b2bcbccc2bad9d9e91989c3d472953293 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 6 Oct 2026 17:01:06 -0400 Subject: [PATCH 1/3] fix: a reader escaping an unmodelled flow is a finding, and the fixpoint refuses exhaustion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r3's major. The analysis had grown real — folded constants, one reader model, SSA/CHA — and still SKIPPED what it could not follow, which is the same defect in its last place: a map of functions, a reflect.Value.Call, a generic instantiation and a third-party wrapper each carried a reader somewhere the model had no edge for, and each produced silence from both guards. The default is inverted. A reader VALUE in a position the propagation does not pass through — a map or slice literal, an argument to another package, a generic instantiation whose callee is an index expression, a return, a channel — is an unresolved finding that fails the gate. The modelled flows are exactly the ones grow() follows: a name, a variable specification, a struct literal field, an argument to one of this package's own functions, a conversion, and being called. Refusing rather than enumerating is the whole point, and the review said so: teaching the model about maps and reflection and instantiation would leave the NEXT way of moving a function value silent again. Four spellings are committed as fixtures, but they are evidence for the rule, not the rule. The wrapper is the other half: a dependency's body is not here, so x/sys/unix.Getenv is modelled by SUMMARY, beside os and syscall. Every way Go reads the environment bottoms out in those three packages; a wrapper in a fourth is the residual, and the escape rule is what keeps that residual from being silent when a reader value is what travels into it. The fixpoint reaches a fixed point or refuses. Stopping after a fixed number of rounds was a skip wearing a loop. Two assertions corrected, both from the review's (C): - the serving fixture asserted three aggregate reader names across seven paths, so a lost shape could be masked by another path reporting the same reader. It asserts the function each read sits in, per shape. - the facade fixture counted occurrences of the claim path, which one finding satisfies twice — once as the constant's value, once as the shape matched. It counts distinct finding sites. Suite 177s, against main's 221s. The escape walk kept a parent stack with a defer inside the visitor, which is a defer per AST node of the package: that alone was ninety seconds under -race, and a run before the fix took the suite to 299s. The stack is kept by hand, and the reader analysis is cached per loaded package. Mutations, all red at this tree: R3Map, R3Reflect, R3Generic, R3Wrapper, N5, X1, R2Mount, the exported closure, R2LateHost, E2, C5, P9. Co-Authored-By: Claude Opus 5 (1M context) --- environment_boundary_test.go | 84 ++++++--- facade_claim_test.go | 319 ++++++++++++++++++++++++++++++++--- 2 files changed, 365 insertions(+), 38 deletions(-) diff --git a/environment_boundary_test.go b/environment_boundary_test.go index d4ab3e0..1744a3f 100644 --- a/environment_boundary_test.go +++ b/environment_boundary_test.go @@ -78,16 +78,17 @@ func TestNoEnvironmentReadIsReachableAfterBoot(t *testing.T) { pkgs := gatePackages(t, nil) readers := servingReaders(t, pkgs) - var names []string - for name := range readers { - names = append(names, name) + var sites []string + for site := range readers { + sites = append(sites, site) } - sort.Strings(names) - for _, name := range names { - t.Errorf("%s is reachable from the post-boot surface without passing through loadConfig:\n %s\n"+ + sort.Strings(sites) + for _, site := range sites { + found := readers[site] + t.Errorf("%s reads the environment (%s) and is reachable from the post-boot surface without passing through loadConfig:\n %s\n"+ "A value read after boot is never refused by validate(): it changes what a running solution does, with nothing judging it. "+ "Resolve it in loadConfig and carry the resolved value, rather than reading the environment where it is used.", - name, readers[name]) + found.site, found.reader, found.path) } } @@ -177,17 +178,38 @@ func ExportedLateHost() string { source = strings.ReplaceAll(source, "os.LookupEnvValue(", "os.Getenv(") readers := servingReaders(t, gatePackages(t, map[string][]byte{fixture: []byte(source)})) - if len(readers) == 0 { - t.Fatal("the gate reported no post-boot reader for a fixture that reads on seven serving paths") + + // Per SHAPE, by the function the read sits in. Asserting a few reader + // names over seven paths let one path stand for another: a gate that lost + // the stored method value while still finding the parenthesised one + // reported os.Getenv either way and passed. + holders := map[string]bool{} + for _, found := range readers { + holders[found.holder] = true + } + for _, want := range []string{ + "ServedParenthesisedRead", // a parenthesised callee + "fixtureMountedRead", // reached only as a handler value + "fixtureStoredRead", // reached only through a stored method value + "servingAliasRead", // an aliased reader with a constant key + "discoverHostModule", // the whole environment, via an exported wrapper + } { + if !holders[want] { + t.Errorf("no post-boot read reported inside %s, so that shape is unwalked; reported: %v", want, holders) + } } - // Both readers these shapes reach, so a gate that saw one kind of read and - // not the other cannot pass: env for the in-package helper, os.Getenv for - // the direct and aliased ones, os.Environ for the whole-environment scan. - for _, want := range []string{"env", "os.Getenv", "os.Environ"} { - if _, ok := readers[want]; !ok { - t.Errorf("the gate did not report %s: readers=%v", want, readers) + // And the closure assigned to an exported variable, which has no name of + // its own: its read sits in an anonymous function, so it is asserted by + // the reader it reaches from a closure holder. + closure := false + for _, found := range readers { + if strings.Contains(found.holder, "$") || found.holder == "ExportedClosure" { + closure = true } } + if !closure { + t.Errorf("no post-boot read reported inside a closure, so the exported-variable closure is unwalked; reported: %v", readers) + } } // --- One reader definition, by object identity --- @@ -210,7 +232,15 @@ func ExportedLateHost() string { // wrapper of a function using it reads changing configuration after boot. var readerNames = map[string]map[string]bool{ "os": {"Getenv": true, "LookupEnv": true, "Environ": true, "ExpandEnv": true}, - "syscall": {"Getenv": true, "Environ": true}, + "syscall": {"Getenv": true, "LookupEnv": true, "Environ": true}, + // A dependency that wraps the syscall layer is a reader too, and a + // SUMMARY is how one gets modelled without its body: x/sys/unix.Getenv + // delegates to syscall.Getenv, so calling it reads the environment just as + // directly. Every way Go reads the environment bottoms out in one of these + // three packages; a wrapper in a fourth is the residual this list carries, + // and the escape rule below is what keeps that residual from being silent + // when a reader VALUE is what travels into it. + "golang.org/x/sys/unix": {"Getenv": true, "Setenv": true, "Clearenv": true, "Environ": true}, } // readerPackageOf is the import path a reader package goes by, since SSA names @@ -256,7 +286,13 @@ var keylessReaders = map[string]bool{"Environ": true, "ExpandEnv": true} // servingReaders is every environment reader reachable from the post-boot // roots, named by the function that reads, with the path that reaches it. -func servingReaders(t *testing.T, pkgs []*packages.Package) map[string]string { +type servingRead struct { + // site is where the read is written, holder the function it is in, and + // reader which reader it calls. + site, holder, reader, path string +} + +func servingReaders(t *testing.T, pkgs []*packages.Package) map[string]servingRead { t.Helper() // SSA for THIS package only, not the whole dependency closure. Building @@ -302,7 +338,7 @@ func servingReaders(t *testing.T, pkgs []*packages.Package) map[string]string { } // Breadth-first from the roots, never through loadConfig. - readers := map[string]string{} + readers := map[string]servingRead{} seen := map[*callgraph.Node]bool{} path := map[*callgraph.Node]string{} var queue []*callgraph.Node @@ -363,7 +399,17 @@ func servingReaders(t *testing.T, pkgs []*packages.Package) map[string]string { if holder == nil { continue } - readers[site.name] = path[holder] + " → " + site.name + // Keyed by SITE, not by reader name: seven serving paths reaching + // os.Getenv are seven findings, and keying by the reader let one + // of them stand for all. A fixture losing its own path could then + // be masked by another fixture reporting the same name. + position := pkg.Fset.Position(site.pos).String() + readers[position] = servingRead{ + site: position, + holder: holder.Func.Name(), + reader: site.name, + path: path[holder] + " → " + site.name, + } } } return readers diff --git a/facade_claim_test.go b/facade_claim_test.go index b742b4f..bef45b6 100644 --- a/facade_claim_test.go +++ b/facade_claim_test.go @@ -9,6 +9,7 @@ import ( "sort" "strconv" "strings" + "sync" "testing" "golang.org/x/tools/go/packages" @@ -172,7 +173,7 @@ func unresolvedKeyFindings(t *testing.T, pkg *packages.Package) []string { if len(identity.readerAliases) == 0 { t.Fatal("no readers identified at all: this gate is inert") } - return identity.unresolvedKeys + return append(append([]string{}, identity.unresolvedKeys...), identity.unresolved...) } // --- The fixture that escaped, as a compiled negative case --- @@ -198,7 +199,12 @@ func TestTheFacadeGatesCatchTheEscapingFixture(t *testing.T) { source := `package solution -import "os" +import ( + "os" + "reflect" + + "golang.org/x/sys/unix" +) // A constant conversion splicing the carrier's name. func FacadeCredentialViaConversion() string { @@ -289,6 +295,38 @@ func AliasedThroughParentheses(parts []string) string { var osGetenv = os.Getenv +// The ways of moving a function value this analysis does not model. None is +// handled by teaching the model about maps, reflection or instantiation — +// that would leave the NEXT way silent. Each is a reader value in a position +// grow() does not propagate through, so each fails for the same reason, and so +// will the one nobody has thought of. +func EscapeViaMap() string { + readers := map[string]func(string) string{"get": os.Getenv} + return readers["get"](joinedKey(nil)) +} + +func EscapeViaReflect() string { + return reflect.ValueOf(os.Getenv).Call([]reflect.Value{reflect.ValueOf(joinedKey(nil))})[0].String() +} + +func escapeApply[F ~func(string) string](reader F, key string) string { return reader(key) } + +func EscapeViaGeneric() string { + return escapeApply[func(string) string](os.Getenv, joinedKey(nil)) +} + +func EscapeViaSlice() string { + readers := []func(string) string{os.Getenv} + return readers[0](joinedKey(nil)) +} + +// And a dependency that wraps the syscall layer is a reader by SUMMARY: its +// body is not here, so calling it is modelled as the read it is. +func WrapperRead(parts []string) string { + value, _ := unix.Getenv(joinedKey(parts)) + return value +} + func joinedKey(parts []string) string { out := "" for _, part := range parts { @@ -317,10 +355,19 @@ func joinedKey(parts []string) string { t.Errorf("the claim-shape gate did not catch %q.\nfindings:\n%s", want, shapes) } } - // Every spelling, not just one of each shape: the chain and the local - // constant both produce the claim path, so two findings name it. - if strings.Count(shapes, "/modules/_register") < 2 { - t.Errorf("the claim path was caught in only one spelling.\nfindings:\n%s", shapes) + // Every spelling, by DISTINCT finding. Counting occurrences of the string + // did not require two: one finding names the shape twice, once as the + // constant's value and once as the shape it matched, so a gate that found + // the conversion and lost the chain still counted two. + distinct := map[string]bool{} + for _, line := range strings.Split(shapes, "\n") { + if strings.Contains(line, "/modules/_register") { + distinct[strings.SplitN(line, ":", 4)[0]+":"+strings.SplitN(line, ":", 4)[1]] = true + } + } + if len(distinct) < 2 { + t.Errorf("the claim path was caught at %d distinct places, want the conversion, the chain and the local constant.\nfindings:\n%s", + len(distinct), shapes) } // Four aliased reader invocations: through a conversion, a struct field, @@ -329,6 +376,21 @@ func joinedKey(parts []string) string { if got := strings.Count(keys, "does not resolve to constants"); got < 5 { t.Errorf("the key gate caught %d of the five untraceable-key invocations.\nfindings:\n%s", got, keys) } + + // Per escape, by its own source line: an aggregate count lets one stand + // for another. + for _, want := range []string{ + "EscapeViaMap", "EscapeViaReflect", "EscapeViaGeneric", "EscapeViaSlice", + } { + if !mentionsFunction(t, source, keys, want) { + t.Errorf("no finding inside %s, so that escape is still silent.\nfindings:\n%s", want, keys) + } + } + // The dependency wrapper is a read by summary, so its runtime key is + // refused like any other reader's. + if !mentionsFunction(t, source, keys, "WrapperRead") { + t.Errorf("a dependency wrapper of the syscall layer was not treated as a reader.\nfindings:\n%s", keys) + } } // And the gates must not fire on the package as it is, or the fixtures above @@ -370,22 +432,78 @@ type readerIdentity struct { // one model, so an alias the key gate resolves is an alias that gate sees // too. invocations []readerSite + // decls caches the declaration of each of this package's functions. The + // propagation asks for them once per call expression per round, and + // re-walking every file each time was most of this gate's cost. + decls map[*types.Func]*ast.FuncDecl + // unresolved is every reader flow this analysis does not model, and the + // exhaustion of the fixpoint if it happens. These FAIL the gate. + // + // This is the whole shape of the rule. An analysis that skips what it + // cannot follow reports a boundary intact because it did not look: a map + // of functions, a reflect.Value.Call, a generic instantiation each carried + // a reader somewhere this model has no edge for, and each produced + // silence. Refusing instead means a new way of moving a function value + // fails the suite until somebody models it or decides it is fine. + unresolved []string } +// maxReaderFlowRounds bounds the propagation. Reaching it is a finding rather +// than a stopping condition. +const maxReaderFlowRounds = 64 + // readerSite is one resolved reader invocation. type readerSite struct { pos token.Pos name string } +// readerIdentityCache holds one analysis per loaded package. Several gates ask +// the same package the same question, and the propagation is the expensive +// part of this suite — recomputing it per gate cost ninety seconds. +var ( + readerIdentityMu sync.Mutex + readerIdentityCache = map[*packages.Package]*readerIdentity{} +) + func newReaderIdentity(pkg *packages.Package) *readerIdentity { - r := &readerIdentity{pkg: pkg, readerAliases: map[types.Object]bool{}} + readerIdentityMu.Lock() + defer readerIdentityMu.Unlock() + if cached, ok := readerIdentityCache[pkg]; ok { + return cached + } + identity := buildReaderIdentity(pkg) + readerIdentityCache[pkg] = identity + return identity +} + +func buildReaderIdentity(pkg *packages.Package) *readerIdentity { + r := &readerIdentity{pkg: pkg, readerAliases: map[types.Object]bool{}, decls: map[*types.Func]*ast.FuncDecl{}} + for _, file := range r.files() { + for _, d := range file.Decls { + if decl, ok := d.(*ast.FuncDecl); ok && decl.Name != nil { + if fn, ok := pkg.TypesInfo.Defs[decl.Name].(*types.Func); ok { + r.decls[fn] = decl + } + } + } + } r.seed() - for round := 0; round < 16; round++ { + // A fixed point, or a refusal. Stopping silently after a fixed number of + // rounds is a skip wearing a loop: whatever had not propagated yet simply + // was not there, and nothing said so. + settled := false + for round := 0; round < maxReaderFlowRounds; round++ { if !r.grow() { + settled = true break } } + if !settled { + r.unresolved = append(r.unresolved, "reader flow did not reach a fixed point within "+ + strconv.Itoa(maxReaderFlowRounds)+" rounds, so what else may hold a reader is unknown") + } + r.checkEscapes() r.checkInvocations() return r } @@ -507,6 +625,148 @@ func (r *readerIdentity) holds(expr ast.Expr) bool { return false } +// checkEscapes finds every reader VALUE in a position this analysis does not +// model, and reports it. +// +// The modelled flows are the ones grow() propagates through: an assignment to +// a name, a variable specification, a struct literal field, an argument to one +// of this package's own functions, a conversion, and being called. A reader +// anywhere else — a map literal, an argument to a dependency, a generic +// instantiation, a return value, a channel — is a reader this model loses, and +// losing it silently is how a map of functions and a reflect.Value.Call both +// read the environment while both gates reported nothing. +// +// Refusing rather than enumerating is the point. Teaching the model about maps +// and reflection and instantiation would leave the NEXT way of moving a +// function value silent again; refusing means it fails the suite instead, +// until somebody models it or decides it is fine. +func (r *readerIdentity) checkEscapes() { + for _, file := range r.files() { + // The stack is kept by hand rather than with a defer in the visitor: + // a defer per AST node is a defer per node of the whole package, and + // that alone cost this suite a minute and a half under -race. + var stack []ast.Node + ast.Inspect(file, func(n ast.Node) bool { + if n == nil { + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + return true + } + var parent ast.Node + if len(stack) > 0 { + parent = stack[len(stack)-1] + } + stack = append(stack, n) + + expr, ok := n.(ast.Expr) + if !ok { + return true + } + // A DECLARATION is not a flow: `func env(...)` names the reader, + // it does not move it. Only a use can carry one somewhere. + if ident, isIdent := expr.(*ast.Ident); isIdent && r.pkg.TypesInfo.Defs[ident] != nil { + return true + } + if !r.holds(expr) || r.modelledFlow(expr, parent, stack) { + return true + } + r.unresolved = append(r.unresolved, + r.pkg.Fset.Position(expr.Pos()).String()+": a reader value flows into "+ + describeNode(parent)+", which this analysis does not model, so where it is "+ + "called and with what key is unknown") + return true + }) + } + sort.Strings(r.unresolved) +} + +// modelledFlow reports whether a reader expression sits in a position grow() +// propagates through. +func (r *readerIdentity) modelledFlow(expr ast.Expr, parent ast.Node, stack []ast.Node) bool { + switch p := parent.(type) { + case nil: + return false + case *ast.ParenExpr: + return true // judged at the enclosing position + case *ast.SelectorExpr: + return true // `os.Getenv` is judged as the selector, not as its halves + case *ast.AssignStmt: + // Only when the destination is a name this model can mark. + for i, rhs := range p.Rhs { + if rhs == expr && i < len(p.Lhs) { + return r.object(p.Lhs[i]) != nil + } + } + return false + case *ast.ValueSpec: + for _, value := range p.Values { + if value == expr { + return true + } + } + return false + case *ast.KeyValueExpr: + // A struct field is modelled; a map entry is not — the key decides + // which element a call reads, and that is not resolved here. + if p.Value != expr { + return false + } + for i := len(stack) - 1; i >= 0; i-- { + if lit, ok := stack[i].(*ast.CompositeLit); ok { + _, isStruct := structTypeOf(r.pkg.TypesInfo.TypeOf(lit)) + return isStruct + } + } + return false + case *ast.CallExpr: + if p.Fun == expr { + return true // being called IS the invocation + } + // A conversion carries the reader through. + if tv, ok := r.pkg.TypesInfo.Types[p.Fun]; ok && tv.IsType() { + return true + } + // An argument, only to one of this package's own functions, whose + // parameter grow() marks. Anything else — a dependency, a generic + // instantiation whose callee is an IndexExpr, a call through a value — + // takes the reader somewhere with no body here to follow. + fn, isFunc := r.object(p.Fun).(*types.Func) + return isFunc && fn.Pkg() == r.pkg.Types + } + return false +} + +// describeNode names a syntax position for a finding. +func describeNode(n ast.Node) string { + switch node := n.(type) { + case nil: + return "an unattached position" + case *ast.CallExpr: + if ident, ok := unparen(node.Fun).(*ast.Ident); ok { + return "a call to " + ident.Name + } + if selector, ok := unparen(node.Fun).(*ast.SelectorExpr); ok { + return "a call to " + selector.Sel.Name + } + if _, ok := unparen(node.Fun).(*ast.IndexExpr); ok { + return "a generic instantiation" + } + return "a call through a value" + case *ast.KeyValueExpr: + return "a keyed composite element" + case *ast.CompositeLit: + return "a composite literal" + case *ast.ReturnStmt: + return "a return" + case *ast.SendStmt: + return "a channel send" + case *ast.IndexExpr: + return "an index expression" + } + return "an unmodelled position" +} + // checkInvocations requires every resolved reader invocation's key to be // traceable to constants. A keyless reader reads the whole environment, so // there is no key: that read is the post-boot gate's finding, not this one's. @@ -588,17 +848,7 @@ func (r *readerIdentity) field(lit *ast.CompositeLit, name string) types.Object } // decl is the declaration of one of this package's functions. -func (r *readerIdentity) decl(fn *types.Func) *ast.FuncDecl { - for _, file := range r.files() { - for _, d := range file.Decls { - if decl, ok := d.(*ast.FuncDecl); ok && decl.Name != nil && - r.pkg.TypesInfo.Defs[decl.Name] == fn { - return decl - } - } - } - return nil -} +func (r *readerIdentity) decl(fn *types.Func) *ast.FuncDecl { return r.decls[fn] } // keyTracer follows an environment key back to the constants that reach it. type keyTracer struct { @@ -848,3 +1098,34 @@ func structTypeOf(t types.Type) (*types.Struct, bool) { structType, ok := t.Underlying().(*types.Struct) return structType, ok } + +// mentionsFunction reports whether any finding falls on a line belonging to +// the named function in the fixture source. +func mentionsFunction(t *testing.T, source, findings, name string) bool { + t.Helper() + lines := strings.Split(source, "\n") + start, end := -1, len(lines) + for i, line := range lines { + if strings.HasPrefix(line, "func "+name) || strings.HasPrefix(line, "func "+name+"[") { + start = i + 1 + continue + } + if start > 0 && line == "}" { + end = i + 1 + break + } + } + if start < 0 { + t.Fatalf("the fixture has no function %s", name) + } + for _, finding := range strings.Split(findings, "\n") { + parts := strings.Split(finding, ":") + if len(parts) < 2 { + continue + } + if at, err := strconv.Atoi(parts[1]); err == nil && at >= start && at <= end { + return true + } + } + return false +} From 8232023f815ed78ac3c7bc9a1b88480565fcbb5c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:13:15 -0400 Subject: [PATCH 2/3] test: ask the fixpoint's refusal directly, and record the no-skip rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #53. The escape rule and the per-site assertions landed in the previous commit; what had no test was the other half of "a fixed point or a refusal". No fixture can exhaust sixty-four rounds — that is what the budget is for — so the branch that turns exhaustion into a finding was a claim rather than a guarantee, which is the shape of the defect it exists to pin: the previous loop stopped after sixteen rounds and said nothing, so a partial reader set and a complete one were indistinguishable to every caller. The budget is named (buildReaderIdentityWithin) and the branch is asked at ONE round against a four-link alias chain declared in reverse, so a single forward pass resolves one link and no more. Its control is the same fixture at the full budget: without that, the test would pass on a tree reporting exhaustion unconditionally. AGENTS.md records the two rules, beside the one about resolving rather than matching that they complete: these gates have no skip, and their fixtures are asserted per site rather than in aggregate. Mutations, red at this tree: - X-EXH exhaustion appends no finding → the new test - X-SKIP modelledFlow returns true → EscapeViaMap/Reflect/Generic/Slice silent - X-UNIX x/sys/unix dropped from the seeds → WrapperRead not a read - X-DEDUPE one finding per shape → claim path at 1 distinct site; the occurrence count it replaced saw 2 and PASSED - X-ROOTS address-taken roots dropped → fixtureMountedRead, fixtureStoredRead and the closure unwalked; main's three aggregate reader names all still reported, so that assertion passed Suite 186.6s under -race, vet and gofmt clean, go mod verify all modules verified, go mod tidy no change. Hermetic as always: nothing here was run against a real composition. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 33 +++++++++++++++++++++ facade_claim_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2822c92..29cf248 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,39 @@ embedding it. here, resolve rather than match: `facade_claim_test.go` and `environment_boundary_test.go` carry the compiled fixtures for every spelling that beat an earlier version. +- **Those two gates have no skip, for the same reason `typeWalk` has none.** + Resolving instead of matching closed the spellings and left the last form of + the defect in place: a reader value moved somewhere the propagation had no + edge for — a map of functions, a `reflect.Value.Call`, a generic + instantiation, a slice — produced *silence* from both gates, which reads + exactly like a clean package. So the default is inverted. The modelled flows + are the ones the propagation follows (a name, a variable specification, a + struct-literal field, a conversion, an argument to one of this package's own + functions, being called) and a reader in any other position is an + **unresolved finding**. Teaching it about maps and reflection was explicitly + not the fix: the author picks the carrier, so the next one would be silent + again. The four spellings are committed as fixtures because they are evidence + for the rule, not the rule. + A dependency's body is not here, so a wrapper of the syscall layer is modelled + by **summary** — `golang.org/x/sys/unix` beside `os` and `syscall`, which is + where every environment read in Go bottoms out. A wrapper in a fourth package + is the residual, and the escape rule is what keeps that residual from being + silent whenever a reader *value* is what travels into it. + The propagation reaches a fixed point **or refuses**: stopping after a fixed + number of rounds was a skip wearing a loop, since whatever had not propagated + yet simply was not there and nothing said so. + `TestTheReaderFixpointRefusesExhaustionRatherThanStoppingQuietly` asks that + branch directly against a reverse-declared alias chain at a one-round budget, + because no fixture can exhaust sixty-four rounds — the same reason + `TestTheWalkRefusesWhatItCannotFinish` exists. +- **A gate's fixtures are asserted per site, never in aggregate.** Two of these + asserted totals and both were satisfiable with a shape missing: three reader + *names* across seven serving paths passed with the mounted handler and the + stored method value gone, because another path reported the same reader; and + counting occurrences of a claim path passed on a single finding, which names + the shape twice — once as the constant's value and once as the shape matched. + They assert the function each read sits in, and the distinct sites a shape was + caught at. - Resolving a value in `loadConfig` is half the rule; the other half is a refusal in `validate()` that fires **whether or not the feature using it is switched on**. The `api.consumes` projection was resolved at boot and decoded diff --git a/facade_claim_test.go b/facade_claim_test.go index bef45b6..cc8b9f0 100644 --- a/facade_claim_test.go +++ b/facade_claim_test.go @@ -478,6 +478,14 @@ func newReaderIdentity(pkg *packages.Package) *readerIdentity { } func buildReaderIdentity(pkg *packages.Package) *readerIdentity { + return buildReaderIdentityWithin(pkg, maxReaderFlowRounds) +} + +// buildReaderIdentityWithin is the analysis with the round budget named, so the +// refusal below can be asked directly. No fixture can exhaust sixty-four +// rounds — that is what a budget is for — and a branch no test reaches is a +// claim, not a guarantee. +func buildReaderIdentityWithin(pkg *packages.Package, rounds int) *readerIdentity { r := &readerIdentity{pkg: pkg, readerAliases: map[types.Object]bool{}, decls: map[*types.Func]*ast.FuncDecl{}} for _, file := range r.files() { for _, d := range file.Decls { @@ -493,7 +501,7 @@ func buildReaderIdentity(pkg *packages.Package) *readerIdentity { // rounds is a skip wearing a loop: whatever had not propagated yet simply // was not there, and nothing said so. settled := false - for round := 0; round < maxReaderFlowRounds; round++ { + for round := 0; round < rounds; round++ { if !r.grow() { settled = true break @@ -501,7 +509,7 @@ func buildReaderIdentity(pkg *packages.Package) *readerIdentity { } if !settled { r.unresolved = append(r.unresolved, "reader flow did not reach a fixed point within "+ - strconv.Itoa(maxReaderFlowRounds)+" rounds, so what else may hold a reader is unknown") + strconv.Itoa(rounds)+" rounds, so what else may hold a reader is unknown") } r.checkEscapes() r.checkInvocations() @@ -1129,3 +1137,59 @@ func mentionsFunction(t *testing.T, source, findings, name string) bool { } return false } + +// The fixpoint's refusal, asked directly. +// +// "Reach a fixed point or refuse exhaustion" is half a rule until the refusing +// half has a test, and no fixture can supply one against the real budget: the +// budget exists precisely so that nothing in a real package reaches it. So the +// budget is named, and the branch is asked at one round against a fixture whose +// reader identities need several — the chain is declared in REVERSE, so a +// single forward pass resolves one link and no more. +// +// The control is the same fixture at the full budget. Without it this test +// would pass on a tree that reported exhaustion unconditionally, which is the +// mirror of the defect it pins: the previous loop stopped after sixteen rounds +// and said nothing, so a partial reader set and a complete one were +// indistinguishable to every caller. +func TestTheReaderFixpointRefusesExhaustionRatherThanStoppingQuietly(t *testing.T) { + dir, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + fixture := filepath.Join(dir, "zz_reader_chain_fixture.go") + + source := `package solution + +import "os" + +// Declared in reverse, so each round of the propagation resolves exactly one +// link: four links need four rounds, and one round leaves three unknown. +var chainReader3 = chainReader2 +var chainReader2 = chainReader1 +var chainReader1 = chainReader0 +var chainReader0 = os.Getenv + +func ChainedRead() string { return chainReader3("ANYTHING") } +` + + const refusal = "did not reach a fixed point" + checked := false + for _, pkg := range gatePackages(t, map[string][]byte{fixture: []byte(source)}) { + if pkg.PkgPath != "github.com/codefly-dev/solution-runtime-go" { + continue + } + checked = true + starved := strings.Join(buildReaderIdentityWithin(pkg, 1).unresolved, "\n") + if !strings.Contains(starved, refusal) { + t.Errorf("the propagation ran out of rounds and reported no finding, so a partial reader set reads as a complete one.\nfindings:\n%s", starved) + } + settled := strings.Join(buildReaderIdentityWithin(pkg, maxReaderFlowRounds).unresolved, "\n") + if strings.Contains(settled, refusal) { + t.Errorf("the propagation does not settle within %d rounds on this fixture, so the refusal above proves nothing.\nfindings:\n%s", maxReaderFlowRounds, settled) + } + } + if !checked { + t.Fatal("the fixture package was not loaded, so neither branch was asked") + } +} From 105fcb281b1b3fafbe2230bc26632595ed3b18f7 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:46:30 -0400 Subject: [PATCH 3/3] test: the whole-environment shape is held by ExportedLateHost now that discoverHostModule is deleted e80369a deleted the saas-host mode and with it discoverHostModule; main's fixture scans os.Environ inside ExportedLateHost directly, so the per-shape assertion names that holder. Removing it from the list makes the test fail, so the shape is still asserted. Co-Authored-By: Claude Opus 5.5 --- environment_boundary_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment_boundary_test.go b/environment_boundary_test.go index acfad40..f9c8620 100644 --- a/environment_boundary_test.go +++ b/environment_boundary_test.go @@ -192,7 +192,7 @@ func ExportedLateHost() string { "fixtureMountedRead", // reached only as a handler value "fixtureStoredRead", // reached only through a stored method value "servingAliasRead", // an aliased reader with a constant key - "discoverHostModule", // the whole environment, via an exported wrapper + "ExportedLateHost", // the whole environment, scanned by an exported function } { if !holders[want] { t.Errorf("no post-boot read reported inside %s, so that shape is unwalked; reported: %v", want, holders)