mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
feat(skill): add scripts, templates, and evals to backend-engineering and frontend-engineering (#256)
Thicken the two flagship engineering methodology skills with the artifact set promised by issue #239: schema-v1 eval manifests (6 cases each), fillable templates, and one small stdlib-only script per skill with tests. backend-engineering: - evals/evals.json: API implementation review, endpoint modeling, service structure, error handling, N+1 detection, integration retry/idempotency - templates/service-design-record.md, templates/error-handling-taxonomy.md - scripts/n1-query-spotter.py (+ test_n1_query_spotter.py): flags query-like calls inside loops with loop-variable confidence, --json output frontend-engineering: - evals/evals.json: component/state design, state management selection, API integration, data-fetching states, performance review, performance budgets - templates/component-state-design-record.md, templates/performance-budget.md - scripts/bundle-budget-checker.py (+ test_bundle_budget_checker.py): enforces total and per-chunk byte budgets on bundle reports, exit 1 on violation Both SKILL.md files gain Templates and Scripts sections; both READMEs document the scripts in Quick Start. All local validators pass (validate-skills.rb, validate-evals.py, eval-coverage ratchet, make validate). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
abe1ab3a00
commit
48c1a1e6f5
@@ -4,7 +4,7 @@ Backend engineering methodology — API implementation patterns (REST, gRPC, Gra
|
||||
|
||||
## Why Install This Skill
|
||||
|
||||
Your agent gains structured patterns for API design, service architecture, database access, error handling, and integration — instead of improvising each time.
|
||||
Your agent gains structured patterns for API design, service architecture, database access, error handling, and integration — instead of improvising each time. Fillable templates turn service designs and error contracts into reviewable records, and the bundled N+1 query spotter catches a whole class of database performance bugs during review.
|
||||
|
||||
## What You Get
|
||||
|
||||
@@ -12,6 +12,9 @@ Your agent gains structured patterns for API design, service architecture, datab
|
||||
|-----------|---------|
|
||||
| `SKILL.md` | Core methodology, trigger conditions, reference index |
|
||||
| `references/` | Deep-dive reference files loaded on demand |
|
||||
| `templates/` | Fillable records: service design record, error-handling taxonomy |
|
||||
| `scripts/` | `n1-query-spotter.py` — scans Python source for potential N+1 query patterns |
|
||||
| `evals/` | Output-quality eval manifest for the skill's methodology cases |
|
||||
|
||||
## Triggers
|
||||
|
||||
@@ -19,8 +22,16 @@ Building or reviewing APIs, designing service layers, implementing database acce
|
||||
|
||||
## Requirements
|
||||
|
||||
Platform-agnostic. Applicable to any language/framework stack.
|
||||
Platform-agnostic. Applicable to any language/framework stack. The bundled script needs only Python 3 (standard library).
|
||||
|
||||
## Quick Start
|
||||
|
||||
Scan a service for potential N+1 query patterns before a performance review:
|
||||
|
||||
```bash
|
||||
python3 backend-engineering/scripts/n1-query-spotter.py services/orders.py
|
||||
```
|
||||
|
||||
Each finding points at the query call, the enclosing loop, and whether the loop variable is used in the query (high confidence vs possible). Add `--json` for machine-readable output, and run it from CI — the script exits 1 when findings exist.
|
||||
|
||||
Load SKILL.md for the methodology overview and reference table, then load specific references as needed for the task at hand.
|
||||
|
||||
@@ -37,6 +37,19 @@ Backend engineering is the craft of building the server-side systems that power
|
||||
| `references/integration-patterns.md` | Integrating with external systems — retry with backoff, circuit breakers, idempotency keys, webhook verification, message queue consumers |
|
||||
| `references/error-handling.md` | Handling errors systematically — classification (client vs server), structured responses, exception handling patterns, observability correlation |
|
||||
|
||||
## Templates
|
||||
|
||||
| Template | When to Use |
|
||||
|-----------|-------------|
|
||||
| `templates/service-design-record.md` | Designing or restructuring a service — structure, API surface, data access, error handling, and testing plan in one reviewable record |
|
||||
| `templates/error-handling-taxonomy.md` | Defining or auditing a service's error contract — classification, response format, retry/idempotency policy, and error-path tests |
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | When to Use |
|
||||
|-----------|-------------|
|
||||
| `scripts/n1-query-spotter.py` | Scanning Python source for potential N+1 query patterns (query-like calls inside loops); `--json` for CI-friendly output, exit 1 on findings |
|
||||
|
||||
## Core Principles
|
||||
|
||||
**The interface is the contract** — API boundaries are service-level contracts. Every endpoint signature, request schema, response format, and error code is a promise to consumers. Breaking changes are coordination problems, not version bumps.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"skill_name": "backend-engineering",
|
||||
"evals": [
|
||||
{
|
||||
"id": "api-implementation-review",
|
||||
"prompt": "A teammate just implemented a REST endpoint to update a customer profile (PUT /customers/{id}). The handler parses the request body directly with no schema validation, writes the fields straight to the database from the handler function, returns 200 with an empty body on success, and catches every database error and turns it into a generic 500 with a stack trace in the response. Review this implementation against backend-engineering patterns and tell me what to change.",
|
||||
"expected_output": "An API implementation review that walks the endpoint from request to response: validate the request against an explicit schema before the handler runs and return 400 with a structured error body listing the offending fields; separate the HTTP layer from business logic so the handler delegates to a service layer instead of writing to the database directly; return a representation of the updated resource (200 with the updated entity, or 204 only for delete-style operations) with consistent content negotiation; map known failures to specific status codes (404 for a missing customer, 409 for a version conflict) and reserve 500 for unexpected errors, logging the stack trace server-side rather than echoing it to the client; and add an idempotency consideration for retried PUTs by supporting If-Match/ETag or a version field.",
|
||||
"assertions": [
|
||||
"The review requires request schema validation that returns a 400 with a structured body identifying the invalid fields",
|
||||
"The review separates the HTTP handler from business logic and moves database access into a service or repository layer",
|
||||
"The review requires a resource representation in the success response and maps known failures to specific 4xx status codes",
|
||||
"The review says stack traces must stay in server logs, not client responses, and 500 is reserved for unexpected errors",
|
||||
"The review adds a concurrency or idempotency mechanism such as If-Match with an ETag or a version field for updates"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "api-endpoint-resource-modeling",
|
||||
"prompt": "I am designing the API for a subscription billing system. I need endpoints for listing subscriptions, fetching a subscription with its invoices, changing a plan, and cancelling. How should I model the resources and endpoints, and how do I handle pagination, filtering, and the transition between plan states?",
|
||||
"expected_output": "A resource model with nouns and stable identifiers: /subscriptions for the collection, /subscriptions/{id} for a single subscription, and /subscriptions/{id}/invoices as a nested read-only collection with cursor or offset pagination, ordering, and filtering by status. State transitions such as plan changes and cancellation are expressed as explicit operations on the resource (PATCH with a status field, or purpose-specific actions) rather than inventing endpoints for verbs. The design covers idempotency keys for state-changing operations so retries cannot double-charge, a 404 versus 403 distinction for cross-tenant access, and versioning that keeps the existing client contract stable while the model evolves.",
|
||||
"assertions": [
|
||||
"The response models resources as nouns with nested read-only collections for related data such as invoices",
|
||||
"The response covers pagination, ordering, and filtering for collection endpoints",
|
||||
"The response expresses state transitions as operations on the resource rather than verb-only endpoints",
|
||||
"The response requires idempotency keys on state-changing operations such as plan changes and cancellation",
|
||||
"The response distinguishes 404 from 403 for access control and addresses API versioning"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "service-structure-review",
|
||||
"prompt": "Our order service started as a prototype and is now in production. All business logic lives in the route handlers, shared helpers are piling up in a 3,000-line utils.py, the database is accessed directly from handlers, and every feature branch touches the same files. I want to restructure it so it is testable and survives the next two years of features. Where do I start?",
|
||||
"expected_output": "A service structure plan that introduces layers with a strict dependency direction: transport (HTTP/gRPC handlers) at the edge, an application/service layer owning business rules and use cases, and a persistence layer behind a repository or data-access interface. Utils.py is decomposed into focused modules grouped by responsibility, and shared logic is extracted into the layer where its dependencies live. The plan defines ports and adapters at the boundaries (repository interface, message publisher, clock) so the service layer can be unit-tested with fakes, and it sequences the refactor: introduce the boundary interfaces first with the existing behavior as the contract, move business rules out of handlers feature by feature, and keep each step covered by tests.",
|
||||
"assertions": [
|
||||
"The response structures the service into transport, application/service, and persistence layers with a strict dependency direction",
|
||||
"The response decomposes the shared utils module into focused, responsibility-scoped modules",
|
||||
"The response uses ports and adapters (repository interface, message publisher, clock) so business logic is testable with fakes",
|
||||
"The response sequences the refactor starting from boundary interfaces with existing behavior as the contract",
|
||||
"The response requires test coverage at each step of the refactor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "error-handling-design",
|
||||
"prompt": "Our new payments service needs consistent error handling across REST endpoints and background job processing. Today every handler invents its own error responses, retries are missing, and when a webhook fails we lose the event. Design the error-handling model for this service.",
|
||||
"expected_output": "An error-handling model with three parts: classification, representation, and recovery. Classification distinguishes client errors (validation, not found, conflict), transient server-side failures (timeouts, overload, dependency outages), and permanent server failures. The response format is structured and consistent — a stable error code, a human message, and a correlation ID — with the mapping from internal exceptions to codes owned in one place. Recovery is per failure class: retries with exponential backoff and jitter for transient failures, idempotency keys so retried operations are safe, dead-letter handling for background jobs that exhaust retries, and circuit breaking toward degraded dependencies. Every handled error carries enough context for observability (trace ID, request ID, service) so the handler does not need the stack trace.",
|
||||
"assertions": [
|
||||
"The response classifies errors into client, transient, and permanent failure classes",
|
||||
"The response defines a single structured error representation with a stable code, message, and correlation ID",
|
||||
"The response prescribes retry with exponential backoff and jitter for transient failures",
|
||||
"The response requires idempotency keys and dead-letter handling for jobs that exhaust retries",
|
||||
"The response ties error responses to observability correlation IDs rather than exposing stack traces"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "database-n-plus-one-detection",
|
||||
"prompt": "GET /orders returns a list of orders, and each order row is followed by a loop that fetches that order's line items and customer one at a time. The endpoint is fast with 10 orders and crawls with 500. Walk me through diagnosing and fixing this, and how I would catch the same problem in the next codebase.",
|
||||
"expected_output": "A diagnosis that names the N+1 query pattern: one query for the orders plus one query per order for line items and customer, so 500 orders produce 1,001 queries. The fix batches the data access: one query with a WHERE IN over the collected order ids for line items and one for customers, joining or grouping in memory, and indexing the foreign keys involved. The response also covers pagination so a page is bounded, and prevention: review loops that contain query calls (for example by running the n1-query-spotter script over the codebase), prefer ORM eager-loading or explicit batch queries, and add a query-count assertion to tests so a regression fails the suite.",
|
||||
"assertions": [
|
||||
"The response names the N+1 pattern and quantifies it as one query per row on top of the initial query",
|
||||
"The response fixes it by batching with WHERE IN queries or joins and indexing the foreign keys",
|
||||
"The response adds pagination so the result set is bounded",
|
||||
"The response mentions running the n1-query-spotter script or reviewing loops that contain query calls as a prevention step",
|
||||
"The response adds query-count assertions to tests so N+1 regressions fail CI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "integration-retry-idempotency",
|
||||
"prompt": "We call a third-party inventory API from our order service. Occasionally the API times out or returns 503, and when that happens the whole request fails and the user retries manually, which sometimes creates duplicate inventory holds. Design the integration layer for this dependency.",
|
||||
"expected_output": "An integration layer design with a client wrapper that owns timeouts, retries with exponential backoff and jitter for transient statuses and timeouts, and a circuit breaker so a failing dependency does not stall every caller. Idempotency keys on the inventory-hold request let the client retry safely without duplicate holds, and the design handles the ambiguity case (timeout before response) by checking the hold status with a GET before retrying the mutation. The layer also defines what happens after retries are exhausted: the order request fails fast with a structured, classified error instead of hanging, and a fallback (queue the operation or surface a clear error) is chosen deliberately.",
|
||||
"assertions": [
|
||||
"The response wraps the dependency in a client with explicit timeouts and retry with exponential backoff and jitter",
|
||||
"The response adds a circuit breaker so a failing dependency does not stall all callers",
|
||||
"The response uses idempotency keys so retried inventory-hold requests cannot create duplicates",
|
||||
"The response resolves timeout ambiguity by querying the hold status before retrying the mutation",
|
||||
"The response defines failure behavior after retries are exhausted rather than hanging"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""N+1 query spotter for backend-engineering.
|
||||
|
||||
Scans Python source files for the classic N+1 query pattern: a database query
|
||||
or ORM fetch invoked inside a loop body. When a loop runs N iterations and
|
||||
each iteration issues its own query, the code makes N+1 round trips instead
|
||||
of one batched query — the fix is a WHERE IN batch, a join, or eager loading.
|
||||
|
||||
Detection is static and heuristic: any query-like call that appears inside a
|
||||
for or while loop is flagged. A call that also references the loop variable
|
||||
in one of its arguments is flagged with higher confidence, because the query
|
||||
is almost certainly varying per iteration.
|
||||
|
||||
Input: one or more file paths. With no paths, Python source is read from
|
||||
stdin. Output is one line per finding, or a JSON report with --json.
|
||||
|
||||
Exit codes:
|
||||
0 no potential N+1 patterns found
|
||||
1 one or more potential N+1 patterns found
|
||||
2 usage or I/O error (missing file, unparseable source, bad flags)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Attribute method names (obj.NAME(...)) treated as database query / ORM fetch calls.
|
||||
QUERY_METHODS = frozenset(
|
||||
{
|
||||
"query",
|
||||
"execute",
|
||||
"fetchall",
|
||||
"fetchone",
|
||||
"fetchmany",
|
||||
"fetch",
|
||||
"first",
|
||||
"one",
|
||||
"all",
|
||||
"get",
|
||||
"filter",
|
||||
"select",
|
||||
"find",
|
||||
"save",
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"insert",
|
||||
"commit",
|
||||
"persist",
|
||||
"bulk_create",
|
||||
"bulk_update",
|
||||
}
|
||||
)
|
||||
|
||||
# Bare function names (NAME(...)) treated as query entry points.
|
||||
QUERY_NAMES = frozenset({"query", "execute", "run", "fetch", "find", "select"})
|
||||
|
||||
_STDIN_LABEL = "<stdin>"
|
||||
|
||||
|
||||
class N1Finding:
|
||||
"""One suspected N+1 pattern: a query-like call inside a loop."""
|
||||
|
||||
def __init__(self, line, column, call_text, loop_line, loop_targets, high_confidence):
|
||||
self.line = line
|
||||
self.column = column
|
||||
self.call_text = call_text
|
||||
self.loop_line = loop_line
|
||||
self.loop_targets = sorted(loop_targets)
|
||||
self.high_confidence = high_confidence
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"line": self.line,
|
||||
"column": self.column,
|
||||
"call": self.call_text,
|
||||
"loop_line": self.loop_line,
|
||||
"loop_targets": self.loop_targets,
|
||||
"confidence": "high" if self.high_confidence else "possible",
|
||||
}
|
||||
|
||||
def render(self, source_path):
|
||||
confidence = "high confidence" if self.high_confidence else "possible"
|
||||
target_note = f", loop target {', '.join(self.loop_targets)}" if self.loop_targets else ""
|
||||
return (
|
||||
f"{source_path}:{self.line}:{self.column}: potential N+1: "
|
||||
f"{self.call_text!r} inside loop at line {self.loop_line}{target_note} ({confidence})"
|
||||
)
|
||||
|
||||
|
||||
class N1Scanner(ast.NodeVisitor):
|
||||
"""Walks one module, flagging query-like calls that sit inside a loop."""
|
||||
|
||||
def __init__(self, extra_methods=(), extra_names=()):
|
||||
self.methods = QUERY_METHODS | set(extra_methods)
|
||||
self.names = QUERY_NAMES | set(extra_names)
|
||||
self.loop_stack = [] # (loop_node, loop_target_names)
|
||||
self.findings = []
|
||||
|
||||
# -- loop tracking -----------------------------------------------------
|
||||
|
||||
def _target_names(self, target):
|
||||
if isinstance(target, ast.Name):
|
||||
return {target.id}
|
||||
if isinstance(target, ast.Tuple):
|
||||
return {elt.id for elt in target.elts if isinstance(elt, ast.Name)}
|
||||
return set()
|
||||
|
||||
def visit_For(self, node):
|
||||
self.loop_stack.append((node, self._target_names(node.target)))
|
||||
self.generic_visit(node)
|
||||
self.loop_stack.pop()
|
||||
|
||||
def visit_While(self, node):
|
||||
self.loop_stack.append((node, set()))
|
||||
self.generic_visit(node)
|
||||
self.loop_stack.pop()
|
||||
|
||||
# -- call inspection ---------------------------------------------------
|
||||
|
||||
def _call_name(self, node):
|
||||
"""Return (name, kind) for a call's callee, or None if not matched."""
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute):
|
||||
return func.attr, "attribute"
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id, "name"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _names_in_node(node):
|
||||
"""All identifier names reachable inside an AST node."""
|
||||
return {name.id for name in ast.walk(node) if isinstance(name, ast.Name)}
|
||||
|
||||
@classmethod
|
||||
def _references_loop_var(cls, node, names):
|
||||
"""True if any argument of the call references a loop variable.
|
||||
|
||||
Attribute access (user.id) and nested expressions count as references,
|
||||
since a per-iteration query keyed off the loop item is the N+1 signature.
|
||||
"""
|
||||
for arg in node.args:
|
||||
if cls._names_in_node(arg) & names:
|
||||
return True
|
||||
for kw in node.keywords:
|
||||
if kw.arg in names:
|
||||
return True
|
||||
if kw.value is not None and cls._names_in_node(kw.value) & names:
|
||||
return True
|
||||
return False
|
||||
|
||||
def visit_Call(self, node):
|
||||
if self.loop_stack:
|
||||
matched = self._call_name(node)
|
||||
if matched:
|
||||
call_name, kind = matched
|
||||
if (kind == "attribute" and call_name in self.methods) or (
|
||||
kind == "name" and call_name in self.names
|
||||
):
|
||||
loop_node, targets = self.loop_stack[-1]
|
||||
high = bool(targets) and self._references_loop_var(node, targets)
|
||||
self.findings.append(
|
||||
N1Finding(
|
||||
line=node.lineno,
|
||||
column=getattr(node, "col_offset", 0),
|
||||
call_text=ast.unparse(node),
|
||||
loop_line=loop_node.lineno,
|
||||
loop_targets=targets,
|
||||
high_confidence=high,
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def scan_source(source, source_path, extra_methods=(), extra_names=()):
|
||||
"""Parse source and return the list of N1Finding objects."""
|
||||
tree = ast.parse(source, filename=source_path)
|
||||
scanner = N1Scanner(extra_methods=extra_methods, extra_names=extra_names)
|
||||
scanner.visit(tree)
|
||||
return scanner.findings
|
||||
|
||||
|
||||
def _split_csv(value):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="n1-query-spotter.py",
|
||||
description=(
|
||||
"Spot potential N+1 query patterns (query-like calls inside loops) in "
|
||||
"Python source files. With no FILE arguments, reads source from stdin."
|
||||
),
|
||||
epilog="Exit codes: 0 no findings, 1 findings, 2 usage or I/O error.",
|
||||
)
|
||||
parser.add_argument("files", nargs="*", metavar="FILE", help="Python source files to scan")
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="emit a machine-readable JSON report instead of human lines",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-methods",
|
||||
default="",
|
||||
metavar="NAME[,NAME...]",
|
||||
help="additional query-like attribute method names to detect, e.g. 'run_query,raw'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-names",
|
||||
default="",
|
||||
metavar="NAME[,NAME...]",
|
||||
help="additional query-like bare function names to detect",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _read_input(paths, errors):
|
||||
"""Yield (label, source) pairs for every input; collect IO errors."""
|
||||
if not paths:
|
||||
yield _STDIN_LABEL, sys.stdin.read()
|
||||
return
|
||||
for raw in paths:
|
||||
path = Path(raw)
|
||||
try:
|
||||
yield str(path), path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
errors.append(f"{path}: cannot read: {exc.strerror}")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
errors = []
|
||||
all_findings = []
|
||||
for label, source in _read_input(args.files, errors):
|
||||
try:
|
||||
findings = scan_source(
|
||||
source,
|
||||
label,
|
||||
extra_methods=_split_csv(args.extra_methods),
|
||||
extra_names=_split_csv(args.extra_names),
|
||||
)
|
||||
except SyntaxError as exc:
|
||||
errors.append(f"{label}:{exc.lineno}: cannot parse source: {exc.msg}")
|
||||
continue
|
||||
for finding in findings:
|
||||
all_findings.append((label, finding))
|
||||
|
||||
if errors:
|
||||
for message in errors:
|
||||
print(f"ERROR: {message}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.json:
|
||||
report = {
|
||||
"findings": [{"file": label, **finding.to_dict()} for label, finding in all_findings],
|
||||
"count": len(all_findings),
|
||||
}
|
||||
print(json.dumps(report, indent=2))
|
||||
else:
|
||||
for label, finding in all_findings:
|
||||
print(finding.render(label))
|
||||
if all_findings:
|
||||
print(
|
||||
f"{len(all_findings)} potential N+1 pattern(s) found; "
|
||||
"consider batch queries, joins, or eager loading.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1 if all_findings else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Tests for n1-query-spotter.py.
|
||||
|
||||
Covers: query call inside a loop (attribute and bare-name forms), no finding
|
||||
when queries live outside loops, high-confidence vs possible classification,
|
||||
nested loops, --json output, --extra-methods, stdin input, --help, and error
|
||||
paths (missing file, unparseable source).
|
||||
|
||||
Discoverable by both pytest and unittest (unittest.TestCase classes).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import suppress
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)))
|
||||
SPOTTER = os.path.join(SCRIPTS_DIR, "n1-query-spotter.py")
|
||||
|
||||
|
||||
def run_spotter(args, stdin_data=None):
|
||||
cmd = [sys.executable, SPOTTER, *args]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=stdin_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def write_source(code):
|
||||
"""Write Python source to a temp file; return its path."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as handle:
|
||||
handle.write(code)
|
||||
path = handle.name
|
||||
return path
|
||||
|
||||
|
||||
def cleanup(path):
|
||||
with suppress(OSError):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class TestN1SpotterFindings(unittest.TestCase):
|
||||
def test_flags_query_call_inside_loop(self):
|
||||
code = (
|
||||
"import db\n"
|
||||
"def list_orders(orders):\n"
|
||||
" result = []\n"
|
||||
" for order in orders:\n"
|
||||
" result.append(db.query('SELECT * FROM items WHERE order_id=?', order.id))\n"
|
||||
" return result\n"
|
||||
)
|
||||
path = write_source(code)
|
||||
try:
|
||||
rc, stdout, _ = run_spotter([path])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("potential N+1", stdout)
|
||||
self.assertIn("inside loop at line 4", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_flags_attribute_query_in_loop(self):
|
||||
code = (
|
||||
"def send_receipts(customers):\n"
|
||||
" for customer in customers:\n"
|
||||
" account = customer.accounts.get(account_id=customer.default_account_id)\n"
|
||||
" email_receipt(account)\n"
|
||||
)
|
||||
path = write_source(code)
|
||||
try:
|
||||
rc, stdout, _ = run_spotter([path])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("potential N+1", stdout)
|
||||
self.assertIn(".get(", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_no_finding_when_query_outside_loop(self):
|
||||
code = (
|
||||
"import db\n"
|
||||
"def list_orders(order_ids):\n"
|
||||
" placeholders = ','.join('?' for _ in order_ids)\n"
|
||||
" return db.query(f'SELECT * FROM orders WHERE id IN ({placeholders})', *order_ids)\n"
|
||||
)
|
||||
path = write_source(code)
|
||||
try:
|
||||
rc, stdout, _ = run_spotter([path])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(stdout.strip(), "")
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_no_finding_without_query_calls(self):
|
||||
code = "def add(a, b):\n return a + b\n"
|
||||
path = write_source(code)
|
||||
try:
|
||||
rc, stdout, _ = run_spotter([path])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(stdout.strip(), "")
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_nested_loop_detection(self):
|
||||
code = (
|
||||
"def flatten(teams):\n"
|
||||
" for team in teams:\n"
|
||||
" for member in team.members:\n"
|
||||
" profile = profiles.find(member.profile_id)\n"
|
||||
)
|
||||
path = write_source(code)
|
||||
try:
|
||||
rc, stdout, _ = run_spotter([path])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("potential N+1", stdout)
|
||||
self.assertIn("profiles.find", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_while_loop_detection(self):
|
||||
code = (
|
||||
"def drain(queue):\n"
|
||||
" while queue:\n"
|
||||
" item = queue.pop()\n"
|
||||
" row = db.execute('SELECT * FROM jobs WHERE id=?', item.id)\n"
|
||||
)
|
||||
path = write_source(code)
|
||||
try:
|
||||
rc, stdout, _ = run_spotter([path])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("inside loop", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
|
||||
class TestN1SpotterConfidence(unittest.TestCase):
|
||||
def test_high_confidence_when_loop_var_referenced(self):
|
||||
code = (
|
||||
"def render(users):\n"
|
||||
" for user in users:\n"
|
||||
" posts = db.query(posts_by_author, user.id)\n"
|
||||
)
|
||||
path = write_source(code)
|
||||
try:
|
||||
_, stdout, _ = run_spotter([path, "--json"])
|
||||
report = json.loads(stdout)
|
||||
self.assertEqual(report["count"], 1)
|
||||
self.assertEqual(report["findings"][0]["confidence"], "high")
|
||||
self.assertEqual(report["findings"][0]["loop_targets"], ["user"])
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_possible_confidence_without_loop_var(self):
|
||||
code = "def process(rows):\n for _ in rows:\n db.query('SELECT 1')\n"
|
||||
path = write_source(code)
|
||||
try:
|
||||
_, stdout, _ = run_spotter([path, "--json"])
|
||||
report = json.loads(stdout)
|
||||
self.assertEqual(report["count"], 1)
|
||||
self.assertEqual(report["findings"][0]["confidence"], "possible")
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
|
||||
class TestN1SpotterCli(unittest.TestCase):
|
||||
def test_help_exits_zero(self):
|
||||
rc, stdout, _ = run_spotter(["--help"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("potential N+1", stdout)
|
||||
|
||||
def test_json_output_parseable(self):
|
||||
code = "def go(items):\n for item in items:\n row = db.get(item.id)\n"
|
||||
path = write_source(code)
|
||||
try:
|
||||
rc, stdout, _ = run_spotter([path, "--json"])
|
||||
self.assertEqual(rc, 1)
|
||||
report = json.loads(stdout)
|
||||
self.assertEqual(report["count"], 1)
|
||||
self.assertEqual(report["findings"][0]["file"], path)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_stdin_input(self):
|
||||
code = "for row in rows:\n fetch(row.id)\n"
|
||||
rc, stdout, _ = run_spotter([], stdin_data=code)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("<stdin>", stdout)
|
||||
|
||||
def test_extra_methods_flag(self):
|
||||
code = "def go(items):\n for item in items:\n engine.raw_query(item.id)\n"
|
||||
path = write_source(code)
|
||||
try:
|
||||
rc_without, _, _ = run_spotter([path])
|
||||
rc_with, stdout, _ = run_spotter([path, "--extra-methods", "raw_query"])
|
||||
self.assertEqual(rc_without, 0)
|
||||
self.assertEqual(rc_with, 1)
|
||||
self.assertIn("raw_query", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_missing_file_exit_two(self):
|
||||
rc, _, stderr = run_spotter(["/nonexistent/nope.py"])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("ERROR", stderr)
|
||||
|
||||
def test_parse_error_exit_two(self):
|
||||
path = write_source("def broken(:\n pass\n")
|
||||
try:
|
||||
rc, _, stderr = run_spotter([path])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("cannot parse", stderr)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_empty_file_exit_zero(self):
|
||||
path = write_source("")
|
||||
try:
|
||||
rc, stdout, _ = run_spotter([path])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(stdout.strip(), "")
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
# Error-Handling Taxonomy
|
||||
|
||||
Fill this taxonomy when designing or reviewing error handling for a service.
|
||||
It makes the service's error behavior explicit, consistent, and reviewable in
|
||||
one place. Every error path in the code should trace back to a row here.
|
||||
|
||||
## Error Classification
|
||||
|
||||
| Class | Meaning | Examples | Client-visible? | Recovery strategy |
|
||||
|---|---|---|---|---|
|
||||
| Client error | The request is wrong and will not succeed if retried unchanged | `[fill: invalid field, missing resource, conflict]` | Yes — structured 4xx | Fix the request; no retry |
|
||||
| Transient failure | A dependency or resource was temporarily unavailable | `[fill: timeout, 503, connection reset]` | Sometimes — retryable signal | Retry with backoff and jitter |
|
||||
| Permanent server failure | The service itself hit an unexpected state | `[fill: coding bug, corrupt state]` | Generic 5xx only | Alert; no automatic retry |
|
||||
|
||||
## Error Response Contract
|
||||
|
||||
- Response shape: `[fill: JSON/gRPC error structure — code, message, details, correlation id]`
|
||||
- Stable error codes: `[fill: the enumerated codes clients can match on]`
|
||||
- Status-code mapping table:
|
||||
|
||||
| Condition | HTTP status | Error code | Notes |
|
||||
|---|---|---|---|
|
||||
| Validation failed | `[fill: e.g. 400]` | `[fill: e.g. validation_error]` | `[fill: which fields and why]` |
|
||||
| Resource not found | `[fill: e.g. 404]` | `[fill: code]` | `[fill: when this applies]` |
|
||||
| State conflict / stale write | `[fill: e.g. 409]` | `[fill: code]` | `[fill: concurrency or idempotency context]` |
|
||||
| Too many requests | `[fill: e.g. 429]` | `[fill: code]` | `[fill: rate-limit and retry-after header]` |
|
||||
| Unexpected error | `[fill: e.g. 500]` | `[fill: code]` | `[fill: what is logged vs returned]` |
|
||||
|
||||
## Exception Handling Rules
|
||||
|
||||
- Where exceptions are caught: `[fill: boundary layers that map exceptions to responses]`
|
||||
- What is logged at each layer: `[fill: context fields, stack traces only server-side]`
|
||||
- What is never exposed to clients: `[fill: stack traces, SQL, internal paths, dependency details]`
|
||||
- Correlation: `[fill: how request/trace IDs are attached to logs and error responses]`
|
||||
|
||||
## Retry and Idempotency
|
||||
|
||||
| Operation | Idempotency key? | Retry policy | Ambiguity handling |
|
||||
|---|---|---|---|
|
||||
| `[fill: operation]` | `[fill: yes/no and where the key comes from]` | `[fill: attempts, backoff, jitter, which statuses are retried]` | `[fill: how a timeout-before-response is resolved, e.g. GET to verify state]` |
|
||||
|
||||
## Background Jobs and Queues
|
||||
|
||||
- Retry policy per queue: `[fill: max attempts, backoff schedule, retryable error classes]`
|
||||
- Dead-letter behavior: `[fill: where failed jobs land and who drains them]`
|
||||
- Poison-message handling: `[fill: how a message that always fails is quarantined]`
|
||||
|
||||
## Testing the Error Paths
|
||||
|
||||
- Test cases to add: `[fill: one test per mapping row above — request, expected code, expected body]`
|
||||
- Failure injection: `[fill: how transient failures are simulated in tests (e.g. a stub that returns 503)]`
|
||||
- Verification: `[fill: how the error contract is asserted (contract tests, schema checks)]`
|
||||
@@ -0,0 +1,70 @@
|
||||
# Service Design Record
|
||||
|
||||
Fill this record when designing or restructuring a backend service, before
|
||||
implementation begins. Keep it in the repository next to the service code so
|
||||
reviewers and future maintainers can see the decisions that shaped the
|
||||
architecture.
|
||||
|
||||
## Context
|
||||
|
||||
- Service name: `[fill: service name]`
|
||||
- Owner team: `[fill: owning team]`
|
||||
- Problem being solved: `[fill: what user or system problem does this service address]`
|
||||
- Consumers: `[fill: which services, clients, or teams call this service]`
|
||||
- Non-functional requirements: `[fill: latency target, throughput, availability, data-retention needs]`
|
||||
|
||||
## Service Structure
|
||||
|
||||
- Boundary style chosen (layered / hexagonal / clean): `[fill: which structure applies and why]`
|
||||
- Layers or modules and their responsibility: `[fill: list each layer or module with one line of responsibility]`
|
||||
- Dependency direction rule: `[fill: e.g. "transport may depend on service, service on persistence interfaces, never the reverse"]`
|
||||
- Framework and language: `[fill: stack, and what framework-owned vs framework-agnostic code exists]`
|
||||
|
||||
## API Surface
|
||||
|
||||
| Endpoint / operation | Method | Purpose | Request validation | Success response | Failure response |
|
||||
|---|---|---|---|---|---|
|
||||
| `[fill: path or RPC name]` | `[fill: HTTP verb or gRPC method]` | `[fill: purpose]` | `[fill: schema/validation approach]` | `[fill: status + body]` | `[fill: error codes mapped to this operation]` |
|
||||
|
||||
## Data Access
|
||||
|
||||
- Storage: `[fill: database or store, and why this one]`
|
||||
- Access pattern: `[fill: repository interface, ORM, raw SQL; batch queries and eager-loading strategy]`
|
||||
- Transaction boundaries: `[fill: which operations need a transaction and its isolation level]`
|
||||
- Pagination strategy: `[fill: cursor or offset, ordering key]`
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Error classification: `[fill: how client vs transient vs permanent errors are distinguished]`
|
||||
- Error response format: `[fill: shape of the error body, stable error codes, correlation IDs]`
|
||||
- Retry policy for external dependencies: `[fill: backoff, jitter, max attempts, idempotency keys]`
|
||||
- Failure fallback: `[fill: what happens when retries are exhausted]`
|
||||
|
||||
## Integrations
|
||||
|
||||
| External system | Interaction | Failure handling | Idempotency | Backpressure |
|
||||
|---|---|---|---|---|
|
||||
| `[fill: system]` | `[fill: sync call, webhook, queue]` | `[fill: retry/circuit breaker policy]` | `[fill: how duplicates are prevented]` | `[fill: queue limit, rate limit, load shedding]` |
|
||||
|
||||
## Observability
|
||||
|
||||
- Structured logging fields: `[fill: request id, trace id, service, environment]`
|
||||
- Metrics: `[fill: RED or USE metrics exposed and where]`
|
||||
- Traces: `[fill: span coverage at service boundaries]`
|
||||
- Alerts: `[fill: the alert rules tied to this service]`
|
||||
|
||||
## Testing Plan
|
||||
|
||||
- Unit tests: `[fill: business-logic cases and the fakes used for boundaries]`
|
||||
- Integration tests: `[fill: API contract tests and how the stack is provisioned]`
|
||||
- Contract tests: `[fill: consumer contract tests and their provider]`
|
||||
- Query regression guard: `[fill: query-count assertions or N+1 checks]`
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Alternative 1: `[fill: option considered]` — rejected because `[fill: reason]`
|
||||
- Alternative 2: `[fill: option considered]` — rejected because `[fill: reason]`
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `[fill: any unresolved decision that needs input before implementation]`
|
||||
@@ -4,7 +4,7 @@ Frontend engineering methodology — component architecture, state management, A
|
||||
|
||||
## Why Install This Skill
|
||||
|
||||
Your agent applies proven component architecture, state management, and performance patterns instead of reinventing frontend structure each time.
|
||||
Your agent applies proven component architecture, state management, and performance patterns instead of reinventing frontend structure each time. Fillable templates capture component/state design and performance budgets as reviewable records, and the bundled bundle-budget checker enforces performance budgets in CI.
|
||||
|
||||
## What You Get
|
||||
|
||||
@@ -12,6 +12,9 @@ Your agent applies proven component architecture, state management, and performa
|
||||
|-----------|---------|
|
||||
| `SKILL.md` | Core methodology, trigger conditions, reference index |
|
||||
| `references/` | Deep-dive reference files loaded on demand |
|
||||
| `templates/` | Fillable records: component/state design record, performance budget |
|
||||
| `scripts/` | `bundle-budget-checker.py` — checks bundle size reports against total and per-chunk budgets |
|
||||
| `evals/` | Output-quality eval manifest for the skill's methodology cases |
|
||||
|
||||
## Triggers
|
||||
|
||||
@@ -19,8 +22,16 @@ Building UI components, choosing state management approaches, integrating APIs,
|
||||
|
||||
## Requirements
|
||||
|
||||
Platform-agnostic. Framework-agnostic patterns applicable to React, Vue, Svelte, or vanilla JS.
|
||||
Platform-agnostic. Framework-agnostic patterns applicable to React, Vue, Svelte, or vanilla JS. The bundled script needs only Python 3 (standard library).
|
||||
|
||||
## Quick Start
|
||||
|
||||
Check a bundle size report against total and per-chunk budgets before merging a change:
|
||||
|
||||
```bash
|
||||
python3 frontend-engineering/scripts/bundle-budget-checker.py dist/bundle-report.json --total 500KB --chunk 120KB
|
||||
```
|
||||
|
||||
The report maps chunk names to sizes (or a `{"chunks": [...]}` list from your bundler's analyzer). The script prints each chunk, the budget, and OK/OVER status, and exits 1 when any budget is exceeded — so it can gate CI. Add `--json` for machine-readable output.
|
||||
|
||||
Load SKILL.md for the methodology overview and reference table, then load specific references as needed for the task at hand.
|
||||
|
||||
@@ -38,6 +38,19 @@ Frontend engineering is the craft of building the user-facing layer of applicati
|
||||
| `references/responsive-layout-testing.md` | Implementing responsive designs (layout system selection — Grid vs Flexbox vs Container Queries, breakpoint strategies, cross-device testing methodology) and testing frontend code (component testing with Testing Library, integration testing with Playwright/Cypress, visual regression, accessibility testing with axe-core and Lighthouse CI, test data management) |
|
||||
| `references/performance.md` | Optimizing client-side performance — Core Web Vitals, bundle analysis, code splitting, render optimization |
|
||||
|
||||
## Templates
|
||||
|
||||
| Template | When to Use |
|
||||
|-----------|-------------|
|
||||
| `templates/component-state-design-record.md` | Designing a component tree and state ownership for a feature — decomposition, state scoping, data fetching, and error/loading UX |
|
||||
| `templates/performance-budget.md` | Defining performance targets — bundle byte budgets, Core Web Vitals budgets, measurement setup, and CI enforcement |
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | When to Use |
|
||||
|-----------|-------------|
|
||||
| `scripts/bundle-budget-checker.py` | Checking a bundle size report against total and per-chunk budgets; fails (exit 1) when a budget is exceeded, so CI can block performance regressions |
|
||||
|
||||
## Core Principles
|
||||
|
||||
**Components are the unit of composition, not pages** — Design and build components as reusable, composable units. Pages are assembled from components, not built as monoliths. A well-designed component can be reused in contexts its creator never imagined.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"skill_name": "frontend-engineering",
|
||||
"evals": [
|
||||
{
|
||||
"id": "component-state-design",
|
||||
"prompt": "I am building a checkout flow with a cart summary, shipping address form, payment method selector, and order confirmation. The whole flow currently lives in one giant component with a dozen useState hooks and props threaded through five levels. Redesign the component structure and the state ownership for this flow.",
|
||||
"expected_output": "A component decomposition that breaks the checkout flow into focused, composable components — CartSummary, ShippingAddressForm, PaymentMethodSelector, OrderConfirmation — each with a narrow props interface and its own local state where the state is only used there. The design co-locates state with the components that need it: form field state stays local to each form, the cart contents and order status are server state fetched and cached, and only genuinely shared state (for example the active step or the selected payment method used across siblings) lives in a shared context or store. Every data-dependent component defines loading, empty, error, and success states, and props stay flat and explicit so components remain reusable outside the checkout flow.",
|
||||
"assertions": [
|
||||
"The response decomposes the flow into focused components with narrow, explicit props interfaces",
|
||||
"The response co-locates local state with the components that need it and separates server state from client state",
|
||||
"The response limits shared/global state to what multiple components genuinely need",
|
||||
"The response designs all four states (loading, empty, error, success) for data-dependent components",
|
||||
"The response keeps components reusable by avoiding deep prop drilling and context sprawl"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "state-management-selection",
|
||||
"prompt": "Our team is about to pick a state management approach for a dashboard app: it fetches a lot of server data (users, reports, settings), has some shared UI state (open sidebar, active filters), and lots of form state. We are debating a global store, server-cache libraries, and just component state. What should we choose and where should each kind of state live?",
|
||||
"expected_output": "A state management decision that separates the three kinds of state instead of picking one tool for everything. Server state (users, reports, settings) belongs in a server-cache layer that owns fetching, caching, deduplication, invalidation, and background refetch rather than being copied into a global store. Shared UI state (sidebar, active filters) lives in the smallest scope that covers its consumers — a component-level context or a lightweight store slice. Form and ephemeral state stays local to components. The response explains the tradeoff: a global store adds complexity and becomes a dumping ground when used for server data, while server-cache libraries handle the hard parts (retries, staleness, mutation cache updates) that hand-rolled fetching duplicates. It also covers how the choice scales as the app grows and what migration path looks like if the team already has a store.",
|
||||
"assertions": [
|
||||
"The response separates server state, shared UI state, and local state instead of choosing one tool for all three",
|
||||
"The response routes server data through a server-cache layer with caching, deduplication, and invalidation",
|
||||
"The response keeps shared UI state in the smallest scope that covers its consumers",
|
||||
"The response keeps form and ephemeral state local to components",
|
||||
"The response explains the tradeoffs and a migration path from an existing global store"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "api-integration-design",
|
||||
"prompt": "Our React app needs to talk to a REST API that requires a bearer token, returns paged collections, and occasionally returns 429s. Right now every component calls fetch directly and each screen re-implements token handling and error display. Design the API integration layer for this frontend.",
|
||||
"expected_output": "An API integration layer with a single API client module that owns the base URL, request serialization, auth token attachment and refresh-on-401 handling, and a standard error shape the UI can render. The layer exposes typed functions per domain (listUsers, fetchReport) that components call instead of raw fetch, handles retry with backoff for 429 responses, and normalizes errors into a common structure with a user-facing message plus a machine-readable code. Components receive data through a data-fetching layer (query hook or cache) so loading, error, and success states are handled once instead of per component. The design covers pagination: the client exposes cursor or page helpers so infinite scroll and paginated tables do not reimplement slicing, and auth flows (OAuth/JWT refresh) are handled in the client rather than in components.",
|
||||
"assertions": [
|
||||
"The response centralizes HTTP in one API client module that owns base URL, serialization, and auth token handling",
|
||||
"The response handles 401-triggered token refresh and retry with backoff for 429 responses in the client layer",
|
||||
"The response normalizes errors into a common shape with a user-facing message and a machine-readable code",
|
||||
"The response routes data through a data-fetching layer so loading/error/success states are handled once",
|
||||
"The response covers pagination helpers and keeps auth flows out of individual components"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "data-fetching-loading-error-empty",
|
||||
"prompt": "I need a user profile page that fetches a user by id from /users/{id} and shows their posts. The API can return 404 for a missing user, 500 on server trouble, and an empty list of posts is valid. Design the data-dependent component states for this page.",
|
||||
"expected_output": "A component design that treats loading, error, empty, and success as first-class states. Loading renders a skeleton or spinner with an accessible busy indicator (aria-busy) rather than a blank screen. Error handling distinguishes the 404 case — a clear 'user not found' message with a link back to the directory — from 500s, which show a retry affordance and a user-friendly message while logging the technical detail to the monitoring tool. Empty posts render a purpose-built empty state (an illustration plus a call to action), not an error, because an empty list is a valid success. The success state renders the profile with the posts. The response also covers refetching after a failed load without losing the user's place, and cancelling or ignoring stale responses when the user navigates away.",
|
||||
"assertions": [
|
||||
"The response defines four distinct states: loading, error, empty, and success",
|
||||
"The response renders an accessible loading state instead of a blank screen",
|
||||
"The response distinguishes 404 from 500 handling with different user-facing outcomes",
|
||||
"The response treats an empty list as a valid success with its own empty-state design",
|
||||
"The response covers retry without losing user context and ignoring stale responses after navigation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "performance-review",
|
||||
"prompt": "Our marketing site loads slowly: the initial bundle is 1.4 MB, images are not sized, and Lighthouse shows LCP 4.2 s and CLS 0.35. Walk me through reviewing and fixing the frontend performance of this site.",
|
||||
"expected_output": "A performance review structured around measuring before optimizing: run Lighthouse and collect Core Web Vitals (LCP, CLS, INP, TBT) with field data to confirm the regression source. The fixes target the named problems: split the bundle by route with code splitting and lazy loading so the initial bundle only contains above-the-fold code, remove or defer heavy dependencies, serve properly sized and compressed images with explicit dimensions to eliminate layout shift, preload the LCP element, and use modern formats (AVIF/WebP). CLS is fixed by reserving space for images, ads, and fonts (font-display swap, size-adjust) and avoiding injecting content above already-rendered content. The response prioritizes by impact: the biggest wins first, re-measure after each change, and add a performance budget so regressions are caught in CI.",
|
||||
"assertions": [
|
||||
"The response starts by measuring with Lighthouse and field Core Web Vitals before changing anything",
|
||||
"The response reduces the initial bundle via route-based code splitting and lazy loading",
|
||||
"The response fixes CLS by reserving space for images and fonts and avoiding injected layout shift",
|
||||
"The response addresses image sizing, compression, and modern formats for LCP",
|
||||
"The response prioritizes fixes by impact and adds a performance budget enforced in CI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "performance-budget-implementation",
|
||||
"prompt": "We want to stop our app from getting slower release after release. I need to set up a performance budget: what metrics should it cover, how do we measure it, and how do we enforce it so a regression fails the build? We ship our JS bundle report as JSON.",
|
||||
"expected_output": "A performance-budget plan covering the three dimensions that matter: a byte budget for the initial JS/CSS bundle (for example 250 KB gzipped of route-level code, enforced per route), timing budgets for Core Web Vitals (LCP under 2.5 s, CLS under 0.1, INP under 200 ms) measured by Lighthouse in CI, and a request/asset budget for third-party scripts. The plan measures the bundle from the build output — running the bundle-budget-checker script on the bundle report with --total and --chunk budgets so an oversized chunk fails the build — and measures vitals with Lighthouse in a CI job that fails on budget breach. The response covers the workflow: budgets live in a committed config, alerts go to the team when a PR exceeds them, and every change is compared against the same baseline so the budget is meaningful.",
|
||||
"assertions": [
|
||||
"The response defines byte budgets for route-level JS/CSS and timing budgets for Core Web Vitals",
|
||||
"The response measures the bundle from build output and enforces it in CI",
|
||||
"The response mentions running the bundle-budget-checker script on the bundle report with total and chunk budgets",
|
||||
"The response covers third-party script and request budgets",
|
||||
"The response commits budgets as config and compares every change against the same baseline"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bundle budget checker for frontend-engineering.
|
||||
|
||||
Compares a JavaScript/CSS bundle size report against total and per-chunk byte
|
||||
budgets and fails when a budget is exceeded, so a performance regression stops
|
||||
the build instead of shipping silently.
|
||||
|
||||
Input: a JSON file describing bundle chunks, in either of two shapes:
|
||||
|
||||
{"chunks": [{"name": "main.js", "size": 180000}, ...]} # structured
|
||||
{"main.js": 180000, "vendor.js": 90000} # name -> bytes
|
||||
|
||||
Budgets accept human units: 250KB, 1.5MB, 512000, 10 B (decimal KB/MB/GB or
|
||||
binary KiB/MiB/GiB).
|
||||
|
||||
Exit codes:
|
||||
0 all sizes within budget
|
||||
1 one or more chunks or the total exceed budget
|
||||
2 usage or input error (missing file, malformed JSON, bad budget value)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_UNIT_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?\s*$", re.IGNORECASE)
|
||||
_MULTIPLIERS = {
|
||||
"b": 1,
|
||||
"kb": 1000,
|
||||
"kib": 1024,
|
||||
"mb": 1000**2,
|
||||
"mib": 1024**2,
|
||||
"gb": 1000**3,
|
||||
"gib": 1024**3,
|
||||
}
|
||||
|
||||
|
||||
def parse_size(text):
|
||||
"""Parse '250KB', '1.5MB', or plain byte counts into an int, or None."""
|
||||
match = _UNIT_RE.match(text)
|
||||
if not match:
|
||||
return None
|
||||
value = float(match.group(1))
|
||||
unit = (match.group(2) or "b").lower()
|
||||
return int(value * _MULTIPLIERS[unit])
|
||||
|
||||
|
||||
def human_size(size):
|
||||
"""Render a byte count in a compact human unit (binary)."""
|
||||
value = float(size)
|
||||
for unit in ("B", "KiB", "MiB", "GiB"):
|
||||
if value < 1024 or unit == "GiB":
|
||||
if unit == "B":
|
||||
return f"{int(value)} B"
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
return f"{value:.1f} GiB"
|
||||
|
||||
|
||||
def load_chunks(path):
|
||||
"""Load a bundle report into [(name, bytes)]; raises ValueError on bad input."""
|
||||
try:
|
||||
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
raise ValueError(f"cannot read {path}: {exc.strerror}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"{path}: invalid JSON: {exc.msg}") from exc
|
||||
|
||||
if isinstance(raw, dict):
|
||||
if "chunks" in raw:
|
||||
chunks = raw["chunks"]
|
||||
if not isinstance(chunks, list):
|
||||
raise ValueError(f"{path}: 'chunks' must be a list")
|
||||
return [
|
||||
(str(entry["name"]), int(entry["size"]))
|
||||
for entry in chunks
|
||||
if "name" in entry and "size" in entry
|
||||
]
|
||||
return [(str(name), int(size)) for name, size in raw.items()]
|
||||
raise ValueError(f"{path}: report must be a JSON object of chunk names to sizes")
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="bundle-budget-checker.py",
|
||||
description=(
|
||||
"Check a bundle size report against total and per-chunk budgets. "
|
||||
'Report format: {"chunks": [{"name": ..., "size": bytes}]} '
|
||||
"or a plain {name: bytes} mapping."
|
||||
),
|
||||
epilog="Exit codes: 0 within budget, 1 over budget, 2 usage or input error.",
|
||||
)
|
||||
parser.add_argument("report", metavar="REPORT.json", help="bundle size report")
|
||||
parser.add_argument(
|
||||
"--total",
|
||||
metavar="SIZE",
|
||||
default=None,
|
||||
help="total budget for all chunks, e.g. 500KB or 512000",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--chunk",
|
||||
metavar="SIZE",
|
||||
default=None,
|
||||
help="per-chunk budget, e.g. 120KB; each chunk is checked individually",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="emit a machine-readable JSON report")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
total_budget = parse_size(args.total) if args.total is not None else None
|
||||
chunk_budget = parse_size(args.chunk) if args.chunk is not None else None
|
||||
if args.total is not None and total_budget is None:
|
||||
print(f"ERROR: cannot parse budget {args.total!r}", file=sys.stderr)
|
||||
return 2
|
||||
if args.chunk is not None and chunk_budget is None:
|
||||
print(f"ERROR: cannot parse budget {args.chunk!r}", file=sys.stderr)
|
||||
return 2
|
||||
if total_budget is not None and total_budget < 0:
|
||||
print("ERROR: --total must not be negative", file=sys.stderr)
|
||||
return 2
|
||||
if chunk_budget is not None and chunk_budget < 0:
|
||||
print("ERROR: --chunk must not be negative", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
chunks = load_chunks(args.report)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
total = sum(size for _, size in chunks)
|
||||
rows = []
|
||||
over_budget = False
|
||||
for name, size in chunks:
|
||||
over = chunk_budget is not None and size > chunk_budget
|
||||
over_budget = over_budget or over
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"bytes": size,
|
||||
"budget": chunk_budget,
|
||||
"status": "over" if over else "ok",
|
||||
}
|
||||
)
|
||||
total_over = total_budget is not None and total > total_budget
|
||||
over_budget = over_budget or total_over
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"total": {
|
||||
"bytes": total,
|
||||
"budget": total_budget,
|
||||
"status": "over" if total_over else "ok",
|
||||
},
|
||||
"chunks": rows,
|
||||
"over_budget": over_budget,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
else:
|
||||
print("Bundle budget report")
|
||||
for row in rows:
|
||||
status = "OVER" if row["status"] == "over" else "OK"
|
||||
budget_text = human_size(row["budget"]) if row["budget"] is not None else "unset"
|
||||
print(
|
||||
f" {row['name']:<24} {human_size(row['bytes']):>10} "
|
||||
f"budget {budget_text:>8} {status}"
|
||||
)
|
||||
total_budget_text = human_size(total_budget) if total_budget is not None else "unset"
|
||||
total_status = "OVER" if total_over else "OK"
|
||||
print(
|
||||
f" {'total':<24} {human_size(total):>10} "
|
||||
f"budget {total_budget_text:>8} {total_status}"
|
||||
)
|
||||
if over_budget:
|
||||
print("Result: over budget", file=sys.stderr)
|
||||
else:
|
||||
print("Result: within budget")
|
||||
return 1 if over_budget else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Tests for bundle-budget-checker.py.
|
||||
|
||||
Covers: total and per-chunk budget enforcement, both input shapes (structured
|
||||
chunks list and name->bytes mapping), human unit parsing (250KB, 1.5MB), --json
|
||||
output, no-budget report mode, --help, and error paths (missing file, malformed
|
||||
JSON, bad budget value).
|
||||
|
||||
Discoverable by both pytest and unittest (unittest.TestCase classes).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import suppress
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)))
|
||||
CHECKER = os.path.join(SCRIPTS_DIR, "bundle-budget-checker.py")
|
||||
|
||||
|
||||
def run_checker(args):
|
||||
cmd = [sys.executable, CHECKER, *args]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def write_report(data):
|
||||
"""Write a JSON bundle report to a temp file; return its path."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
) as handle:
|
||||
json.dump(data, handle)
|
||||
path = handle.name
|
||||
return path
|
||||
|
||||
|
||||
def cleanup(path):
|
||||
with suppress(OSError):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class TestBundleBudgetBudgets(unittest.TestCase):
|
||||
def test_within_total_budget(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 100000}]})
|
||||
try:
|
||||
rc, _, _ = run_checker([path, "--total", "250KB"])
|
||||
self.assertEqual(rc, 0)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_over_total_budget(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 300000}]})
|
||||
try:
|
||||
rc, _, stderr = run_checker([path, "--total", "250KB"])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("over budget", stderr)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_over_chunk_budget(self):
|
||||
path = write_report(
|
||||
{"chunks": [{"name": "main.js", "size": 180000}, {"name": "vendor.js", "size": 90000}]}
|
||||
)
|
||||
try:
|
||||
rc, stdout, _ = run_checker([path, "--chunk", "120KB"])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("main.js", stdout)
|
||||
self.assertIn("OVER", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_all_chunks_within_chunk_budget(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 100000}]})
|
||||
try:
|
||||
rc, stdout, _ = run_checker([path, "--chunk", "200KB"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("OK", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_total_and_chunk_combined(self):
|
||||
path = write_report(
|
||||
{"chunks": [{"name": "main.js", "size": 80000}, {"name": "vendor.js", "size": 80000}]}
|
||||
)
|
||||
try:
|
||||
# Within both budgets: total 160 KB <= 200 KB, each chunk <= 100 KB.
|
||||
rc_ok, _, _ = run_checker([path, "--total", "200KB", "--chunk", "100KB"])
|
||||
self.assertEqual(rc_ok, 0)
|
||||
# Over chunk budget but within total: one chunk over 100 KB.
|
||||
rc_chunk, _, _ = run_checker([path, "--total", "300KB", "--chunk", "75KB"])
|
||||
self.assertEqual(rc_chunk, 1)
|
||||
# Over total but within chunk budget.
|
||||
rc_total, _, _ = run_checker([path, "--total", "100KB", "--chunk", "100KB"])
|
||||
self.assertEqual(rc_total, 1)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_no_budget_reports_only(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 180000}]})
|
||||
try:
|
||||
rc, stdout, _ = run_checker([path])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("unset", stdout)
|
||||
self.assertIn("within budget", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
|
||||
class TestBundleBudgetFormats(unittest.TestCase):
|
||||
def test_mapping_input_shape(self):
|
||||
path = write_report({"main.js": 180000, "vendor.js": 90000})
|
||||
try:
|
||||
rc, stdout, _ = run_checker([path, "--total", "300KB"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("main.js", stdout)
|
||||
self.assertIn("vendor.js", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_empty_chunks_list(self):
|
||||
path = write_report({"chunks": []})
|
||||
try:
|
||||
rc, stdout, _ = run_checker([path, "--total", "100KB"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("within budget", stdout)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
|
||||
class TestBundleBudgetParsing(unittest.TestCase):
|
||||
def test_unit_parsing_variants(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 1024}]})
|
||||
try:
|
||||
rc_ok, _, _ = run_checker([path, "--total", "1.5KB"])
|
||||
self.assertEqual(rc_ok, 0)
|
||||
rc_bin, _, _ = run_checker([path, "--total", "1KiB"])
|
||||
self.assertEqual(rc_bin, 0)
|
||||
rc_over, _, _ = run_checker([path, "--total", "512B"])
|
||||
self.assertEqual(rc_over, 1)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_plain_byte_budget(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 512000}]})
|
||||
try:
|
||||
rc, _, _ = run_checker([path, "--total", "512000"])
|
||||
self.assertEqual(rc, 0)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_mib_budget(self):
|
||||
path = write_report({"chunks": [{"name": "app.js", "size": 1500000}]})
|
||||
try:
|
||||
rc, _, _ = run_checker([path, "--total", "2MiB"])
|
||||
self.assertEqual(rc, 0)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
|
||||
class TestBundleBudgetCli(unittest.TestCase):
|
||||
def test_help_exits_zero(self):
|
||||
rc, stdout, _ = run_checker(["--help"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("budget", stdout)
|
||||
|
||||
def test_json_output_parseable(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 300000}]})
|
||||
try:
|
||||
rc, stdout, _ = run_checker([path, "--total", "250KB", "--json"])
|
||||
self.assertEqual(rc, 1)
|
||||
report = json.loads(stdout)
|
||||
self.assertTrue(report["over_budget"])
|
||||
self.assertEqual(report["total"]["status"], "over")
|
||||
self.assertEqual(report["chunks"][0]["name"], "main.js")
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_json_within_budget(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 100000}]})
|
||||
try:
|
||||
rc, stdout, _ = run_checker([path, "--total", "250KB", "--json"])
|
||||
self.assertEqual(rc, 0)
|
||||
report = json.loads(stdout)
|
||||
self.assertFalse(report["over_budget"])
|
||||
self.assertEqual(report["chunks"][0]["status"], "ok")
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_missing_file_exit_two(self):
|
||||
rc, _, stderr = run_checker(["/nonexistent/report.json", "--total", "100KB"])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("ERROR", stderr)
|
||||
|
||||
def test_malformed_json_exit_two(self):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
) as handle:
|
||||
handle.write("{not json")
|
||||
path = handle.name
|
||||
try:
|
||||
rc, _, stderr = run_checker([path, "--total", "100KB"])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("invalid JSON", stderr)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_bad_budget_value_exit_two(self):
|
||||
path = write_report({"chunks": [{"name": "main.js", "size": 1000}]})
|
||||
try:
|
||||
rc, _, stderr = run_checker([path, "--total", "lots"])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("cannot parse budget", stderr)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
def test_wrong_top_level_shape_exit_two(self):
|
||||
path = write_report(["main.js", "vendor.js"])
|
||||
try:
|
||||
rc, _, stderr = run_checker([path, "--total", "100KB"])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("must be a JSON object", stderr)
|
||||
finally:
|
||||
cleanup(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
# Component / State Design Record
|
||||
|
||||
Fill this record when designing a component tree or choosing state ownership
|
||||
for a feature. It captures the decomposition and the state decisions before
|
||||
implementation, so reviewers can validate the structure and future
|
||||
maintainers can see why state lives where it does.
|
||||
|
||||
## Feature
|
||||
|
||||
- Feature or screen: `[fill: what is being built]`
|
||||
- Users and primary tasks: `[fill: who uses this and what they accomplish]`
|
||||
- Entry points: `[fill: routes, modals, or embed points that render this]`
|
||||
|
||||
## Component Tree
|
||||
|
||||
Sketch the component decomposition:
|
||||
|
||||
```
|
||||
[fill: top-level component]
|
||||
├── [fill: child component]
|
||||
│ └── [fill: leaf component]
|
||||
├── [fill: child component]
|
||||
└── [fill: child component]
|
||||
```
|
||||
|
||||
- Composition rules: `[fill: which components are reusable vs feature-specific]`
|
||||
- Props interfaces: `[fill: the props each component takes and why they are minimal]`
|
||||
- What is NOT a component here: `[fill: repeated markup that should stay a component vs markup that stays inline]`
|
||||
|
||||
## State Ownership
|
||||
|
||||
| State | Owner | Kind (local / shared / server) | Why here |
|
||||
|---|---|---|---|
|
||||
| `[fill: state]` | `[fill: component or context/store]` | `[fill: kind]` | `[fill: justification]` |
|
||||
|
||||
- Local state: `[fill: what stays in useState/useReducer inside a component]`
|
||||
- Shared state: `[fill: what is shared and at what scope (component context, route, global)]`
|
||||
- Server state: `[fill: what is fetched and cached, and the cache/invalidation strategy]`
|
||||
|
||||
## Data Fetching
|
||||
|
||||
| Data | Source endpoint | Cache key | Invalidation | States handled |
|
||||
|---|---|---|---|---|
|
||||
| `[fill: data]` | `[fill: endpoint]` | `[fill: key]` | `[fill: when it refetches]` | `[fill: loading/error/empty/success]` |
|
||||
|
||||
- Optimistic updates: `[fill: which mutations update the cache optimistically and the rollback plan]`
|
||||
- Race handling: `[fill: how stale responses and rapid re-fetches are handled]`
|
||||
|
||||
## Error and Loading UX
|
||||
|
||||
- Loading presentation: `[fill: skeletons, spinners, aria-busy usage]`
|
||||
- Error presentation: `[fill: per-error-state UI, retry affordances, 404 vs 5xx handling]`
|
||||
- Empty states: `[fill: what renders when data is valid but empty]`
|
||||
|
||||
## Accessibility and Responsive Notes
|
||||
|
||||
- Keyboard and focus behavior: `[fill: focus management for modals/forms/loading transitions]`
|
||||
- Breakpoint behavior: `[fill: how the layout adapts and what changes per breakpoint]`
|
||||
|
||||
## Testing Plan
|
||||
|
||||
- Component tests: `[fill: the interactions and states covered per component]`
|
||||
- Integration tests: `[fill: flows covered end to end through the component tree]`
|
||||
- Visual regression: `[fill: which screens are snapshotted]`
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Alternative 1: `[fill: option considered]` — rejected because `[fill: reason]`
|
||||
- Alternative 2: `[fill: option considered]` — rejected because `[fill: reason]`
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `[fill: unresolved decision needing input before implementation]`
|
||||
@@ -0,0 +1,57 @@
|
||||
# Performance Budget
|
||||
|
||||
Fill this budget when defining or reviewing frontend performance targets.
|
||||
Budgets are committed config, measured on every change, and enforced in CI so
|
||||
a regression fails the build instead of shipping silently.
|
||||
|
||||
## Budget Dimensions
|
||||
|
||||
| Dimension | Metric | Budget | Measured by | Enforcement point |
|
||||
|---|---|---|---|---|
|
||||
| Bundle size | Initial JS (gzipped) | `[fill: e.g. 250 KB per route]` | Bundle report from the build | Build / CI script |
|
||||
| Bundle size | Initial CSS (gzipped) | `[fill: e.g. 50 KB]` | Build output | Build / CI script |
|
||||
| Bundle size | Largest single chunk | `[fill: e.g. 120 KB]` | Bundle report | `bundle-budget-checker` |
|
||||
| Loading | LCP | `[fill: e.g. 2.5 s]` | Lighthouse (lab + field) | CI Lighthouse job |
|
||||
| Stability | CLS | `[fill: e.g. 0.1]` | Lighthouse | CI Lighthouse job |
|
||||
| Responsiveness | INP | `[fill: e.g. 200 ms]` | Field data / lab | CI Lighthouse job |
|
||||
| Third-party | Script count / weight | `[fill: e.g. max 2 scripts, 50 KB]` | Request audit | CI check |
|
||||
|
||||
## Bundle Budget
|
||||
|
||||
Fill in the enforced numbers for the `bundle-budget-checker` invocation:
|
||||
|
||||
- Total budget for all route chunks: `[fill: bytes or human size, e.g. 512000 or 500KB]`
|
||||
- Per-chunk budget: `[fill: e.g. 120KB]`
|
||||
- Chunks exempt from the per-chunk budget (lazy-loaded vendors, web workers): `[fill: names and reason]`
|
||||
- Command used in CI:
|
||||
```
|
||||
[fill: e.g. python3 frontend-engineering/scripts/bundle-budget-checker.py dist/bundle-report.json --total 500KB --chunk 120KB]
|
||||
```
|
||||
|
||||
## Measurement Setup
|
||||
|
||||
- Lab tooling: `[fill: Lighthouse CI config, mobile + desktop profiles, throttling]`
|
||||
- Field data source: `[fill: CrUX / RUM provider and the percentiles tracked, e.g. p75]`
|
||||
- Baseline commit and scores: `[fill: the recorded baseline so regressions are measured against it]`
|
||||
- How often measurements run: `[fill: every PR, nightly, on release]`
|
||||
|
||||
## Enforcement Workflow
|
||||
|
||||
- Where budgets live: `[fill: committed file path]`
|
||||
- What happens when a PR exceeds a budget: `[fill: CI fails, alert channel, owner follows up]`
|
||||
- Escalation path for deliberate regressions: `[fill: who can approve an exception and how it is tracked]`
|
||||
|
||||
## Known Current Violations
|
||||
|
||||
| Metric | Current value | Budget | Owner | Follow-up |
|
||||
|---|---|---|---|---|
|
||||
| `[fill: metric]` | `[fill: value]` | `[fill: budget]` | `[fill: owner]` | `[fill: linked issue]` |
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [fill: check that] Initial render contains no unused heavy dependencies
|
||||
- [fill: check that] Images are sized, compressed, and dimensioned
|
||||
- [fill: check that] Fonts load with font-display swap and no layout shift
|
||||
- [fill: check that] Route-level code splitting is in place for every page
|
||||
- [fill: check that] Third-party scripts are deferred and counted in the budget
|
||||
- [fill: check that] Measurements are rerun after each change
|
||||
Reference in New Issue
Block a user