Files
breakpilot-compliance/REFACTOR_PLAYBOOK.md
Sharang Parnerkar baf2d8a550 docs: add root README, CONTRIBUTING, onboarding section, gitignore fixes
- README.md: new root README with CI badge, per-service table, quick start,
  CI pipeline table, file budget docs, and links to production endpoints
- CONTRIBUTING.md: dev environment setup, pre-commit checklist, commit
  marker reference, architecture non-negotiables, Claude Code section
- CLAUDE.md: insert First-Time Setup & Claude Code Onboarding section with
  branch guard, hook explanation, guardrail marker table, and staging rules
- REFACTOR_PLAYBOOK.md: commit existing refactor playbook doc
- .gitignore: add dist/, .turbo/, pnpm-lock.yaml, .pnpm-store/ to prevent
  SDK build artifacts from appearing as untracked files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:09:28 +02:00

40 KiB
Raw Blame History


1.9 AGENTS.python.md — Python / FastAPI conventions

# AGENTS.python.md — Python Service Conventions

## Layered architecture (FastAPI)


## 1. Guardrail files (drop these in first)

These artifacts enforce the rules without you or Claude having to remember them. Install them as **Phase 0**, before touching any real code.

### 1.1 `.claude/CLAUDE.md` — loaded into every Claude session

