Version Cursor rules

Track .cursor/rules/ in git so coding conventions are shared
across the team. Everything else under .cursor/ stays ignored.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-19 14:10:39 +04:00
parent 58d3ba3823
commit 8f8f09008a
16 changed files with 687 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
---
description: Configuration propagation — update all config consumers when changing probod config
globs: pkg/probod/*.go,pkg/bootstrap/builder.go,cfg/dev.yaml,e2e/console/testdata/config.yaml,contrib/lima/provision.sh,contrib/helm/charts/probo/values.yaml,contrib/helm/charts/probo/values-production.yaml.example,contrib/helm/charts/probo/templates/deployment.yaml,contrib/helm/charts/probo/templates/secret.yaml
alwaysApply: false
---
# Config Propagation
When adding, renaming, or removing a config field, update **all** of these:
1. `pkg/probod/*.go` — struct definition (source of truth)
2. `pkg/probod/probod.go` `New()` — default value
3. `pkg/bootstrap/builder.go` — env-var mapping + validation
4. `pkg/bootstrap/builder_test.go` — test coverage for new env var
5. `cfg/dev.yaml` — local dev config
6. `e2e/console/testdata/config.yaml` — e2e test config
7. `contrib/lima/provision.sh` — sandbox env vars (only if default is wrong for sandbox)
8. `contrib/helm/charts/probo/values.yaml` — Helm defaults
9. `contrib/helm/charts/probo/values-production.yaml.example` — production template
10. `contrib/helm/charts/probo/templates/deployment.yaml` — values → env vars
11. `contrib/helm/charts/probo/templates/secret.yaml` — sensitive values via secretKeyRef
See `contrib/claude/config.md` for full details and conventions.

View File

@@ -0,0 +1,83 @@
---
description: Coredata Load vs LoadAll naming and no cross-entity JOINs
globs: "pkg/coredata/**/*.go"
alwaysApply: false
---
# Coredata Load vs LoadAll naming
The method name signals whether the result set is bounded:
- **`LoadBy*` with a `cursor` param** — paginated list; the cursor provides limit and ordering.
- **`Load` / `LoadBy*` with a `limit int` param** — filtered list with explicit limit, when cursor pagination is not needed but the caller controls the result count.
- **`LoadAllBy*`** — returns all matching rows, no limit or cursor.
- **`LoadAll`** — same as `LoadAllBy*` but without a parent key; returns all rows matching a filter.
`LoadAll*` methods must **never** accept a cursor or limit parameter — `All` means the entire matching set is returned. The codebase has some legacy `LoadAllBy*` methods that accept a cursor; do not follow that pattern — new code must use `LoadBy*` for paginated queries.
```go
// GOOD — explicit limit, named Load
func (ds *Things) Load(
ctx context.Context,
conn pg.Querier,
limit int,
filter *ThingFilter,
) error {
// GOOD — no limit, named LoadAll
func (ds *Things) LoadAll(
ctx context.Context,
conn pg.Querier,
filter *ThingFilter,
) error {
// BAD — LoadAll with a limit
func (ds *Things) LoadAll(
ctx context.Context,
conn pg.Querier,
limit int,
filter *ThingFilter,
) error {
// BAD — LoadAllBy with a cursor (legacy pattern, do not use)
func (ds *Things) LoadAllByParentID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
parentID gid.GID,
cursor *page.Cursor[ThingOrderField],
) error {
// GOOD — paginated query uses LoadBy, not LoadAll
func (ds *Things) LoadByParentID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
parentID gid.GID,
cursor *page.Cursor[ThingOrderField],
) error {
```
# No cross-entity JOINs
Each entity file in `pkg/coredata` queries only its own table. When data from multiple entities is needed, the caller orchestrates separate calls.
- Never JOIN two entity tables inside an entity method.
- Never return a raw ID belonging to a different entity — return the full entity and let the caller read the foreign key field.
```go
// BAD — cross-entity JOIN inside DetectedTrackers
q := `
SELECT ctpd.common_third_party_id
FROM detected_trackers dt
JOIN common_third_party_domains ctpd ON ctpd.domain = dt.initiator_domain
WHERE dt.tracker_pattern_id = @tracker_pattern_id
`
// GOOD — caller orchestrates two entity calls
domains, err := trackers.LoadInitiatorDomainsByTrackerPatternID(ctx, conn, patternID, 10)
filter := coredata.NewCommonThirdPartyDomainFilter(domains)
var matched coredata.CommonThirdPartyDomains
err = matched.Load(ctx, conn, 1, filter)
thirdPartyID := matched[0].CommonThirdPartyID
```

