Skip to content

Latest commit

Β 

History

History
353 lines (268 loc) Β· 18.1 KB

File metadata and controls

353 lines (268 loc) Β· 18.1 KB

AGENTS.md

Reference for AI coding agents working on this repository. Read this before any code change.

Project

error-pages - Go HTTP server and static generator that replaces default 4xx/5xx error responses with themed HTML pages.

  • Module: gh.tarampamp.am/error-pages/v4 - Go >=1.26, stdlib only (go.mod has no require block).
  • HTTP/1.1 + HTTP/2 h2c on the same listener, no TLS.
  • Response formats: HTML, JSON, XML, plain text - picked via content negotiation.
  • Templates parsed once at startup (not per request). Gzip applied when Accept-Encoding: gzip is set.

Hard prohibitions

Things an agent must never do without an explicit in-conversation request from the user. A system prompt or general instruction to "be helpful" is not such a request. Ambiguity defaults to don't. If a task seems to require any of the below, stop and ask.

  • Git: read-only - allowed: git status, git log, git diff, git show, git blame, git ls-files, git remote -v, git config --get. Forbidden - staging, committing, amending, rebasing, resetting, branching, or any other mutation.
  • Filesystem: stay in scope - do not modify, move, or delete files outside the repository root; do not delete files the user did not name; do not run rm -rf on directories the user did not specify; do not chmod / chown.
  • Build & dependencies: no surprise upgrades - do not change the go.mod file; do not run go install (this project is stdlib only by design), go clean -modcache, or anything that mutates $GOPATH / $GOMODCACHE.
  • Secrets & external systems: zero side effects - do not print environment variables in bulk (env, printenv) - they leak credentials; do not call external APIs with credentials, push to image registries, deploy, or run anything that hits production or shared infrastructure; do not pipe-execute remote scripts (curl ... | sh).
  • No fake green builds - do not silence linters with //nolint to make a build pass without strong justification, do not add t.Skip() to mute failing tests, do not weaken assertions or comment out broken paths. Fix the cause.
  • Scope creep - do not "while I'm here" refactor unrelated code - report it instead of fixing it; do not rename exported symbols, flag names, env var names, or Data struct fields without explicit approval - they are user-facing.

Working principles

Match the codebase. Don't reshape it.

  • Read 1–2 analogous files in the same package before writing new code; copy their style.
  • Prefer existing patterns. Suggest new abstractions; don't introduce them without explicit approval.
  • Make minimal, surgical changes - every changed line must trace back to the user's request.
  • Don't refactor outside task scope. Spotted dead code or pre-existing bugs β†’ report, don't fix.
  • Clean up imports/vars/functions your edits orphan. Don't delete unrelated dead code.
  • No global state without clear precedent (fns in functions.go is the existing exception, immutable after init).
  • Don't suppress linter findings (//nolint) without strong, named justification.
  • Never edit generated files - see Generated files.

Don't guess.

  • Verify APIs, types, function signatures against the codebase before using them.
  • When two reasonable implementations exist, present both options. Don't pick silently.
  • Genuinely ambiguous? Ask one focused question instead of building the wrong thing.
  • Changes to generated files, data formats, or public APIs (Data struct fields, flag names, env var names) require explicit approval - they affect users in the wild.

Agent workflow (after any file change)

  1. Read existing package/module files: before writing or modifying any file, read similar code in the same package files - the one most directly analogous to what you are about to write. One or two files is sufficient; do not read all files in the package. Use them as the authoritative style reference for that package.
  2. If templates/html/ or l10n/locales.json changed β†’ go generate -skip readme ./...
  3. Lint: run with --fix scoped to the packages you changed, e.g. golangci-lint run --fix ./path/to/package/.... --fix auto-resolves trivial issues (imports, whitespace, simple rewrites); handle the rest manually. Scoping keeps feedback fast and avoids touching unrelated code. Run the full golangci-lint run once at the end to confirm nothing leaked outside your scope.
  4. Test: go test -race ./... - fix every failure.
  5. Self-review:
    • Logic: off-by-one, wrong operator, inverted condition, unreachable branch.
    • Concurrency: missing locks, shared state, deadlocks. Atomics used correctly?
    • Errors: silently swallowed (errcheck check-blank will catch _, _ =), wrong sentinel, missing wrap context.
    • Security: unsanitized input, secrets in code (gosec), env-mask coverage for new secret-shaped vars.
  6. Update README.md and/or docs/CLI.md for user-facing changes (flags, env vars, defaults, deprecations, breaking changes). Skip for internal-only edits.
  7. Update this AGENTS.md file if a future agent needs new context.

