- 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
701 lines
21 KiB
Go
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
|
|
}
|