package rules import ( "fmt" "go/ast" "go/token" "strings" ) func init() { registered = append(registered, Rule{ ID: "wire.hook-bad-signature", Severity: SeverityWarning, Module: "wire", Check: checkHookSignature, }, Rule{ ID: "wire.hook-outside-beforestart", Severity: SeverityWarning, Module: "wire", Check: checkHookBodyShape, }, Rule{ ID: "wire.route-specific-after-param", Severity: SeverityWarning, Module: "web", Check: checkRouteOrdering, }, ) } // isHookName reports whether name matches the with convention. // A hook is "with" + capital letter + anything, so "with" and "withers" // don't qualify but "withUsers", "withAuth", "withSuperAdminSeed" do. func isHookName(name string) bool { if len(name) < 5 || !strings.HasPrefix(name, "with") { return false } c := name[4] return c >= 'A' && c <= 'Z' } func checkHookSignature(c *Context) []Finding { var findings []Finding for _, decl := range c.File.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Recv != nil || !isHookName(fn.Name.Name) { continue } params := fn.Type.Params if params == nil || len(params.List) == 0 { findings = append(findings, Finding{ Message: fmt.Sprintf("hook %s has no parameters; expected first param launcher.Launcher", fn.Name.Name), Hint: "func with(lc launcher.Launcher, srv server.Server, deps...)", Line: c.Fset.Position(fn.Pos()).Line, }) continue } first := exprName(params.List[0].Type) if !strings.Contains(first, "Launcher") { findings = append(findings, Finding{ Message: fmt.Sprintf("hook %s first param is %q; expected launcher.Launcher", fn.Name.Name, first), Hint: "Always pass the launcher as the first argument so the hook can register BeforeStart.", Line: c.Fset.Position(fn.Pos()).Line, }) } } return findings } func checkHookBodyShape(c *Context) []Finding { var findings []Finding for _, decl := range c.File.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Recv != nil || fn.Body == nil || !isHookName(fn.Name.Name) { continue } for _, stmt := range fn.Body.List { if !suspiciousTopLevelStmt(stmt) { continue } findings = append(findings, Finding{ Message: fmt.Sprintf("hook %s performs wiring outside lc.BeforeStart", fn.Name.Name), Hint: "Move repo/service/handler construction and route registration inside lc.BeforeStart(func() error { ... return nil }).", Line: c.Fset.Position(stmt.Pos()).Line, }) } } return findings } // suspiciousTopLevelStmt is true for top-level statements inside a hook that // look like wiring work (route registration or component construction) but // are not the canonical lc.BeforeStart or lc.Append calls. func suspiciousTopLevelStmt(stmt ast.Stmt) bool { if expr, ok := stmt.(*ast.ExprStmt); ok { if call, ok := expr.X.(*ast.CallExpr); ok { name := exprName(call.Fun) if strings.HasSuffix(name, ".BeforeStart") || strings.HasSuffix(name, ".Append") || strings.HasSuffix(name, ".Run") { return false } } } suspicious := false ast.Inspect(stmt, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } name := exprName(call.Fun) switch { case strings.HasSuffix(name, ".Get"), strings.HasSuffix(name, ".Post"), strings.HasSuffix(name, ".Put"), strings.HasSuffix(name, ".Patch"), strings.HasSuffix(name, ".Delete"), strings.HasSuffix(name, ".New"): suspicious = true return false } return true }) return suspicious } type routeReg struct { method string path string pos token.Pos } func collectRoutes(file *ast.File) []routeReg { var routes []routeReg 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 } switch sel.Sel.Name { case "Get", "Post", "Put", "Patch", "Delete": default: return true } if len(call.Args) < 1 { return true } lit, ok := call.Args[0].(*ast.BasicLit) if !ok || lit.Kind != token.STRING { return true } routes = append(routes, routeReg{ method: sel.Sel.Name, path: strings.Trim(lit.Value, `"`), pos: call.Pos(), }) return true }) return routes } func checkRouteOrdering(c *Context) []Finding { routes := collectRoutes(c.File) var findings []Finding for i := 0; i < len(routes); i++ { for j := i + 1; j < len(routes); j++ { if routes[i].method != routes[j].method { continue } if !routesConflict(routes[i].path, routes[j].path) { continue } findings = append(findings, Finding{ Message: fmt.Sprintf("%s %s registered before %s %s — chi will bind %q to a path parameter and the literal route becomes unreachable", routes[i].method, routes[i].path, routes[j].method, routes[j].path, conflictingSegment(routes[i].path, routes[j].path), ), Hint: "Register literal-segment routes before parametrised siblings that share the same prefix.", Line: c.Fset.Position(routes[i].pos).Line, }) } } return findings } // routesConflict reports whether `param` is a parametrised route that would // shadow `literal` (a route differing only in one segment being a literal // where param has {placeholder}). func routesConflict(param, literal string) bool { p := strings.Split(strings.Trim(param, "/"), "/") l := strings.Split(strings.Trim(literal, "/"), "/") if len(p) != len(l) { return false } hasParam := false for i := range p { pIsParam := isParamSeg(p[i]) lIsParam := isParamSeg(l[i]) if pIsParam { if lIsParam { return false } hasParam = true continue } if p[i] != l[i] { return false } } return hasParam } func isParamSeg(s string) bool { return strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") } func conflictingSegment(param, literal string) string { p := strings.Split(strings.Trim(param, "/"), "/") l := strings.Split(strings.Trim(literal, "/"), "/") for i := range p { if isParamSeg(p[i]) && !isParamSeg(l[i]) { return l[i] } } return "" }