View File

@@ -0,0 +1,59 @@
---
description: Go declarations — group related types/consts/vars, no iota for string enums
globs: "**/*.go"
alwaysApply: false
---
# Go grouped declarations
Group related declarations using `type ()`, `const ()`, and `var ()` blocks.
```go
// GOOD — grouped type block
type (
CreateFooRequest struct {
Name string
Active bool
}
UpdateFooRequest struct {
ID gid.GID
Name *string
Active *bool
}
)
// GOOD — grouped const block
const (
NameMaxLength = 100
ContentMaxLength = 5000
)
// GOOD — interface satisfaction checks
var (
_ Reader = (*FileReader)(nil)
_ Writer = (*FileWriter)(nil)
)
```
## String enums
Use explicit typed string values, **not** `iota`:
```go
// GOOD
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
)
// BAD — iota for string-like enums
type Status int
const (
StatusActive Status = iota
StatusInactive
)
```

View File

@@ -0,0 +1,32 @@
---
description: Delete methods must not check RowsAffected — deletes are idempotent
globs: "pkg/coredata/**/*.go"
alwaysApply: false
---
# Delete must not check RowsAffected
In `Delete` methods, do **not** check `result.RowsAffected() == 0`. A DELETE that affects zero rows is not an error — the resource may have already been deleted. Deletes are idempotent.
Discard the result with `_`:
```go
// GOOD — Delete ignores RowsAffected
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete foo: %w", err)
}
return nil
// BAD — Delete checks RowsAffected
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete foo: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
```
This rule applies only to `Delete`. `Update` methods **must** still check `RowsAffected() == 0` and return `ErrResourceNotFound`.

View File

@@ -0,0 +1,46 @@
---
description: Go error handling — wrap with fmt.Errorf, always name err, use errors.AsType
globs: "**/*.go"
alwaysApply: false
---
# Go error handling
## Wrapping
- Error variables are always named `err`
- When a function returns errors from multiple call sites, each must be wrapped
- Wrap with `fmt.Errorf("cannot ...: %w", err)` — lowercase, starts with `cannot`
- Each wrap message in a function must be distinct
```go
// GOOD
foo, err := s.loadFoo(ctx)
if err != nil {
return fmt.Errorf("cannot load foo: %w", err)
}
bar, err := s.loadBar(ctx, foo.ID)
if err != nil {
return fmt.Errorf("cannot load bar: %w", err)
}
// BAD — bare return without wrapping
foo, err := s.loadFoo(ctx)
if err != nil {
return err
}
```
## Type assertions on errors
Use `errors.AsType[T](err)` (generic form), not `errors.As(err, &ptr)`:
```go
// GOOD
if e, ok := errors.AsType[*ValidationError](err); ok { ... }
// BAD
var ve *ValidationError
if errors.As(err, &ve) { ... }
```

View File

@@ -0,0 +1,41 @@
---
description: Go import ordering — stdlib first, then third-party and internal together
globs: "**/*.go"
alwaysApply: false
---
# Go import ordering
Two groups separated by a blank line:
1. Standard library
2. Everything else (third-party and internal sorted together alphabetically)
```go
// GOOD
import (
"context"
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/iam"
)
// BAD — three groups (stdlib / third-party / internal separated)
import (
"context"
"fmt"
"github.com/go-chi/chi/v5"
"go.probo.inc/probo/pkg/iam"
)
// BAD — no separation at all
import (
"context"
"github.com/go-chi/chi/v5"
"go.probo.inc/probo/pkg/iam"
)
```

