Contributing
Coding standards, testing expectations, file organization, auth patterns, and quality rules for the Sprinter Platform.
Contributing
This guide covers the standards and conventions for contributing to the Sprinter Platform. These rules are enforced across the codebase and apply to all contributors -- human and AI alike.
Coding standards
Use shadcn/ui for all interactive UI
Every button, input, select, card, dialog, and interactive element must come from @/components/ui/. Never use raw HTML <button>, <input>, or <select> elements.
// Correct
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
// Wrong
<button onClick={handleClick}>Save</button>
<input type="text" onChange={handleChange} />No hardcoded colors
Never use Tailwind color classes like text-blue-600, bg-red-50, or text-green-800. Use CSS variables from the design system:
| Need | Class |
|---|---|
| Primary text/bg | text-primary, bg-primary |
| Muted backgrounds | bg-muted, text-muted-foreground |
| Borders | border |
| Success status | text-status-success, bg-status-success/10 |
| Error status | text-status-error, bg-status-error/10 |
| Warning status | text-status-warning, bg-status-warning/10 |
| Info status | text-status-info, bg-status-info/10 |
All neutral chrome colors must be zero-chroma: oklch(X 0 0). Only --primary has a hue (deep navy: oklch(0.35 0.05 260)).
No "entities" in user-facing copy
In user-facing UI, use "records", "data", "data types", or "items" instead of "entities". The word "entity" is fine in admin pages, code comments, variable names, and API routes.
Use cn() for conditional classes
Always use the cn() utility from lib/utils.ts for conditional class merging:
import { cn } from "@/lib/utils";
<div className={cn("p-4", isActive && "bg-primary text-primary-foreground")} />Entity types are DB-driven
Never hardcode entity type slugs (like "opportunity" or "company") in platform code (features/ except features/custom/). Entity types are defined in the database. If platform behavior depends on a specific entity type, make it configurable via agent config or tenant settings.
Zod validation on all API routes
Every POST, PUT, and PATCH handler must validate input with a Zod schema at the route boundary:
import { z } from "zod";
import { apiErrorResponse } from "@/lib/api-utils";
const updateSchema = z.object({
title: z.string().min(1),
content: z.record(z.unknown()).optional(),
});
export async function PATCH(request: Request) {
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return apiErrorResponse("Invalid input", 400);
}
// ... use parsed.data
}Component guidelines
200-line limit
Components must stay under 200 lines. If a component approaches that limit, extract sub-components into the same directory:
features/entities/components/
entity-detail/
entity-detail.tsx # Main component (~150 lines)
entity-header.tsx # Extracted header
entity-sidebar.tsx # Extracted sidebar
entity-field-list.tsx # Extracted field listAccessibility
- Every icon-only button must have an
aria-label - Custom interactive elements need
tabIndexandonKeyDownhandlers - Use semantic HTML where possible (even with shadcn/ui wrappers)
Card padding convention
The Card component uses py-4 gap-3 (vertical) + px-4 on children (horizontal) for uniform padding. Block components should not add manual padding overrides -- use Card defaults.
Auth patterns
Always use the auth adapter
Import auth functions from @/features/tenant/auth -- never call Supabase auth directly:
import { getUserId, requireAuth, requireAdmin, hasPermission, requirePermission } from "@/features/tenant/auth";
// API route: require authentication
export async function GET() {
const context = await requireAuth(); // throws 401 if not authenticated
// context.userId, context.tenantId, context.role available
}
// API route: require admin
export async function DELETE() {
const context = await requireAdmin(); // throws 403 if not admin
}
// API route: check specific permission
export async function PATCH() {
const allowed = await hasPermission("entities.team.update");
if (!allowed) return apiErrorResponse("Forbidden", 403);
}Supabase client selection
- Authenticated client (
createClient()) -- for user-scoped reads/writes. RLS applies. - Admin client (
createAdminClient()) -- only for cross-user/system operations: tenant management, user provisioning, background jobs, extraction.
// Correct: authenticated client for user operations
import { createClient } from "@/lib/supabase/server";
const supabase = await createClient();
// Correct: admin client for system operations
import { createAdminClient } from "@/lib/supabase/server";
const supabase = createAdminClient();Tenant scoping
All database operations must be tenant-scoped:
import { getTenantContext } from "@/features/tenant/context";
import { DEFAULT_TENANT_ID } from "@/features/tenant/constants";
const { tenantId } = await getTenantContext();
// Always filter by tenant_id
const { data } = await supabase
.from("entities")
.select("*")
.eq("tenant_id", tenantId);Never hardcode the default tenant UUID. Use DEFAULT_TENANT_ID from features/tenant/constants.ts.
Testing
Co-located tests
Every new file with exported logic must have a co-located .test.ts file:
features/entities/lib/
scoring.ts
scoring.test.ts
features/tools/
registry.ts
registry.test.tsWhat to test
| File type | What to test |
|---|---|
| Pure functions | Input/output, edge cases, error conditions |
| Zod schemas | Valid inputs pass, invalid inputs fail, optional fields |
| Constants/enums | All expected values present, cross-referencing |
| Server actions | Mock Supabase, verify query construction, error handling |
| Custom tools | Schema validation + execute function (use captureTool() pattern) |
| Bridge/transform functions | Input shape to output shape, filtering logic, empty inputs |
Running tests
pnpm test # Run Vitest unit + integration tests
pnpm typecheck # TypeScript type checking
pnpm e2e # Playwright browser tests
pnpm lint # ESLint
pnpm build # Full production buildTest patterns
import { describe, it, expect, vi } from "vitest";
describe("myFunction", () => {
it("should handle normal input", () => {
expect(myFunction("hello")).toBe("HELLO");
});
it("should handle edge cases", () => {
expect(myFunction("")).toBe("");
expect(myFunction(null as unknown as string)).toBeNull();
});
});Use as unknown as Type instead of as any when mocking in tests.
Regression assets
Every escaped bug must leave behind a regression asset at the appropriate layer:
- Unit test -- for logic bugs in pure functions
- Integration test -- for bugs in server actions or API routes
- E2E test -- for bugs in user flows
- Visual test -- for layout or rendering bugs
- Failure-mode test -- for error handling bugs
File organization
Feature modules
Each feature module is self-contained with a consistent internal structure:
features/entities/
types.ts # Types, Zod schemas, constants
server/
actions.ts # Server actions (DB operations)
components/
entity-list.tsx # UI components
entity-detail.tsx
lib/
scoring.ts # Pure functions
scoring.test.ts
hooks/
use-entities.ts # Client-side hooks
extraction/
types.ts # Extraction-specific types
extract-field.ts # Core extraction logic
run-extraction.ts # OrchestrationApp routes
The app/ directory contains thin route handlers. Business logic belongs in feature modules:
// app/(app)/opportunity/page.tsx -- THIN
import { EntityListPage } from "@/features/entities/components/entity-list-page";
export default function OpportunityPage() {
return <EntityListPage />;
}Import boundaries
- Platform code (
features/exceptfeatures/custom/) must never import fromfeatures/custom/ - Custom code (
features/custom/) can import from platform features freely - App routes import from features, never the reverse
- Components/ui are pure primitives with no feature imports
Shared utilities
Use canonical shared utilities instead of duplicating logic:
| Utility | Location | Purpose |
|---|---|---|
cn() | lib/utils.ts | Conditional class merging |
slugify() | lib/utils.ts | String to URL-safe slug |
humanize() | lib/utils.ts | Slug to human-readable text |
apiErrorResponse() | lib/api-utils.ts | Consistent API error responses |
CHART_COLORS | lib/chart-colors.ts | Shared chart color palette |
getUserId() | features/tenant/auth.ts | Current user ID (zero network calls) |
requireAuth() | features/tenant/auth.ts | Auth check + tenant context |
getActiveTenantId() | features/tenant/context.ts | Active tenant ID |
DEFAULT_TENANT_ID | features/tenant/constants.ts | Default tenant UUID |
ROLE_IDS | features/tenant/roles.ts | Role UUID constants (including guest) |
parseAgentConfig() | features/agents/types.ts | Safe parse of agent config JSONB |
TOOL_GROUPS | features/agents/types.ts | Tool group constants |
formatDisplayValue() | features/tools/lib/format.ts | Format values for display |
formatFileSize() | features/documents/lib/format.ts | Human-readable file sizes |
recordAnalyticsEvent() | features/analytics/record.ts | Fire-and-forget event tracking |
EXTRACTION_SOURCES | features/entities/types.ts | Extraction source constants |
Quality rules summary
These are non-negotiable across the codebase:
- No explicit
any-- useunknown, generics, or proper types. In tests, useas unknown as Type. - No hardcoded colors -- CSS variables only. Zero-chroma neutrals (
oklch(X 0 0)), navy primary. - Components under 200 lines -- extract sub-components when approaching the limit.
- Co-located tests -- every file with exported logic gets a
.test.tsfile. - Zod at the boundary -- every API route validates input with Zod.
- Auth via adapter -- never import Supabase directly for auth checks.
- Tenant scoping -- every DB query filtered by
tenant_id. - Entity writes log activity -- create an activity record on create/update/delete.
- No "entities" in frontend copy -- use "records", "data", "data types", "items".
- Entity types are DB-driven -- never hardcode slugs in platform code.
- AI SDK v6 only --
streamText,convertToModelMessages,DefaultChatTransport. - Run
pnpm db:typesafter migrations -- keep TypeScript types in sync with the schema.
Pre-push checklist
Before pushing any change:
pnpm lint # Must pass
pnpm typecheck # Must pass
pnpm test # Must pass
pnpm build # Must passFor UI changes, visual verification is also required at desktop (1280x720) and mobile (375x667) viewports. No force-pushing allowed.
Adding a custom tool
The most common extension point. Four steps:
- Create the definition at
features/custom/tools/[name]/definition.ts:
import { registerTool } from "@/features/tools/registry";
import { z } from "zod";
const inputSchema = z.object({
revenue: z.number().describe("Annual revenue in dollars"),
costs: z.number().describe("Implementation costs"),
});
registerTool({
slug: "my-calculator",
name: "My Calculator",
description: "Calculates something useful",
category: "analysis",
inputSchema,
execute: async (input) => {
const roi = (input.revenue - input.costs) / input.costs;
return { roi, paybackMonths: Math.ceil(12 / roi) };
},
});-
Create tests at
features/custom/tools/[name]/definition.test.tsusingmockToolRegistry()andcaptureTool(). -
Register the import in
features/custom/tools/index.ts. -
(Optional) Add custom UI -- create
input-form.tsxand/oroutput-display.tsx, register viaregisterToolUI()infeatures/custom/tools/ui.ts. Without custom UI, the generic form and output renderers handle everything automatically from the Zod schema.
The tool automatically appears in the /tools library, gets its own page at /tools/[slug], and is available to all agents in chat.