| new file mode 100644 |
| index 0000000..7047605 |
| --- /dev/null |
| +++ b/internal/memoriatest/routes_test.go |
| @@ -0,0 +1,150 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Gate 17, spec §10: no route exists that the spec has not listed. |
| +// |
| +// This is the regression gate for the sibling-facing push surface draft 1 |
| +// shipped by accident, and — since decision 14 — the gate that makes |
| +// "memoria is a search page, not a chat" enforceable rather than prose. A |
| +// review pointed out that §10 had claimed this gate for three drafts while |
| +// nothing in this package walked the router: the guard-written-to-pass |
| +// shape one step worse, a guard not written at all. |
| +// |
| +// It lives in its own file rather than gates_test.go only because it needs |
| +// the go/ast imports and nothing else here does. |
| +// |
| +// It reads the AST rather than grepping, so a path in a comment or an |
| +// unrelated string cannot trip it, and it fails a route whose pattern is |
| +// not a string literal — otherwise the way past this gate is to compute the |
| +// path. What it does NOT catch: a route registered by a helper in a package |
| +// this walk does not cover, or on a receiver named something other than r |
| +// or mux. The honest claim is "a route added the way every current route is |
| +// added is caught", and the declared list below is the real control. |
| +// |
| +// MUTATION-TESTED 2026-09-04: adding `r.Post("/chat", a.index)` to the |
| +// authenticated group in app.go fails with `undeclared route: POST /chat`. |
| +// Replacing `r.Get("/", a.index)` with a `root` const fails with `route |
| +// pattern is not a literal`. Pointing the walk at an empty directory fails |
| +// the seen == 0 check rather than passing. All three reverted. |
| +func TestNoUndeclaredRoutes(t *testing.T) { |
| + // The person-facing and connector surface, spelled out. §9.1's |
| + // /query is deliberately NOT here: the app serves GET / and it is the |
| + // spec that is wrong, which §8.4 now records. Adding to this list is |
| + // a decision, which is the point of it being a list. |
| + declared := map[string]bool{ |
| + "/signin": true, |
| + "/": true, |
| + "/documents": true, |
| + "/documents/{id}": true, |
| + "/documents/{id}/delete": true, |
| + "/auth/begin": true, |
| + "/auth/callback": true, |
| + "/auth/verify": true, |
| + "/signout": true, |
| + // Mounts, not endpoints. |
| + "GET /static/": true, |
| + "/auth/": true, |
| + "POST /signout": true, |
| + } |
| + // Router method name -> index of the pattern argument. |
| + patternArg := map[string]int{ |
| + "Get": 0, "Post": 0, "Put": 0, "Patch": 0, "Delete": 0, |
| + "Head": 0, "Options": 0, "Connect": 0, "Trace": 0, |
| + "Handle": 0, "HandleFunc": 0, "Mount": 0, |
| + "Method": 1, "MethodFunc": 1, |
| + } |
| + |
| + var problems []string |
| + seen := 0 |
| + |
| + inspect := func(path string, fset *token.FileSet, file *ast.File) { |
| + ast.Inspect(file, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + sel, ok := call.Fun.(*ast.SelectorExpr) |
| + if !ok { |
| + return true |
| + } |
| + recv, ok := sel.X.(*ast.Ident) |
| + if !ok || (recv.Name != "r" && recv.Name != "mux") { |
| + return true |
| + } |
| + idx, ok := patternArg[sel.Sel.Name] |
| + if !ok || len(call.Args) <= idx { |
| + return true |
| + } |
| + seen++ |
| + line := fset.Position(call.Pos()).Line |
| + lit, ok := call.Args[idx].(*ast.BasicLit) |
| + if !ok || lit.Kind != token.STRING { |
| + problems = append(problems, fmt.Sprintf( |
| + "%s:%d: route pattern is not a literal, so this gate cannot read it", |
| + path, line)) |
| + return true |
| + } |
| + pattern, err := strconv.Unquote(lit.Value) |
| + if err != nil { |
| + problems = append(problems, fmt.Sprintf( |
| + "%s:%d: unreadable route pattern %s", path, line, lit.Value)) |
| + return true |
| + } |
| + if !declared[pattern] { |
| + problems = append(problems, fmt.Sprintf( |
| + "%s:%d: undeclared route: %s %s", |
| + path, line, strings.ToUpper(sel.Sel.Name), pattern)) |
| + } |
| + return true |
| + }) |
| + } |
| + |
| + walk := func(root string) error { |
| + return filepath.Walk(root, func(p string, info os.FileInfo, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { |
| + return nil |
| + } |
| + fset := token.NewFileSet() |
| + file, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution) |
| + if err != nil { |
| + return err |
| + } |
| + inspect(p, fset, file) |
| + return nil |
| + }) |
| + } |
| + |
| + for _, root := range []string{ |
| + filepath.Join("..", "memoria"), |
| + filepath.Join("..", "..", "cmd"), |
| + } { |
| + if err := walk(root); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + // A count that fails is not a count of zero (AGENTS.md). If the walk |
| + // found no registrations at all then the finder is broken, and a green |
| + // gate over an empty set is this codebase's worst failure shape. |
| + if seen == 0 { |
| + t.Fatal("found no route registrations at all: the walk is broken, not the app clean") |
| + } |
| + |
| + for _, p := range problems { |
| + t.Error(p) |
| + } |
| +} |