View File

@@ -0,0 +1,33 @@
---
description: Go logging — structured, PII-free, use go.gearno.de/kit/log
globs: "**/*.go"
alwaysApply: false
---
# Go logging
Use `go.gearno.de/kit/log` — named, context-aware structured logging with typed fields.
## Rules
- **Never log PII, PHI, or sensitive data** (emails, names, passwords, tokens, health records)
- Log opaque identifiers instead (IDs, request IDs)
- Use typed field helpers: `log.String`, `log.Int`, `log.Error`, etc.
- Use `*Ctx` variants for context-aware logging
```go
// GOOD
l.InfoCtx(
ctx,
"HTTP request to trust center custom domain, redirecting to HTTPS",
log.String("domain", domain),
log.String("path", r.URL.Path),
log.String("request_id", reqID),
)
// BAD — logs PII
l.InfoCtx(ctx, "user login", log.String("email", user.Email))
// BAD — unstructured
log.Printf("processing request for %s", userID)
```

View File

@@ -0,0 +1,77 @@
---
description: Enforce single-line-or-multiline formatting for Go parameter and argument lists
globs: "**/*.go"
alwaysApply: false
---
# Go multiline parameter and argument lists
Parameter/argument lists are either **all on one line** or **each on its own line** — never mixed.
## Function/method definitions
```go
// GOOD — fits on one line
func (s *Service) GetFoo(ctx context.Context, id gid.GID) (*Foo, error) {
// GOOD — multiline: one param per line, closing ) on its own line
func (s *Service) CreateFoo(
ctx context.Context,
tenantID gid.TenantID,
req CreateFooRequest,
) (*Foo, error) {
// BAD — mixed
func (s *Service) CreateFoo(ctx context.Context, tenantID gid.TenantID,
req CreateFooRequest) (*Foo, error) {
// BAD — closing ) stuck on last param line
func (s *Service) CreateFoo(
ctx context.Context,
req CreateFooRequest) (*Foo, error) {
```
## Call expressions
```go
// GOOD — one line
id := gid.New(tenantID, "Foo")
// GOOD — multiline: one arg per line, trailing comma, closing ) alone
svc, err := foo.NewService(
ctx,
db,
logger,
)
// BAD — some args on the call line, rest below
svc, err := foo.NewService(ctx, db,
logger,
)
// BAD — first args on the callee line with multiline composite literal
svc, err := foo.NewService(ctx, db, foo.Config{
MaxRetry: 3,
})
// BAD — single multiline argument starts on the opening ( line
body, err := json.Marshal(firecrawlRequest{
Query: query,
Limit: maxResults,
})
// GOOD — single multiline argument: break after (, trailing comma, ) alone
body, err := json.Marshal(
firecrawlRequest{
Query: query,
Limit: maxResults,
},
)
```
## Quick checklist when writing or reviewing Go code
1. Does the signature/call fit on one line? → keep it on one line.
2. Doesn't fit? → break after `(`, one item per line, `)` on its own line.
3. Never place some items on the opening line and others below.
4. Even a single argument that spans multiple lines must break after `(`.

View File

