Files
member-console/internal/lint/lint.go
T
cgalo5758 408fa6f5a6 Add page anatomy parts and UI quality gate
- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge,
  emptyState) parsed into every template set
- Add anatomy lint rules with a shrinking allowlist and screen-coverage
  check
- Add make screens capture harness with contact sheets and baseline diff
- Compose member and FedWiki regions server-side so pages arrive
  complete
- Rebuild Domains and Integrations on the parts as pilots
2026-08-30 04:05:31 -05:00

701 lines
21 KiB
Go

// Package lint implements the M7f.1 audit & integrity guards.
//
// It surfaces template/route mismatches that the Go compiler cannot catch
// because URLs and DOM IDs live in template strings:
//
// - dead routes: {{ routeURL "/foo" ... }} with no registered handler
// - dead swap targets: hx-target="#X" with no matching id="X" anywhere
// - stale ?tab= references: the SPA query param was retired in M7d
// - banned interpolated literal URLs: must use {{ routeURL }} instead
//
// See status/milestones.md (M7f.1) and docs/operator-ux-conventions.md §9.
package lint
import (
"fmt"
"go/ast"
"go/parser"
"go/scanner"
"go/token"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"text/template"
"text/template/parse"
"golang.org/x/net/html"
)
// Config controls which directories the linter scans.
type Config struct {
// ServerDir is the path to the Go source tree containing
// mux.HandleFunc registrations. Default: internal/server.
ServerDir string
// TemplateDir is the path to the embedded templates directory.
// Default: internal/embeds/templates.
TemplateDir string
// WalkthroughDir is the path to the operator-walkthroughs test
// directory. When set, enables the M7f.1 follow-up coverage rule:
// every operator mutation route must be claimed by at least one
// walkthrough header comment. Default: test/e2e/operator-walkthroughs.
// Set to "-" to disable this rule.
WalkthroughDir string
// AllowlistPath is the page-anatomy allowlist (templates not yet
// rebuilt on the shared parts); "-" disables the anatomy rules.
AllowlistPath string
// ManifestPath is the screens manifest for the screen-coverage rule;
// "-" disables it.
ManifestPath string
}
// Violation is a single rule failure with source location.
type Violation struct {
File string
Line int
Rule string // short identifier, e.g. "dead-route"
Message string
}
// Result is the outcome of a lint run.
type Result struct {
Violations []Violation
RoutesScanned int
URLRefsScanned int
// Allowlisted counts anatomy violations in allowlisted templates,
// reported but not failed.
Allowlisted int
}
// Run executes all configured rules and returns the aggregated result.
func Run(cfg Config) (*Result, error) {
if cfg.ServerDir == "" {
cfg.ServerDir = "internal/server"
}
if cfg.TemplateDir == "" {
cfg.TemplateDir = "internal/embeds/templates"
}
if cfg.WalkthroughDir == "" {
cfg.WalkthroughDir = "test/e2e/operator-walkthroughs"
}
if cfg.AllowlistPath == "" {
cfg.AllowlistPath = "internal/lint/anatomy_allowlist.txt"
}
if cfg.ManifestPath == "" {
cfg.ManifestPath = "test/e2e/screens/manifest.go"
}
routes, err := extractRoutes(cfg.ServerDir)
if err != nil {
return nil, fmt.Errorf("extract routes: %w", err)
}
tinfo, err := extractTemplates(cfg.TemplateDir)
if err != nil {
return nil, fmt.Errorf("extract templates: %w", err)
}
res := &Result{
RoutesScanned: len(routes),
URLRefsScanned: len(tinfo.urlRefs),
}
res.Violations = append(res.Violations, ruleDeadRoutes(routes, tinfo)...)
res.Violations = append(res.Violations, ruleDeadTargets(tinfo)...)
res.Violations = append(res.Violations, ruleStaleTabRefs(cfg.TemplateDir, cfg.ServerDir)...)
res.Violations = append(res.Violations, ruleInterpolatedLiterals(tinfo)...)
res.Violations = append(res.Violations, ruleRetiredResourceKeys(filepath.Dir(cfg.ServerDir))...)
res.Violations = append(res.Violations, ruleRawErrorRender(cfg.ServerDir)...)
if cfg.WalkthroughDir != "-" {
res.Violations = append(res.Violations, ruleWalkthroughCoverage(routes, cfg.WalkthroughDir)...)
}
if cfg.AllowlistPath != "-" {
goDirs := []string{cfg.ServerDir}
if integrations, _ := filepath.Glob(filepath.Join(filepath.Dir(cfg.ServerDir), "integrations", "*", "web")); len(integrations) > 0 {
goDirs = append(goDirs, integrations...)
}
anatomy, allowlisted := ruleAnatomy(anatomyTemplateDirs(cfg), cfg.AllowlistPath, pageBodyTemplates(goDirs))
res.Violations = append(res.Violations, anatomy...)
res.Allowlisted = allowlisted
}
if cfg.ManifestPath != "-" {
res.Violations = append(res.Violations, ruleScreenCoverage(routes, cfg.ManifestPath)...)
}
sort.Slice(res.Violations, func(i, j int) bool {
if res.Violations[i].File != res.Violations[j].File {
return res.Violations[i].File < res.Violations[j].File
}
return res.Violations[i].Line < res.Violations[j].Line
})
return res, nil
}
// --- retired resource keys ---------------------------------------------------
// retiredResourceKeys maps a retired bare resource key to its namespaced
// replacement. Provider-owned keys are key-prefixed (`<key>_*`) per the
// integration authoring guide (docs/building-an-integration.md); the bare forms must not reappear in Go
// `ResourceKey:` assignments or raw-SQL `resource_key = '...'` predicates,
// where a stale string silently reads/writes a non-existent entitlement
// resource (the compiler cannot see it).
var retiredResourceKeys = map[string]string{
"sites": "fedwiki_sites",
}
// ruleRetiredResourceKeys flags Go source under dir (internal/, including
// tests) that uses a retired bare resource key in a ResourceKey assignment or
// a raw-SQL resource_key predicate. Migration SQL legitimately references the
// old key during the rename, so only .go files are scanned.
func ruleRetiredResourceKeys(dir string) []Violation {
var v []Violation
for old, replacement := range retiredResourceKeys {
goRe := regexp.MustCompile(`ResourceKey:[^\n]*"` + regexp.QuoteMeta(old) + `"`)
sqlRe := regexp.MustCompile(`resource_key\s*=\s*'` + regexp.QuoteMeta(old) + `'`)
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
for i, line := range strings.Split(string(src), "\n") {
if goRe.MatchString(line) || sqlRe.MatchString(line) {
v = append(v, Violation{
File: path,
Line: i + 1,
Rule: "retired-resource-key",
Message: fmt.Sprintf("retired resource key %q — use the namespaced %q (docs/building-an-integration.md)", old, replacement),
})
}
}
return nil
})
}
return v
}
// --- raw error rendering -------------------------------------------------------
// rawErrorCallRe matches an error's raw text — `err.Error()` on any
// identifier ending in err/Err (err, attErr, dbErr, …) — appearing on a line.
var rawErrorCallRe = regexp.MustCompile(`\b(?:err|\w*Err)\.Error\(\)`)
// uiErrorSinkRe matches the calls that put text in front of a user:
// render* helpers, the toast helpers, and http.Error.
var uiErrorSinkRe = regexp.MustCompile(`\b(?:[Rr]ender\w*|fireErrorToast|fireSuccessToast|http\.Error)\(`)
// ruleRawErrorRender flags Go lines under the server dir (skipping tests)
// where a raw err.Error() is concatenated or passed into a UI sink — the
// pattern that leaks driver text ("SQLSTATE 23505 …") into rendered pages
// and toasts. Lines that mention slog. are exempt: logging the real error
// is exactly what handlers should do (docs/operator-ux-conventions.md §4 —
// details go to logs, the user gets FieldErrors via web.FieldErrorsFromDB
// or a generic message).
func ruleRawErrorRender(dir string) []Violation {
var v []Violation
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
for i, line := range strings.Split(string(src), "\n") {
if !rawErrorCallRe.MatchString(line) || !uiErrorSinkRe.MatchString(line) {
continue
}
if strings.Contains(line, "slog.") {
continue
}
v = append(v, Violation{
File: path,
Line: i + 1,
Rule: "raw-error-render",
Message: "raw err.Error() rendered to the UI — translate constraint violations via web.FieldErrorsFromDB (422 + FieldErrors) and slog + generic message otherwise (docs/operator-ux-conventions.md §4)",
})
}
return nil
})
return v
}
// --- route extraction --------------------------------------------------------
type registeredRoute struct {
Method string
Path string
File string
Line int
}
// extractRoutes walks Go source under dir and collects every
// mux.HandleFunc("METHOD /path", ...) call. The first argument's literal
// string is the source of truth — derived/computed pattern strings are
// skipped (the Go side doesn't have the silent-failure problem; mismatches
// surface as 404s at request time).
func extractRoutes(dir string) ([]registeredRoute, error) {
var routes []registeredRoute
fset := token.NewFileSet()
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "HandleFunc" {
return true
}
if len(call.Args) < 1 {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
pattern := strings.Trim(lit.Value, "\"`")
method, p := splitMethodPath(pattern)
pos := fset.Position(lit.Pos())
routes = append(routes, registeredRoute{
Method: method,
Path: p,
File: pos.Filename,
Line: pos.Line,
})
return true
})
return nil
})
if err != nil {
return nil, err
}
return routes, nil
}
// splitMethodPath parses "METHOD /path" or just "/path". Go 1.22+ ServeMux
// patterns use the former; older mux registrations use the latter.
func splitMethodPath(pat string) (method, path string) {
parts := strings.SplitN(pat, " ", 2)
if len(parts) == 2 && isHTTPMethod(parts[0]) {
return parts[0], parts[1]
}
return "", pat
}
func isHTTPMethod(s string) bool {
switch s {
case "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS":
return true
}
return false
}
// --- template extraction -----------------------------------------------------
type urlRef struct {
Pattern string // first arg to routeURL
File string
Line int
}
type interpolatedLiteralRef struct {
Attr string // hx-get / hx-post / data-action-url
Value string // raw attribute value including {{...}}
File string
Line int
}
type targetRef struct {
Target string // hx-target stripped of leading #
File string
Line int
}
type idDecl struct {
ID string
File string
Line int
}
type templateInfo struct {
urlRefs []urlRef
interpolatedLiteralRefs []interpolatedLiteralRef
targets []targetRef
ids []idDecl
}
// extractTemplates walks template files under dir and collects URL refs,
// swap-target refs, and id declarations. Two passes per file:
//
// 1. Go template parser → find {{ routeURL "..." ... }} actions.
// 2. HTML tokenizer → find hx-target / id / interpolated hx-* attributes.
func extractTemplates(dir string) (templateInfo, error) {
var info templateInfo
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
if err := scanTemplateActions(path, string(src), &info); err != nil {
return fmt.Errorf("scan template actions in %s: %w", path, err)
}
scanTemplateHTML(path, string(src), &info)
return nil
})
return info, err
}
func scanTemplateActions(path, src string, info *templateInfo) error {
// Use text/template (not bare parse) so all built-in funcs
// (eq, gt, index, len, not, etc.) are registered automatically.
// Stubs are needed only for the project's custom funcs.
tmpl, err := template.New(path).Funcs(template.FuncMap{
"routeURL": func(string, ...any) string { return "" },
"deploymentName": func() string { return "" },
"renderBody": func(string, any) string { return "" },
"fieldErr": func(_, _, _, _ string, _, _ any) string { return "" },
"stripeEntityURL": func(string, string) string { return "" },
}).Parse(src)
if err != nil {
fmt.Fprintf(os.Stderr, "lint: template parse error %s: %v\n", path, err)
return nil
}
for _, t := range tmpl.Templates() {
if t.Tree == nil || t.Tree.Root == nil {
continue
}
walkNodes(t.Tree.Root, func(n parse.Node) {
act, ok := n.(*parse.ActionNode)
if !ok || act.Pipe == nil {
return
}
for _, cmd := range act.Pipe.Cmds {
if len(cmd.Args) < 2 {
continue
}
id, ok := cmd.Args[0].(*parse.IdentifierNode)
if !ok || id.Ident != "routeURL" {
continue
}
sl, ok := cmd.Args[1].(*parse.StringNode)
if !ok {
continue
}
info.urlRefs = append(info.urlRefs, urlRef{
Pattern: sl.Text,
File: path,
Line: int(act.Line),
})
}
})
}
return nil
}
func walkNodes(n parse.Node, fn func(parse.Node)) {
if n == nil {
return
}
fn(n)
switch x := n.(type) {
case *parse.ListNode:
if x == nil {
return
}
for _, c := range x.Nodes {
walkNodes(c, fn)
}
case *parse.IfNode:
walkNodes(x.List, fn)
walkNodes(x.ElseList, fn)
case *parse.RangeNode:
walkNodes(x.List, fn)
walkNodes(x.ElseList, fn)
case *parse.WithNode:
walkNodes(x.List, fn)
walkNodes(x.ElseList, fn)
}
}
// hxAttrRe matches hx-get, hx-post, hx-put, hx-delete, hx-patch.
var hxAttrRe = regexp.MustCompile(`^hx-(get|post|put|delete|patch)$`)
func scanTemplateHTML(path, src string, info *templateInfo) {
tz := html.NewTokenizer(strings.NewReader(src))
line := 1
// Track line numbers ourselves; html.Tokenizer does not expose them.
// Rough approximation: count newlines in Raw() up to each token.
consumed := 0
for {
tt := tz.Next()
raw := tz.Raw()
// Count newlines in the bytes before this token.
// (Tokens emerge in order; the tokenizer reads forward.)
_ = consumed
consumed += len(raw)
line += strings.Count(string(raw), "\n")
switch tt {
case html.ErrorToken:
return
case html.StartTagToken, html.SelfClosingTagToken:
_, hasAttr := tz.TagName()
if !hasAttr {
continue
}
for {
key, val, more := tz.TagAttr()
k := string(key)
v := string(val)
if k == "id" && v != "" {
info.ids = append(info.ids, idDecl{ID: v, File: path, Line: line})
}
if k == "hx-target" && strings.HasPrefix(v, "#") {
info.targets = append(info.targets, targetRef{
Target: strings.TrimPrefix(v, "#"),
File: path,
Line: line,
})
}
if (hxAttrRe.MatchString(k) || k == "data-action-url") &&
strings.Contains(v, "{{") &&
!strings.Contains(v, "routeURL") &&
urlLooksLikeRoute(v) {
info.interpolatedLiteralRefs = append(info.interpolatedLiteralRefs, interpolatedLiteralRef{
Attr: k,
Value: v,
File: path,
Line: line,
})
}
if !more {
break
}
}
}
}
}
func urlLooksLikeRoute(v string) bool {
return strings.Contains(v, "/operator/") || strings.Contains(v, "/partials/")
}
// --- rules -------------------------------------------------------------------
func ruleDeadRoutes(routes []registeredRoute, info templateInfo) []Violation {
paths := make(map[string]struct{}, len(routes))
for _, r := range routes {
paths[r.Path] = struct{}{}
}
var v []Violation
for _, ref := range info.urlRefs {
if _, ok := paths[ref.Pattern]; !ok {
v = append(v, Violation{
File: ref.File,
Line: ref.Line,
Rule: "dead-route",
Message: fmt.Sprintf("routeURL pattern %q has no registered handler", ref.Pattern),
})
}
}
return v
}
func ruleDeadTargets(info templateInfo) []Violation {
ids := make(map[string]struct{}, len(info.ids))
for _, d := range info.ids {
ids[d.ID] = struct{}{}
}
var v []Violation
for _, t := range info.targets {
if _, ok := ids[t.Target]; !ok {
v = append(v, Violation{
File: t.File,
Line: t.Line,
Rule: "dead-swap-target",
Message: fmt.Sprintf("hx-target=%q has no matching id=%q in any template", "#"+t.Target, t.Target),
})
}
}
return v
}
var tabRefRe = regexp.MustCompile(`[?&]tab=`)
// ruleStaleTabRefs looks for ?tab= URL construction in Go string literals
// and HTML attribute values. Skips comments because operator.go documents
// the legacy redirect handler (intentional). Skips test files.
func ruleStaleTabRefs(templateDir, serverDir string) []Violation {
var v []Violation
// Go: tokenize, examine STRING literals only.
_ = filepath.WalkDir(serverDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
fset := token.NewFileSet()
file := fset.AddFile(path, fset.Base(), len(src))
var s scanner.Scanner
s.Init(file, src, nil, 0)
for {
pos, tok, lit := s.Scan()
if tok == token.EOF {
break
}
if tok == token.STRING && tabRefRe.MatchString(lit) {
v = append(v, Violation{
File: path,
Line: fset.Position(pos).Line,
Rule: "stale-tab-ref",
Message: "?tab= query param was retired in M7d (operator-mpa-conversion)",
})
}
}
return nil
})
// HTML: tokenize, examine attribute values only. html.Tokenizer skips
// comments automatically.
_ = filepath.WalkDir(templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
tz := html.NewTokenizer(strings.NewReader(string(src)))
line := 1
for {
tt := tz.Next()
raw := tz.Raw()
line += strings.Count(string(raw), "\n")
if tt == html.ErrorToken {
return nil
}
if tt != html.StartTagToken && tt != html.SelfClosingTagToken {
continue
}
tz.TagName()
for {
_, val, more := tz.TagAttr()
if tabRefRe.MatchString(string(val)) {
v = append(v, Violation{
File: path,
Line: line,
Rule: "stale-tab-ref",
Message: "?tab= query param was retired in M7d (operator-mpa-conversion)",
})
}
if !more {
break
}
}
}
})
return v
}
// ruleWalkthroughCoverage enforces that every operator mutation route
// (POST/PUT/DELETE/PATCH under /operator or /partials/operator) is
// claimed by at least one walkthrough header comment. Header format:
//
// // walkthrough: products
// // covers: POST /partials/operator/products
// // GET /partials/operator/products/{productID}/edit
//
// Walkthroughs are *_test.go files under WalkthroughDir.
func ruleWalkthroughCoverage(routes []registeredRoute, walkthroughDir string) []Violation {
if _, err := os.Stat(walkthroughDir); err != nil {
// Directory doesn't exist yet — coverage rule is disabled until
// 7f.2 walkthroughs land. Not a violation.
return nil
}
covered := make(map[string]struct{})
_ = filepath.WalkDir(walkthroughDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
for _, m := range walkthroughClaimRe.FindAllStringSubmatch(string(src), -1) {
covered[m[1]+" "+m[2]] = struct{}{}
}
return nil
})
var v []Violation
for _, r := range routes {
if !isMutationMethod(r.Method) || !isOperatorRoute(r.Path) {
continue
}
if _, ok := covered[r.Method+" "+r.Path]; !ok {
v = append(v, Violation{
File: r.File,
Line: r.Line,
Rule: "walkthrough-coverage",
Message: fmt.Sprintf("operator mutation route %q %q has no walkthrough claim in %s (add a `// covers:` line to a *_test.go header)", r.Method, r.Path, walkthroughDir),
})
}
}
return v
}
var walkthroughClaimRe = regexp.MustCompile(`//\s+(?:covers:\s+)?(GET|POST|PUT|DELETE|PATCH)\s+(/(?:operator|partials/operator)\S*)`)
func isMutationMethod(m string) bool {
switch m {
case "POST", "PUT", "DELETE", "PATCH":
return true
}
return false
}
func isOperatorRoute(p string) bool {
return strings.HasPrefix(p, "/operator/") || strings.HasPrefix(p, "/partials/operator/")
}
func ruleInterpolatedLiterals(info templateInfo) []Violation {
var v []Violation
for _, ref := range info.interpolatedLiteralRefs {
v = append(v, Violation{
File: ref.File,
Line: ref.Line,
Rule: "interpolated-literal-url",
Message: fmt.Sprintf(`%s=%q must use {{ routeURL "..." ... }} (see operator-ux-conventions.md §9). If this template's handler doesn't yet register routeURL, add "routeURL": web.RouteURL to its FuncMap.`, ref.Attr, ref.Value),
})
}
return v
}