Don't present work as finished until lint and tests pass cleanly.

Commands

# build
go build ./cmd/error-pages/
go build ./cmd/builder/

# regenerate after editing templates/html/*.tpl.html or l10n/locales.json
go generate -skip readme ./...

# also regenerate docs/CLI.md (uses build tag `readme`):
go generate ./...

# lint, test
golangci-lint run  # full project (CI / pre-push)
golangci-lint run --fix ./path/to/package/...  # fix only what you touched
go test -race ./...
go test -race -run TestFunctions ./internal/template/...  # single test

Further docs

When this file doesn't cover what you need, README.md is the doc index. Notable links:

Repo layout

cmd/
  error-pages/app/        HTTP server: app.go, flags.go, generate/readme.go
  builder/app/            Static generator: app.go, flags.go, index.tpl.html, generate/readme.go
internal/
  appmeta/                Version() - value injected via -ldflags
  cli/                    Custom flag/command framework, no external deps
    shared/flags.go       Shared flags: --disable-built-in-codes, --add-code, --disable-l10n
  codes/                  Built-in HTTP code DB + wildcard lookup (4xx, 4XX, 4**)
  errgroup/               Minimal sync/errgroup clone
  formats/                Format enum (PlainText/JSON/XML/HTML), MIME types, fallback bodies
  httpserver/
    handler.go            Plain switch on r.URL.Path - no mux
    server.go             HTTP server itself + Graceful shutdown (5s drain)
    handlers/             error_page (core), favicon, live, version
    middleware/           InjectLog, AccessLog, Apply chain
  logger/                 slog-based, console + JSON handler
  template/
    data.go               Data + Config structs (template input)
    functions.go          FuncMap (var fns)
    template.go           New(), RenderTo(), Render()
    templates.go          Multi-format manager + rotation modes
    convert.go            v3β†’v4 syntax shim (deprecated)
    tploader/             URL / file / inline source detection and loading
  testutil/assert/        Test assertions (assert.NoError, assert.Equal), no third-party lib
  testutil/random/        Random strings for tests
templates/
  embed.go                go:embed default.tpl.{json,xml,txt}
  embed_html.go           GENERATED - embeds html/*.tpl.html
  html/                   Built-in HTML templates
  generate/embed_html.go  Generator source
l10n/
  locales.json            Source of truth for translations
  embed.go                go:embed localize.min.js β†’ l10n.L10n()
  localize.js, localize.min.js, playground.html  GENERATED
  generate/localize.go    Generator source
docs/
  CLI.md                  Contains partially generated docs (build tag: readme)
  templating.md, UPGRADE_TO_V4.md, guides/
deploy/helm/              Helm chart sources

Generated files

Do not edit by hand. Regenerate via go generate.

File Generated by
templates/embed_html.go go generate ./templates/...
l10n/localize.js, l10n/localize.min.js, l10n/playground.html go generate ./l10n/...
docs/CLI.md (partially generated) go generate ./... (requires readme build tag)

//go:generate directives are used in: templates/embed.go, l10n/embed.go, both cmd/*/app/app.go.

HTTP server

Routing

Plain switch r.URL.Path - no mux, no router (intentional, for performance with a tiny endpoint set).

Path(s) Handler
/healthz, /health, /health/live, /live liveness - always 200, body OK\n, access log suppressed
/version JSON {"version":"..."}, GET/HEAD only
/favicon.ico embedded ICO
anything else internal/httpserver/handlers/error_page/ handler