@@ -0,0 +1,58 @@
---
description: Go naming and conventions — constructors, configs, receivers, context, interfaces
globs: "**/*.go"
alwaysApply: false
---
# Go naming and conventions
## Naming patterns
- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`)
- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`)
- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`)
- Unexported internal types: lowercase (e.g. `thirdPartyInfo`, `ctxKey`)
## Receiver names
Short, usually single-letter matching the type:
- `s` for Service, `c` for Client, `p` for Provider, `w` for Worker
## Context
Always the first parameter. Use private struct keys for context values:
```go
type ctxKey struct{ name string }
var trustCenterIDKey = &ctxKey{name: "trust_center_id"}
```
## Interfaces
- Define in the consumer package, not the provider
- Keep them small
- Verify satisfaction at compile time:
```go
var (
_ unit.Configurable = (*Implm)(nil)
_ unit.Runnable = (*Implm)(nil)
)
```
## Functional options
Use `Config` structs for required params. Use `With*` functions for optional config:
```go
type Option func(*Bridge)
func WithDryRun(dryRun bool) Option {
return func(s *Bridge) { s.dryRun = dryRun }
}
```
## Pointers
Go 1.26: use `new(expr)` for pointer-to-value (e.g. `new(1)`, `new("foo")`, `new(time.Now())`).
Use `go.gearno.de/x/ref` only for dereference helpers (`ref.UnrefOrZero`).

View File

@@ -0,0 +1,40 @@
---
description: PostgreSQL constraint error handling — always check both error code and constraint name
globs: "pkg/coredata/**/*.go"
alwaysApply: false
---
# PostgreSQL constraint error mapping
When mapping `*pgconn.PgError` to sentinel errors, always check **both** `pgErr.Code` and `pgErr.ConstraintName`. A table may have multiple unique constraints; a code-only check silently maps unrelated violations to the wrong sentinel.
```go
// GOOD — checks both code and constraint name
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_framework_ref_unique" {
return ErrResourceAlreadyExists
}
}
// GOOD — multiple constraints on the same table
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" {
switch pgErr.ConstraintName {
case "document_versions_document_id_major_minor_key",
"document_one_active_version_idx":
return ErrResourceAlreadyExists
}
}
}
// BAD — code-only check
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" {
return ErrResourceAlreadyExists
}
}
```
This applies to all PostgreSQL error codes mapped to sentinel errors:
- `"23505"` (unique violation) → `ErrResourceAlreadyExists`
- `"23503"` (foreign key violation) → `ErrResourceInUse`

View File

@@ -0,0 +1,52 @@
---
description: Upsert must RETURNING full row into pointer receiver, not use xmax
globs: "pkg/coredata/**/*.go"
alwaysApply: false
---
# Upsert: RETURNING full row into receiver
Upsert methods use a **pointer receiver** and `RETURNING` all struct columns to sync the receiver with the actual DB state. Save the original ID before the query; compare it with the returned ID to detect insert vs update.
Do **not** use `RETURNING (xmax = 0) AS inserted` — `xmax` is a PostgreSQL internal system column and is fragile.
```go
// GOOD — RETURNING full row, sync receiver
func (t *Thing) Upsert(ctx context.Context, conn pg.Tx) (inserted bool, err error) {
q := `
INSERT INTO things (id, name, created_at, updated_at)
VALUES (@id, @name, @created_at, @updated_at)
ON CONFLICT (name) DO UPDATE
SET
name = EXCLUDED.name,
updated_at = EXCLUDED.updated_at
RETURNING
id,
name,
created_at,
updated_at
`
originalID := t.ID
// ...args...
rows, err := conn.Query(ctx, q, args)
if err != nil {
return false, fmt.Errorf("cannot upsert thing: %w", err)
}
defer rows.Close()
row, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Thing])
if err != nil {
return false, fmt.Errorf("cannot collect upsert result: %w", err)
}
*t = row
return originalID == t.ID, nil
}
// BAD — xmax trick
RETURNING (xmax = 0) AS inserted
// BAD — RETURNING only id without syncing receiver
RETURNING id
```

View File

@@ -0,0 +1,38 @@
---
description: Go URL construction — never use fmt.Sprintf or concatenation for URLs
globs: "**/*.go"
alwaysApply: false
---
# Go URL and query parameter construction
**Never** build URLs with `fmt.Sprintf`, string concatenation, or any string formatting.
Use `net/url` package or `pkg/baseurl.URLBuilder`.
```go
// BAD
endpoint := fmt.Sprintf("https://api.example.com/users/%s?active=%t", userID, active)
endpoint := "https://api.example.com/orgs/" + orgID + "/members"
raw := baseEndpoint + "?domain=" + domain + "&limit=100"
// GOOD — url.JoinPath + url.Values
u, err := url.JoinPath("https://api.example.com", "users", userID)
if err != nil {
return fmt.Errorf("cannot build URL: %w", err)
}
parsed, err := url.Parse(u)
if err != nil {
return fmt.Errorf("cannot parse URL: %w", err)
}
q := parsed.Query()
q.Set("active", strconv.FormatBool(active))
parsed.RawQuery = q.Encode()
// GOOD — URLBuilder from pkg/baseurl
u, err := baseURL.URL("/users", userID).
Query("active", strconv.FormatBool(active)).
Build()
```

View File

@@ -0,0 +1,33 @@
---
description: Never pass domain data through React Router Outlet context — use page loaders with their own queries
globs: "**/*.tsx"
alwaysApply: false
---
# No domain data via Outlet context
Child routes under a layout MUST NOT receive domain data through `useOutletContext`.
Each page that needs GraphQL data must follow the Loader + Page pattern with its
own query, just like sibling pages.
```tsx
// BAD — layout passes data through Outlet context
<Outlet context={{ showBranding: banner.showBranding }} />
// child page
const { showBranding } = useOutletContext<{ showBranding: boolean }>();
// GOOD — child page has its own Loader + Query
// SnippetPageLoader.tsx:
const [queryRef, loadQuery] = useQueryLoader(snippetPageQuery);
useEffect(() => { loadQuery({ cookieBannerId }); }, [loadQuery, cookieBannerId]);
// SnippetPage.tsx:
export const snippetPageQuery = graphql`
query SnippetPageQuery($cookieBannerId: ID!) {
node(id: $cookieBannerId) {
... on CookieBanner { ...ThemePreview_cookieBanner }
}
}
`;
```

View File

@@ -0,0 +1,41 @@
---
description: Enforce Relay fragments instead of passing fetched data as props
globs: "**/*.tsx"
alwaysApply: false
---
# Never pass fetched data as props — use Relay fragments
When a child component needs data from a GraphQL node, it MUST define its own
colocated fragment and receive a **fragment key** (`SomeFragment$key`), never
a plain object or individual fields extracted from the parent's query/fragment.
This applies even when the child only uses the data to seed local state (e.g.
an edit form that copies fields into `useState`).
```tsx
// BAD — parent extracts fields and passes a plain object
<EditCookieRow
cookie={{ name: cookie.name, duration: cookie.duration }}
onSave={handleSave}
/>
// GOOD — child owns its fragment, parent spreads it and passes the key
// In EditCookieRow.tsx:
export const editCookieRowFragment = graphql`
fragment EditCookieRowFragment on Cookie { name duration description }
`;
interface EditCookieRowProps {
cookieKey: EditCookieRowFragment$key;
onSave: (cookie: CookieEntry) => void;
}
// In parent fragment:
// ...EditCookieRowFragment (spread on the Cookie node)
// In parent JSX:
<EditCookieRow cookieKey={cookie} onSave={handleSave} />
```
Callback props (`onSave`, `onCancel`) and configuration props (`isUpdating`,
`variant`) are fine — only **domain data** must come from fragments.

View File

@@ -0,0 +1,29 @@
---
description: Template file naming and conventions
globs: "**/*.tmpl"
alwaysApply: false
---
# Template files
See full guide: `contrib/claude/file-naming.md`
## Naming convention
Template files use the extension pattern `<name>.<output-ext>.tmpl`:
```
pkg/trust/sitemap.xml.tmpl
pkg/cookiebanner/prompts/tracker_identification.txt.tmpl
pkg/probo/templates/risk_list.json.tmpl
```
## Dynamic values over hardcoded lists
Never hardcode enum values or source-of-truth lists in template text. Use a placeholder and substitute at runtime:
```
Use one of: {{.Categories}}.
```
Use `strings.Replace` (single placeholder) or `text/template` (multiple).

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@ bin/
node_modules/
.turbo
.vscode
.cursor/*
!.cursor/rules/
dist/
.react-email/
sbom.json