```markdown
# <Your Project Name>

> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
> 3. **Do not touch the database schema.** No new migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner.
> 4. **Public endpoints are a contract.** Any change to a path/method/status/schema in a backend must be accompanied by a matching update in **every** consumer. OpenAPI snapshot tests in `tests/contracts/` are the gate.
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
>
> These rules apply to every Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this CLAUDE.md.

Keep project-specific notes (dev environment, URLs, tech stack) under this header.

1.2 .claude/settings.json — PreToolUse LOC hook

First line of defense. Blocks Write/Edit operations that would create or push a file past 500 lines. This stops Claude from ever producing oversized files.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md.\"}'; exit 0; fi",
            "shell": "bash",
            "timeout": 5
          }
        ]
      },
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing.\\\"}\"; fi; exit 0",
            "shell": "bash",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

1.3 .claude/rules/architecture.md — auto-loaded architecture rule

# Architecture Rules (auto-loaded)

Non-negotiable. Applied to every Claude Code session in this repo.

## File-size budget
- **Soft target:** 300 lines. **Hard cap:** 500 lines.
- Enforced by PreToolUse hook, pre-commit hook, and CI.
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require `[guardrail-change]` in the commit message. This list should SHRINK over time.

## Clean architecture
- Python: see `AGENTS.python.md`. Layering: api → services → repositories → db.models.
- Go: see `AGENTS.go.md`. Standard Go Project Layout + hexagonal.
- TypeScript: see `AGENTS.typescript.md`. Server-by-default, push client boundary deep, colocate `_components/` and `_hooks/` per route.

## Database is frozen
- No new migrations. No `ALTER TABLE`. No column renames.
- Pre-commit hook blocks any change under `migrations/` unless commit message contains `[migration-approved]`.

## Public endpoints are a contract
- Any change to path/method/status/schema must update every consumer in the same change set.
- OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.

## Tests
- New code without tests fails CI.
- Refactors preserve coverage. Before splitting an oversized file, add a characterization test pinning current behavior.
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.

## Guardrails are protected
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message.
- If Claude thinks a rule is wrong, surface it to the user. Do not silently weaken.

## Tooling baseline
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
- Go: `golangci-lint` strict, `go vet`, table-driven tests.
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
- All: dependency caching in CI, license/SBOM scan via `syft`+`grype`.

1.4 .claude/rules/loc-exceptions.txt

# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
#
# Format: one repo-relative path per line. Comments start with '#'.
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
# Goal: this list SHRINKS over time.

# --- Example entries ---
# Static data catalogs — splitting fragments lookup tables without improving readability.
# src/catalogs/country-data.ts
# src/catalogs/industry-taxonomy.ts

# Generated files — regenerated from schemas.
# api/generated/types.ts

1.5 scripts/check-loc.sh

#!/usr/bin/env bash
# check-loc.sh — File-size budget enforcer. Soft: 300. Hard: 500.
#
# Usage:
#   scripts/check-loc.sh                    # scan whole repo
#   scripts/check-loc.sh --changed          # only files changed vs origin/main
#   scripts/check-loc.sh path/to/file.py    # check specific files
#   scripts/check-loc.sh --json             # machine-readable output
# Exit codes: 0 clean, 1 hard violation, 2 bad invocation.

set -euo pipefail
SOFT=300
HARD=500
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
EXCEPTIONS_FILE="$REPO_ROOT/.claude/rules/loc-exceptions.txt"

CHANGED_ONLY=0; JSON=0; TARGETS=()
for arg in "$@"; do
  case "$arg" in
    --changed) CHANGED_ONLY=1 ;;
    --json) JSON=1 ;;
    -h|--help) sed -n '2,10p' "$0"; exit 0 ;;
    -*) echo "unknown flag: $arg" >&2; exit 2 ;;
    *) TARGETS+=("$arg") ;;
  esac
done

is_excluded() {
  local f="$1"
  case "$f" in
    */node_modules/*|*/.next/*|*/.git/*|*/dist/*|*/build/*|*/__pycache__/*|*/vendor/*) return 0 ;;
    */migrations/*|*/alembic/versions/*) return 0 ;;
    *_test.go|*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx) return 0 ;;
    */tests/*|*/test/*) return 0 ;;
    *.md|*.json|*.yaml|*.yml|*.lock|*.sum|*.mod|*.toml|*.cfg|*.ini) return 0 ;;
    *.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.woff|*.woff2|*.ttf) return 0 ;;
    *.generated.*|*.gen.*|*_pb.go|*_pb2.py|*.pb.go) return 0 ;;
  esac
  return 1
}
is_in_exceptions() {
  [[ -f "$EXCEPTIONS_FILE" ]] || return 1
  local rel="${1#$REPO_ROOT/}"
  grep -Fxq "$rel" "$EXCEPTIONS_FILE"
}
collect_targets() {
  if (( ${#TARGETS[@]} > 0 )); then printf '%s\n' "${TARGETS[@]}"
  elif (( CHANGED_ONLY )); then
    git -C "$REPO_ROOT" diff --name-only --diff-filter=AM origin/main...HEAD 2>/dev/null \
      || git -C "$REPO_ROOT" diff --name-only --diff-filter=AM HEAD
  else git -C "$REPO_ROOT" ls-files; fi
}

violations_hard=(); violations_soft=()
while IFS= read -r f; do
  [[ -z "$f" ]] && continue
  abs="$f"; [[ "$abs" != /* ]] && abs="$REPO_ROOT/$f"
  [[ -f "$abs" ]] || continue
  is_excluded "$abs" && continue
  is_in_exceptions "$abs" && continue
  loc=$(wc -l < "$abs" | tr -d ' ')
  if (( loc > HARD )); then violations_hard+=("$loc	$f")
  elif (( loc > SOFT )); then violations_soft+=("$loc	$f"); fi
done < <(collect_targets)

if (( JSON )); then
  printf '{"hard":['
  first=1; for v in "${violations_hard[@]}"; do
    loc="${v%%	*}"; path="${v#*	}"
    (( first )) || printf ','; first=0
    printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
  done
  printf '],"soft":['
  first=1; for v in "${violations_soft[@]}"; do
    loc="${v%%	*}"; path="${v#*	}"
    (( first )) || printf ','; first=0
    printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
  done
  printf ']}\n'
else
  if (( ${#violations_soft[@]} > 0 )); then
    echo "::warning:: $((${#violations_soft[@]})) file(s) exceed soft target ($SOFT lines):"
    printf '  %s\n' "${violations_soft[@]}" | sort -rn
  fi
  if (( ${#violations_hard[@]} > 0 )); then
    echo "::error:: $((${#violations_hard[@]})) file(s) exceed HARD CAP ($HARD lines) — split required:"
    printf '  %s\n' "${violations_hard[@]}" | sort -rn
  fi
fi
(( ${#violations_hard[@]} == 0 ))

Make executable: chmod +x scripts/check-loc.sh.

1.6 scripts/githooks/pre-commit

#!/usr/bin/env bash
# pre-commit — enforces structural guardrails.
#
# 1. Blocks commits that introduce a non-test, non-generated source file > 500 LOC.
# 2. Blocks commits touching migrations/ unless commit message contains [migration-approved].
# 3. Blocks edits to guardrail files unless [guardrail-change] is in the commit message.

set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"

mapfile -t staged < <(git diff --cached --name-only --diff-filter=ACM)
[[ ${#staged[@]} -eq 0 ]] && exit 0

# 1. LOC budget on staged files.
loc_targets=()
for f in "${staged[@]}"; do
  [[ -f "$REPO_ROOT/$f" ]] && loc_targets+=("$REPO_ROOT/$f")
done
if [[ ${#loc_targets[@]} -gt 0 ]]; then
  if ! "$REPO_ROOT/scripts/check-loc.sh" "${loc_targets[@]}"; then
    echo; echo "Commit blocked: file-size budget violated."
    echo "Split the file (preferred) or add to .claude/rules/loc-exceptions.txt."
    exit 1
  fi
fi

# 2. Migrations frozen unless approved.
if printf '%s\n' "${staged[@]}" | grep -qE '(^|/)(migrations|alembic/versions)/'; then
  if ! grep -q '\[migration-approved\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
    echo "Commit blocked: this change touches a migrations directory."
    echo "Add '[migration-approved]' to your commit message if approved."
    exit 1
  fi
fi

# 3. Guardrail files protected.
guarded='^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$'
if printf '%s\n' "${staged[@]}" | grep -qE "$guarded"; then
  if ! grep -q '\[guardrail-change\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
    echo "Commit blocked: this change modifies guardrail files."
    echo "Add '[guardrail-change]' to your commit message and explain why in the body."
    exit 1
  fi
fi
exit 0

1.7 scripts/install-hooks.sh

#!/usr/bin/env bash
# install-hooks.sh — installs git hooks that enforce repo guardrails locally.
# Idempotent. Safe to re-run. Run once per clone: bash scripts/install-hooks.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOOKS_DIR="$REPO_ROOT/.git/hooks"
SRC_DIR="$REPO_ROOT/scripts/githooks"

[[ -d "$REPO_ROOT/.git" ]] || { echo "Not a git repository: $REPO_ROOT" >&2; exit 1; }
mkdir -p "$HOOKS_DIR"
for hook in pre-commit; do
  src="$SRC_DIR/$hook"; dst="$HOOKS_DIR/$hook"
  if [[ -f "$src" ]]; then cp "$src" "$dst"; chmod +x "$dst"; echo "installed: $dst"; fi
done
echo "Done. Hooks active for this clone."

1.8 CI additions (.github/workflows/ci.yaml or .gitea/workflows/ci.yaml)

Add a loc-budget job that fails on hard violations:

jobs:
  loc-budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check file-size budget
        run: bash scripts/check-loc.sh --changed

  python-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: ruff
        run: pip install ruff && ruff check .
      - name: mypy on new modules
        run: pip install mypy && mypy --strict services/ repositories/ domain/

  go-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: golangci-lint
        uses: golangci/golangci-lint-action@v4
        with: { version: latest }

  ts-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npx tsc --noEmit && npx next build

  contract-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pytest tests/contracts/ -v

  license-sbom-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: anchore/sbom-action@v0
      - uses: anchore/scan-action@v3

1.9 AGENTS.python.md (Python / FastAPI)

# AGENTS.python.md — Python Service Conventions

## Layered architecture

```
<service>/
├── api/                # HTTP layer — routers only. Thin (≤30 LOC per handler).
│   └── <domain>_routes.py
├── services/           # Business logic. Pure-ish; no FastAPI imports.
├── repositories/       # DB access. Owns SQLAlchemy session usage.
├── domain/             # Value objects, enums, domain exceptions.
├── schemas/            # Pydantic models, split per domain. Never one giant schemas.py.
└── db/models/          # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
```

Dependency direction: `api → services → repositories → db.models`. Lower layers must not import upper.

## Routers
- One `APIRouter` per domain file. Handlers ≤30 LOC.
- Parse request → call service → map domain errors → return response model.
- Inject services via `Depends`. No globals.

```python
@router.post("/items", response_model=ItemRead, status_code=201)
async def create_item(
    payload: ItemCreate,
    service: ItemService = Depends(get_item_service),
    tenant_id: UUID = Depends(get_tenant_id),
) -> ItemRead:
    with translate_domain_errors():
        return await service.create(tenant_id, payload)