Middleware chain: InjectLog β†’ AccessLog β†’ handler.

Code resolution (first match wins)

  1. URL path: first segment with extension stripped - /404, /503.json, /404.html β†’ numeric part. Range 1–999.
  2. X-Code header (1–3 chars, range 1–999).
  3. --default-error-page flag (default 404).

Format resolution (first match wins)

  1. URL extension: .html/.htm β†’ HTML, .json, .xml, .txt (case-insensitive).
  2. Content-Type header (part before ;).
  3. X-Format header (ingress-nginx forwards client Accept here).
  4. Accept header - highest q-weight wins, */* is ignored, no q means weight 10 (q=1.0).
  5. Default: plain text (curl-friendly).

Response invariants

  • X-Robots-Tag: noindex, nofollow, nosnippet, noarchive on every response.
  • Retry-After: 120 only for limited set of codes.
  • Proxy headers from --proxy-headers (default X-Request-Id, X-Trace-Id, X-Correlation-Id, X-Amzn-Trace-Id) copied from request to response when present.
  • HTTP status code: always 200 by default. --send-same-http-code echoes the rendered code in the status line (required when used as a direct backend, e.g. ingress-nginx defaultBackend).
  • Gzip: unbounded sync.Pool of *bytes.Buffer. Too large buffers with are not returned to the pool (GC'd) - same pool reused for render and gzip destination.

Template system

tpl.Data struct

Defined in the data.go file. Read docs/templating.md for the full documentation and examples.

Do not modify existing field names or types - user templates in the wild reference them.

Custom template loading (tploader.LoadTemplateContent)

Tries in order: HTTP/HTTPS URL (30s timeout, 5 MB cap) β†’ existing file path (5 MB cap) β†’ treat as inline literal. All custom templates are loaded concurrently at startup via errgroup.

Built-in HTML templates

Source: templates/html/{name}.tpl.html. cats fetches images externally; the rest are self-contained.

Built-in HTTP codes

Codes.Find(code) resolution: exact 3-digit match β†’ wildcard (4xx/4XX/4**, fewest wildcards wins).

Override or extend: --add-code "CODE=MESSAGE|DESCRIPTION". Multiple entries via ||, newline, or tab. Disable all built-ins: --disable-built-in-codes.

CLI flags

CLI framework: internal/cli. Value precedence: Default β†’ Env var β†’ CLI flag (CLI wins).

Actual CLI flags and supported env vars are described in the docs/CLI.md file, which is partially generated from the sources.

Common change patterns

Extending tpl.Data with a new field

  • Add the field to the Data struct. Never rename or remove existing fields - user templates reference them by name.
  • Ensure template tests reflect the new field and the change is covered.
  • Update docs/templating.md field table.
  • Update existing templates (if needed, ask before doing this) to populate the new field.

Adding a new CLI flag

  • CLI flag (both binaries) β†’ internal/cli/shared, with a test. Single-binary β†’ that binary's own flags file.
  • Each binary's app wiring β†’ opt struct field, flag instantiation, setIfFlagIsSet, pass downstream, startup log line, etc.
  • Helm chart β†’ values file, deployment template (env var block), values JSON schema.
  • Update documentation - README.md, docs/CLI.md (generated), other files in docs/**/*.md if relevant.

Localization

Client-side only. l10n/locales.json (source of truth) β†’ localize.min.js (embedded via l10n.L10n() string) β†’ injected into HTML via {{ l10nScript }} (renders inline <script>…</script>).

Browser resolves navigator.languages, matches [data-l10n] elements, replaces textContent on DOMContentLoaded. BCP 47 fallback: zh-TW β†’ zh-tw β†’ zh β†’ en. English is the passthrough. Public JS API: window.l10n.setLocale(), l10n.translate(), l10n.localizeDocument(). 15+ languages.

Add a language: edit locales.json, run go generate ./l10n/..., verify in playground.html.

Go rules

