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:
23
.cursor/rules/config-propagation.mdc
Normal file
23
.cursor/rules/config-propagation.mdc
Normal 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.
|
||||
83
.cursor/rules/go-coredata-load-naming.mdc
Normal file
83
.cursor/rules/go-coredata-load-naming.mdc
Normal 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
|
||||
```
|
||||
59
.cursor/rules/go-declarations.mdc
Normal file
59
.cursor/rules/go-declarations.mdc
Normal 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
|
||||
)
|
||||
```
|
||||
32
.cursor/rules/go-delete-no-rows-check.mdc
Normal file
32
.cursor/rules/go-delete-no-rows-check.mdc
Normal 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`.
|
||||
46
.cursor/rules/go-error-handling.mdc
Normal file
46
.cursor/rules/go-error-handling.mdc
Normal 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) { ... }
|
||||
```
|
||||
41
.cursor/rules/go-imports.mdc
Normal file
41
.cursor/rules/go-imports.mdc
Normal 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"
|
||||
)
|
||||
```
|
||||
33
.cursor/rules/go-logging.mdc
Normal file
33
.cursor/rules/go-logging.mdc
Normal 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)
|
||||
```
|
||||
77
.cursor/rules/go-multiline-params.mdc
Normal file
77
.cursor/rules/go-multiline-params.mdc
Normal 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 `(`.
|
||||
58
.cursor/rules/go-naming-conventions.mdc
Normal file
58
.cursor/rules/go-naming-conventions.mdc
Normal 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`).
|
||||
40
.cursor/rules/go-pg-constraint-check.mdc
Normal file
40
.cursor/rules/go-pg-constraint-check.mdc
Normal 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`
|
||||
52
.cursor/rules/go-upsert-returning-id.mdc
Normal file
52
.cursor/rules/go-upsert-returning-id.mdc
Normal 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
|
||||
```
|
||||
38
.cursor/rules/go-url-construction.mdc
Normal file
38
.cursor/rules/go-url-construction.mdc
Normal 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()
|
||||
```
|
||||
33
.cursor/rules/no-outlet-context-data.mdc
Normal file
33
.cursor/rules/no-outlet-context-data.mdc
Normal 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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
```
|
||||
41
.cursor/rules/relay-fragments-not-data-props.mdc
Normal file
41
.cursor/rules/relay-fragments-not-data-props.mdc
Normal 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.
|
||||
29
.cursor/rules/template-files.mdc
Normal file
29
.cursor/rules/template-files.mdc
Normal 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
2
.gitignore
vendored
@@ -2,6 +2,8 @@ bin/
|
||||
node_modules/
|
||||
.turbo
|
||||
.vscode
|
||||
.cursor/*
|
||||
!.cursor/rules/
|
||||
dist/
|
||||
.react-email/
|
||||
sbom.json
|
||||
|
||||
Reference in New Issue
Block a user