```

## Domain errors + translator

```python
# domain/errors.py
class DomainError(Exception): ...
class NotFoundError(DomainError): ...
class ConflictError(DomainError): ...
class ValidationError(DomainError): ...
class PermissionError(DomainError): ...

# api/_http_errors.py
from contextlib import contextmanager
from fastapi import HTTPException

@contextmanager
def translate_domain_errors():
    try: yield
    except NotFoundError as e:   raise HTTPException(404, str(e)) from e
    except ConflictError as e:   raise HTTPException(409, str(e)) from e
    except ValidationError as e: raise HTTPException(400, str(e)) from e
    except PermissionError as e: raise HTTPException(403, str(e)) from e
```

## Services
- Constructor takes repository interface, not concrete.
- No FastAPI / HTTP knowledge.
- Raise domain exceptions, never HTTPException.

## Repositories
- Intent-named methods (`get_pending_for_tenant`), not CRUD-named (`select_where`).
- Session injected. No business logic.
- Return ORM models or domain VOs; never `Row`.

## Schemas (Pydantic v2)
- One module per domain. ≤300 lines.
- `model_config = ConfigDict(from_attributes=True, frozen=True)` for reads.
- Separate `*Create`, `*Update`, `*Read`.

## Tests
- `tests/unit/`, `tests/integration/`, `tests/contracts/`.
- Unit tests mock repository via `AsyncMock`.
- Integration tests use real Postgres from compose via transactional fixture (rollback per test).
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
- Naming: `test_<unit>_<scenario>_<expected>.py::TestX::test_method`.
- `pytest-asyncio` mode = `auto`. Coverage target: 80% new code.

## Tooling
- `ruff check` + `ruff format` (line length 100).
- `mypy --strict` on `services/`, `repositories/`, `domain/` first. Expand outward via per-module overrides in mypy.ini:

```ini
[mypy]
strict = True