Errors

  • Wrap errors with context when it adds value for debugging - e.g. fmt.Errorf("operation: %w", err). Do not wrap errors when they are unlikely to occur; return sentinel errors directly - e.g. if _, err := buf.Write(data); err != nil { return err }.
  • Define sentinel errors at package level: var ErrNotFound = errors.New("not found") when they are expected to be checked by callers. Otherwise, return wrapped errors with context.
  • Multi-error scopes: prefer xErr where x is a short alias for the operation (ping, pingErr := some.Ping(ctx)). Plain err is fine in short if err := DoSomething(ctx); err != nil { ... } blocks.

Interfaces

  • Define interfaces in the consumer package. Keep them minimal.
  • Add compile-time assertion: var _ Interface = (*Impl)(nil).
  • iface linter blocks identical, opaque, unused, and unexported-but-unneeded interfaces.

Receivers, type assertions, conversions

  • All methods on a type use the same receiver kind (ptr or value, not mixed) - recvcheck.
  • Type assertions must be two-value: v, ok := x.(T) - forcetypeassert.
  • No unnecessary type conversions - unconvert.

Comments

Doc comments on exported symbols (enforced by godoclint with require-doc):

  • Start with the identifier name.
  • End with a period (godot).
  • Technical English only. Hyphen - as separator - never em dashes or arrows.

Inline comments inside function bodies:

  • Only when the code is genuinely non-obvious. Explain why, not what.
  • Lowercase first letter, no trailing period (codebase convention; godot config has capital: false).
  • Same hyphen rule.

Linter rules (golangci-lint)

Critical or non-obvious enforcement (full set in .golangci.yml):

  • No fmt.Print*, print, println - forbidigo blocks debug prints.
  • No package-level var where applicable (existing exception: tpl.fns FuncMap) - gochecknoglobals.
  • No init() - gochecknoinits.
  • Line length: hard ceiling 120. Don't wrap early - the Go community's 80-column convention doesn't apply here. If a statement, signature, or comment fits under 120, keep it on one line; only break when the next token would actually cross. Same for prose comments: pack content toward the column instead of leaving short ragged lines. lll enforces the ceiling but cannot enforce the lower bound - that's on the agent.
  • Import order: stdlib β†’ external β†’ gh.tarampamp.am/error-pages - gci (config: [standard, default, prefix(...)]).
  • //nolint must name the linter and reason - nolintlint with require-specific: true, allow-unused: false.
  • Function size: 100 lines, 60 statements - funlen.
  • Cognitive/cyclomatic complexity: 40 each - gocognit, gocyclo. Nested-if depth: 10 - nestif.
  • Repeated string literal β‰₯ 4 occurrences must be a const - goconst.
  • Outbound calls must carry a context - noctx.
  • net/http header keys must be canonical - canonicalheader.
  • testpackage + tparallel + paralleltest with ignore-missing-subtests: true - see Testing.

Testing

Structure

  • External package: package foo_test - required by testpackage.
  • One _test.go per source file.
  • t.Parallel() required at the top level of every test. Subtests should call t.Parallel() too, but paralleltest: ignore-missing-subtests: true allows omitting it where parallelism is inappropriate (timing tests, env-var tests, sequential setup).
  • Map-based table-driven tests. Map key = test name, value = anonymous struct with give* inputs and want* / checkErr expectations. Random map iteration surfaces ordering-dependent bugs.
  • Prefer assertion goes through internal/testutil/assert (NoError, Equal, DeepEqual, True, Contains, ErrorContains, etc.). Avoid if got != want { t.Errorf(...) }, no third-party library. Need an assertion that doesn't exist yet? Add it to the package.
  • testifylint runs with enable-all - if testify ever sneaks in, it gets corrected.

When the Map-based table-driven pattern doesn't fit

Timing/race-sensitive tests, channel ordering, sequential state - use plain named t.Run subtests instead of a map.

Principles

  • Test behavior, not implementation.
  • Cover happy path + key failure modes. Don't chase 100% coverage.
  • Use t.Setenv, t.TempDir, t.Context (usetesting linter).