[mypy-<service>.services.*]
strict = True

[mypy-<service>.legacy.*]
# Legacy modules not yet refactored — expand strictness over time.
ignore_errors = True
```

## What you may NOT do
- Add a new migration.
- Rename `__tablename__`, column, or enum value.
- Change route contract without simultaneous consumer update.
- Catch `Exception` broadly.
- Put business logic in a router or a Pydantic validator.
- Create a file > 500 lines.

1.10 AGENTS.go.md (Go / Gin or chi)

# AGENTS.go.md — Go Service Conventions

## Layered architecture (Standard Go Project Layout + hexagonal)

```
<service>/
├── cmd/server/main.go        # Thin: parse flags → app.New → app.Run. < 50 LOC.
├── internal/
│   ├── app/                  # Wiring: config + DI + lifecycle.
│   ├── domain/<aggregate>/   # Pure types, interfaces, errors. No I/O.
│   ├── service/<aggregate>/  # Business logic. Depends on domain interfaces.
│   ├── repository/postgres/<aggregate>/   # Concrete repos.
│   ├── transport/http/
│   │   ├── handler/<aggregate>/
│   │   ├── middleware/
│   │   └── router.go
│   └── platform/             # DB pool, logger, config, tracing.
└── pkg/                      # Importable by other repos. Empty unless needed.
```

Direction: `transport → service → domain ← repository`. `domain` imports no siblings.

## Handlers
- ≤40 LOC. Bind → call service → map error via `httperr.Write(c, err)` → respond.

```go
func (h *ItemHandler) Create(c *gin.Context) {
    var req CreateItemRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        httperr.Write(c, httperr.BadRequest(err)); return
    }
    out, err := h.svc.Create(c.Request.Context(), req.ToInput())
    if err != nil { httperr.Write(c, err); return }
    c.JSON(http.StatusCreated, out)
}
```

## Errors — single `httperr` package
```go
switch {
case errors.Is(err, domain.ErrNotFound): return 404
case errors.Is(err, domain.ErrConflict): return 409
case errors.As(err, &validationErr):     return 422
default:                                  return 500
}
```
Never `panic` in request handling. Recovery middleware logs and returns 500.

## Services
- Struct + constructor + interface methods. No package-level state.
- `context.Context` first arg always.
- Return `(value, error)`. Wrap with `fmt.Errorf("create item: %w", err)`.
- Domain errors as sentinel vars or typed; match with `errors.Is` / `errors.As`.

## Repositories
- Interface in `domain/<aggregate>/repository.go`. Impl in `repository/postgres/<aggregate>/`.
- One file per query group; no file > 500 LOC.
- `pgx`/`sqlc` over hand-rolled SQL. No ORM globals. Everything takes `ctx`.

## Tests
- Co-located `*_test.go`. Table-driven for service logic.
- Handlers via `httptest.NewRecorder`.
- Repos via `testcontainers-go` (or the compose Postgres). Never mocks at SQL boundary.
- Coverage target: 80% on `service/`.

## Tooling (`golangci-lint` strict config)
- Linters: `errcheck, govet, staticcheck, revive, gosec, gocyclo(max 15), gocognit(max 20), unused, ineffassign, errorlint, nilerr, nolintlint, contextcheck`.
- `gofumpt` formatting. `go vet ./...` clean. `go mod tidy` clean.

## What you may NOT do
- Touch DB schema/migrations.
- Add a new top-level package under `internal/` without review.
- `import "C"`, unsafe, reflection-heavy code.
- Non-trivial setup in `init()`. Wire in `internal/app`.
- File > 500 lines.
- Change route contract without updating consumers.

1.11 AGENTS.typescript.md (TypeScript / Next.js)

# AGENTS.typescript.md — TypeScript / Next.js Conventions

## Layered architecture (Next.js 15 App Router)

```
app/
├── <route>/
│   ├── page.tsx              # Server Component by default. ≤200 LOC.
│   ├── layout.tsx
│   ├── _components/          # Private folder; colocated UI. Each file ≤300 LOC.
│   ├── _hooks/               # Client hooks for this route.
│   ├── _server/              # Server actions, data loaders for this route.
│   └── loading.tsx / error.tsx
├── api/<domain>/route.ts     # Thin handler. Delegates to lib/server/<domain>/.
lib/
├── <domain>/                 # Pure helpers, types, zod schemas. Reusable.
└── server/<domain>/          # Server-only logic; uses "server-only".
components/                   # Truly shared, app-wide components.
```

Server vs Client: default is Server Component. Add `"use client"` only when state/effects/browser APIs needed. Push client boundary as deep as possible.

## API routes (route.ts)
- One handler per HTTP method, ≤40 LOC.
- Validate with `zod`. Reject invalid → 400.
- Delegate to `lib/server/<domain>/`.

```ts
export async function POST(req: Request) {
  const parsed = CreateItemSchema.safeParse(await req.json());
  if (!parsed.success)
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
  const result = await itemService.create(parsed.data);
  return NextResponse.json(result, { status: 201 });
}
```

## Page components
- Pages > 300 lines → split into colocated `_components/`.
- Server Components fetch data; pass plain objects to Client Components.
- No data fetching in `useEffect` for server-renderable data.
- State: prefer URL state (`searchParams`) + Server Components over global stores.

## Types — barrel re-export pattern for splitting monolithic type files

```ts
// lib/sdk/types/index.ts
export * from './enums'
export * from './vendor'
export * from './dsfa'
// consumers still `import { Foo } from '@/lib/sdk/types'`
```

Rules: no `any`. No `as unknown as`. All DTOs are zod schemas; infer via `z.infer`.

## Tests
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
- Hooks: `@testing-library/react` `renderHook`.
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page minimum.
- Coverage: 70% on `lib/`, smoke on `app/`.

## Tooling
- `tsc --noEmit` clean (strict, `noUncheckedIndexedAccess: true`).
- ESLint with `@typescript-eslint`, type-aware rules on.
- `next build` clean. No `@ts-ignore`. `@ts-expect-error` only with a reason comment.

## What you may NOT do
- Business logic in `page.tsx` or `route.ts`.
- Cross-app module imports.
- `dangerouslySetInnerHTML` without explicit sanitization.
- Backend API calls from Client Components when a Server Component/Action would do.
- Change route contract without updating consumers in the same change.
- File > 500 lines.
- Globally disable lint/type rules — fix the root cause.

2. Phase plan — behavior-preserving refactor

Work in phases. Each phase ends green (tests pass, build clean, contract baseline unchanged). Do not skip ahead.

Phase 0 — Foundation (single PR, low risk)

Goal: Set up rails. No code refactors yet.

  1. Drop in all files from Section 1. Install hooks: bash scripts/install-hooks.sh.
  2. Populate .claude/rules/loc-exceptions.txt with grandfathered entries (one line each, with a comment rationale) so CI doesn't fail day 1.
  3. Append the non-negotiable rules block to root CLAUDE.md.
  4. Add per-language AGENTS.*.md at repo root.
  5. Add the CI jobs from §1.8.
  6. Per-service README.md + CLAUDE.md stubs: what it does, run/test commands, layered architecture diagram, env vars, API surface link.

Verification: CI green; loc-budget job passes with allowlist; next Claude session loads the rules automatically.

Phase 1 — Backend service (Python/FastAPI)

Critical targets: any routes.py / schemas.py / repository.py / models.py over 500 LOC.

Steps:

  1. Snapshot the API contract: curl /openapi.json > tests/contracts/openapi.baseline.json. Add a contract test that diffs current vs baseline and fails on any path/method/param drift.
  2. Characterization tests first. For each oversized route file, add TestClient tests exercising every endpoint (happy path + one error path). Use httpx.AsyncClient + factory fixtures.
  3. Split models.py per aggregate. Keep a shim: from <service>.db.models import * re-exports so existing imports keep working. One module per aggregate; __tablename__ unchanged (no migration).
  4. Split schemas.py similarly with a re-export shim.
  5. Extract service layer. Each route handler delegates to a *Service class injected via Depends. Handlers shrink to ≤30 LOC.
  6. Repository extraction from the giant repository file; one class per aggregate.
  7. mypy --strict scoped to new packages first. Expand outward via mypy.ini per-module overrides.
  8. Tests: unit tests per service (mocked repo), repo tests against a transactional fixture (real Postgres), integration tests at API layer.

Gotchas we hit:

  • Tests that patch module-level symbols (e.g. SessionLocal, scan_X) break when you move logic behind Depends. Fix: re-export the symbol from the route module, or have the service lookup use the module-level symbol directly so the patch still takes effect.
  • from __future__ import annotations can break Pydantic TypeAdapter forward refs. Remove it where it conflicts.
  • Sibling test file status codes drift when you introduce the domain-error translator (e.g. 422 → 400). Update assertions in the same commit.

Verification: all pytest files green. Characterization tests green. Contract test green (no drift). mypy clean on new packages. Coverage ≥ baseline + 10%.

Phase 2 — Go backend

Critical targets: any handler / store / rules file over 500 LOC.

Steps:

  1. OpenAPI/Swagger snapshot (or generate via swag) → contract tests.
  2. Generate handler-level tests with httptest for every endpoint pre-refactor.
  3. Define hexagonal layout (see AGENTS.go.md). Move incrementally with type aliases for back-compat where needed.
  4. Replace ad-hoc error handling with errors.Is/As + a single httperr package.
  5. Add golangci-lint strict config; fix new findings only (don't chase legacy lint).
  6. Table-driven service tests. testcontainers-go for repo layer.

Verification: go test ./... passes; golangci-lint run clean; contract tests green; no DB schema diff.

Phase 3 — Frontend (Next.js)

Biggest beast — expect this to dominate. Critical targets: page.tsx / monolithic types / API routes over 500 LOC.

Per oversized page:

  1. Extract presentational components into app/<route>/_components/ (private folder, Next.js convention).
  2. Move data fetching into Server Components / Server Actions; Client Components become small.
  3. Hooks → app/<route>/_hooks/.
  4. Pure helpers → lib/<domain>/.
  5. Add Vitest unit tests for hooks and pure helpers; Playwright smoke tests for each top-level page.

Monolithic types file: use barrel re-export pattern.

  • Create types/ directory with domain files.
  • Create types/index.ts with export * from './<domain>' lines.
  • Critical: TypeScript won't allow both types.ts AND types/index.ts — delete the file, atomic swap to directory.

API routes (route.ts): same router→service split as backend. Each route.ts becomes a thin handler delegating to lib/server/<domain>/.

Endpoint preservation: if any internal route URL changes, grep every consumer (SDK packages, developer portal, sibling apps) and update in the same change.

Gotchas:

  • Pre-existing type bugs often surface when you try to build. Fix them as drive-by if they block your refactor; otherwise document in a separate follow-up.
  • useClient component imports from '../provider' that rely on re-exports: preserve the re-export or update importers in the same commit.
  • Next.js build can fail at page-manifest stage with unrelated prerender errors. Run next build fresh (not from cache) to see real status.

Verification: next build clean; tsc --noEmit clean; Playwright smoke tests pass; visual diff check on key pages (manual + screenshots in PR).

Phase 4 — SDKs & smaller services

Apply the same patterns at smaller scale:

  • SDK packages (0 tests): add Vitest unit tests for public surface before/while splitting.
  • Manager/Client classes: extract config defaults, side-effect helpers (e.g. Google Consent Mode wiring), framework adapters into sibling files. Keep the main class as orchestration.
  • Framework adapters (React/Vue/Angular): each component/composable/service/module goes in its own sibling file; the entry index.ts is a thin barrel of re-exports.
  • Doc monoliths (index.md thousands of lines): split per topic with mkdocs nav.

Phase 5 — CI hardening & governance

  1. Promote loc-budget from warning → blocking once the allowlist has drained to legitimate exceptions only.
  2. Add mutation testing in nightly (mutmut for Python, gomutesting for Go).
  3. Add dependabot/renovate for npm + pip + go mod.
  4. Add release tagging workflow.
  5. Write ADRs (docs/adr/) capturing the architecture decisions from phases 13.
  6. Distill recurring patterns into .claude/rules/ updates.

3. Agent prompt templates

When the work volume is big, parallelize with subagents. These prompts were battle-tested in practice.

3.1 Backend route file split (Python)

You are working in <repo> on branch <branch>. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300.

Task: split <path/to/file>_routes.py (NNN LOC) following the router → service → repository layering described in AGENTS.python.md.

Steps:

  1. Snapshot the relevant slice of /openapi.json and add a contract test that pins current behavior.
  2. Add characterization tests for every endpoint in this file (happy path + one error path) using httpx.AsyncClient.
  3. Extract each route handler's business logic into a <domain>Service class in <service>/services/<domain>_service.py. Inject via Depends(get_<domain>_service).
  4. Raise domain errors (NotFoundError, ConflictError, ValidationError), never HTTPException. Use the translate_domain_errors() context manager in handlers.
  5. Move DB access to <service>/repositories/<domain>_repository.py. Session injected.
  6. Split Pydantic schemas from the giant schemas.py into <service>/schemas/<domain>.py if >300 lines.

Constraints:

  • Behavior preservation. No route rename/method/status/schema changes.
  • Tests that patch module-level symbols must keep working — re-export the symbol or refactor the lookup so the patch still takes effect.
  • Run pytest after each step. Commit each file as its own commit.
  • Push at end: git push origin <branch>.

When done, report: (a) new LOC counts, (b) test results, (c) mypy status, (d) commit SHAs. Under 300 words.

3.2 Go handler file split

You are working in <repo> on branch <branch>. Hard cap 500 LOC.

Task: split <path>/handlers/<domain>_handler.go (NNN LOC) into a hexagonal layout per AGENTS.go.md.

Steps:

  1. Add httptest tests for every endpoint pre-refactor.
  2. Define internal/domain/<aggregate>/ with types + interfaces + sentinel errors.
  3. Create internal/service/<aggregate>/ with business logic implementing domain interfaces.
  4. Create internal/repository/postgres/<aggregate>/ splitting queries by group.
  5. Thin handlers under internal/transport/http/handler/<aggregate>/. Each handler ≤40 LOC. Error mapping via internal/platform/httperr.
  6. Use errors.Is / errors.As for domain error matching.

Constraints:

  • No DB schema change.
  • Table-driven service tests. testcontainers-go (or compose Postgres) for repo tests.
  • golangci-lint run clean.

Report new LOC, test status, lint status, commit SHAs. Under 300 words.

3.3 Next.js page split (the one we parallelized heavily)

You are working in <repo> on branch <branch>. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300. Other agents are working on OTHER pages in parallel — stay in your lane.

Task: split the following Next.js 15 App Router client pages into colocated components so each page.tsx drops below 500 LOC.

  1. admin-compliance/app/sdk/<page-a>/page.tsx (NNNN LOC)
  2. admin-compliance/app/sdk/<page-b>/page.tsx (NNNN LOC)

Pattern (reference admin-compliance/app/sdk/<already-split-example>/ for "done"):

  • Create _components/ subdirectory (Next.js private folder, won't create routes).
  • Extract each logically-grouped section (forms, tables, modals, tabs, headers, cards) into its own component file. Name files after the component.
  • Create _hooks/ for custom hooks that were inline.
  • Create _types.ts or _data.ts for hoisted types or data arrays.
  • Remaining page.tsx wires extracted pieces — aim for under 300 LOC, hard cap 500.
  • Preserve 'use client' when present on original.
  • DO NOT rename any exports that other files import. Grep first before moving.

Constraints:

  • Behavior preservation. No logic changes, no improvements.
  • Imports must resolve (relative ./_components/Foo).
  • Run cd admin-compliance && npx next build after each file is done. Don't commit broken builds.
  • DO NOT edit .claude/settings.json, scripts/check-loc.sh, loc-exceptions.txt, or any AGENTS.*.md.
  • Commit each page as its own commit: refactor(admin): split <name> page.tsx into colocated components. HEREDOC body, include Co-Authored-By: trailer.
  • Pull before push: git pull --rebase origin <branch>, then git push origin <branch>.

Coordination: DO NOT touch <list of pages other agents own>. You own only <your pages>.

When done, report: (a) each file's new LOC count, (b) how many _components were created, (c) whether next build is clean, (d) commit SHAs. Under 300 words.

If the LOC hook blocks a Write, split further. If you hit rate limits partway, commit what's done and report progress honestly.

3.4 Monolithic types file split (TypeScript)

<repo>, branch <branch>. Hard cap 500 LOC.

Task: split <lib>/types.ts (NNNN LOC) into per-domain modules under <lib>/types/.

Steps:

  1. Identify domain groupings (enums, API DTOs, one group per business aggregate).
  2. Create <lib>/types/ directory with <domain>.ts files.
  3. Create <lib>/types/index.ts barrel: export * from './<domain>' per file.
  4. Atomic swap: delete the old types.ts in the same commit as the new types/ directory. TypeScript won't resolve both a file and a directory with the same stem.
  5. Grep every consumer — imports from '<lib>/types' should still work via the barrel. No consumer file changes needed unless there's a name collision.
  6. Resolve collisions by renaming the less-canonical export (e.g. if two modules both export LegalDocument, rename the RAG one to RagLegalDocument).

Verification: tsc --noEmit clean, next build clean.

Report new LOC per file, collisions resolved, consumer updates, commit SHAs.

3.5 Agent orchestration rules (from hard-won experience)

When you spawn multiple agents in parallel:

  1. Own disjoint paths. Give each agent a bounded list of files under specific directories. Spell out the "do NOT touch" list explicitly.
  2. Always instruct git pull --rebase origin <branch> before push. Agents running in parallel will push and cause non-fast-forward rejects without this.
  3. Instruct commit each file as its own commit — not a single mega-commit. Makes revert surgical.
  4. Ask for concise reports (≤300 words): new LOC counts, component counts, build status, commit SHAs.
  5. Tell them to commit partial progress on rate-limit. If they don't, their partial work lives in the working tree and you have to chase it with git status after. (We hit this — 4 agents silently left uncommitted work.)
  6. Don't give an agent more than 2 big files at once. Each page-split in practice took ~1020 minutes + ~150k tokens. Two is a comfortable batch.
  7. Reference a prior "done" example. Commit SHAs are gold — the agent can inspect exactly the style you want.
  8. Run one final next build / pytest / go test yourself after all agents finish. Agent reports of "build clean" can be scoped (e.g. only their files); you want the whole-repo gate.

4. Workflow loop (per file)

1. Read the oversized file end to end. Identify 36 extraction sections.
2. Write characterization test (if backend) — pin behavior.
3. Create the sibling files one at a time.
   - If the PreToolUse hook blocks (file still > 500), split further.
4. Edit the root file: replace extracted bodies with imports + delegations.
5. Run the full verification: pytest / next build / go test.
6. Run LOC check: scripts/check-loc.sh <changed files>
7. Commit with a scoped message and a 12 line body explaining why.
8. Push.

5. Commit message conventions

refactor(<area>): <one-line what, not how>

<optional 1-3 sentence body: what split changed + verification result>
<LOC table: before → after per file>
<non-behavior changes flagged as drive-by fixes, with reason>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Markers that unlock pre-commit guards:

  • [migration-approved] — allows changes under migrations/ / alembic/versions/.
  • [guardrail-change] — allows changes to .claude/settings.json, .claude/rules/loc-exceptions.txt, scripts/check-loc.sh, scripts/githooks/pre-commit, or any AGENTS.*.md.

Good examples from our session:

  • refactor(consent-sdk): split ConsentManager + framework adapters under 500 LOC
  • refactor(compliance-sdk): split client/provider/embed/state under 500 LOC
  • refactor(admin): split whistleblower page.tsx + restore scope helpers
  • chore: document data-catalog + legacy-service LOC exceptions (with [guardrail-change] body)

6. Verification commands cheatsheet

# LOC budget
scripts/check-loc.sh --changed               # only changed files
scripts/check-loc.sh                         # whole repo
scripts/check-loc.sh --json                  # for CI parsing

# Python
pytest --cov=<package> --cov-report=term-missing
ruff check .
mypy --strict <package>/services <package>/repositories

# Go
go test ./... -cover
golangci-lint run
go vet ./...

# TypeScript
npx tsc --noEmit
npx next build                               # from the Next.js app dir
npm test -- --run                            # vitest one-shot
npx playwright test tests/e2e                # e2e smoke

# Contracts
pytest tests/contracts/                       # OpenAPI snapshot diff

7. Out of scope (don't drift)

  • DB schema / migrations — unless separate green-lit plan.
  • New features. This is a refactor.
  • Public endpoint renames without simultaneous consumer fix-up (exception: intra-monorepo URLs when you do the grep sweep).
  • Unrelated dead code cleanup — do it in a separate PR.
  • Bundling refactors across services in one commit — one service = one commit.

8. Memory / session handoff

If using Claude Code with persistent memory, save a project_refactor_status.md in your memory store after each phase:

  • What's done (files split, LOC before → after).
  • What's in progress (current file, blocker if any).
  • What's deferred (pre-existing bugs surfaced but left for follow-up).
  • Key patterns established (so next session doesn't rediscover them).

This lets you resume after context compacts or after rate-limit windows without losing the thread.


That's the whole methodology. Install Section 1, follow Section 2 phase-by-phase, use Section 3 to parallelize the grind. The guardrails do the policing so you don't have to remember anything.