From 8f8f09008afc40b40a226d3173e35e74c9cd41ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 19 May 2026 14:10:39 +0400 Subject: [PATCH] Version Cursor rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track .cursor/rules/ in git so coding conventions are shared across the team. Everything else under .cursor/ stays ignored. Signed-off-by: Émile Ré --- .cursor/rules/config-propagation.mdc | 23 +++++ .cursor/rules/go-coredata-load-naming.mdc | 83 +++++++++++++++++++ .cursor/rules/go-declarations.mdc | 59 +++++++++++++ .cursor/rules/go-delete-no-rows-check.mdc | 32 +++++++ .cursor/rules/go-error-handling.mdc | 46 ++++++++++ .cursor/rules/go-imports.mdc | 41 +++++++++ .cursor/rules/go-logging.mdc | 33 ++++++++ .cursor/rules/go-multiline-params.mdc | 77 +++++++++++++++++ .cursor/rules/go-naming-conventions.mdc | 58 +++++++++++++ .cursor/rules/go-pg-constraint-check.mdc | 40 +++++++++ .cursor/rules/go-upsert-returning-id.mdc | 52 ++++++++++++ .cursor/rules/go-url-construction.mdc | 38 +++++++++ .cursor/rules/no-outlet-context-data.mdc | 33 ++++++++ .../rules/relay-fragments-not-data-props.mdc | 41 +++++++++ .cursor/rules/template-files.mdc | 29 +++++++ .gitignore | 2 + 16 files changed, 687 insertions(+) create mode 100644 .cursor/rules/config-propagation.mdc create mode 100644 .cursor/rules/go-coredata-load-naming.mdc create mode 100644 .cursor/rules/go-declarations.mdc create mode 100644 .cursor/rules/go-delete-no-rows-check.mdc create mode 100644 .cursor/rules/go-error-handling.mdc create mode 100644 .cursor/rules/go-imports.mdc create mode 100644 .cursor/rules/go-logging.mdc create mode 100644 .cursor/rules/go-multiline-params.mdc create mode 100644 .cursor/rules/go-naming-conventions.mdc create mode 100644 .cursor/rules/go-pg-constraint-check.mdc create mode 100644 .cursor/rules/go-upsert-returning-id.mdc create mode 100644 .cursor/rules/go-url-construction.mdc create mode 100644 .cursor/rules/no-outlet-context-data.mdc create mode 100644 .cursor/rules/relay-fragments-not-data-props.mdc create mode 100644 .cursor/rules/template-files.mdc diff --git a/.cursor/rules/config-propagation.mdc b/.cursor/rules/config-propagation.mdc new file mode 100644 index 000000000..a61499aeb --- /dev/null +++ b/.cursor/rules/config-propagation.mdc @@ -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. diff --git a/.cursor/rules/go-coredata-load-naming.mdc b/.cursor/rules/go-coredata-load-naming.mdc new file mode 100644 index 000000000..a20241da0 --- /dev/null +++ b/.cursor/rules/go-coredata-load-naming.mdc @@ -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 +``` diff --git a/.cursor/rules/go-declarations.mdc b/.cursor/rules/go-declarations.mdc new file mode 100644 index 000000000..601cd68a9 --- /dev/null +++ b/.cursor/rules/go-declarations.mdc @@ -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 +) +``` diff --git a/.cursor/rules/go-delete-no-rows-check.mdc b/.cursor/rules/go-delete-no-rows-check.mdc new file mode 100644 index 000000000..6ac17a99d --- /dev/null +++ b/.cursor/rules/go-delete-no-rows-check.mdc @@ -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`. diff --git a/.cursor/rules/go-error-handling.mdc b/.cursor/rules/go-error-handling.mdc new file mode 100644 index 000000000..8ba66b382 --- /dev/null +++ b/.cursor/rules/go-error-handling.mdc @@ -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) { ... } +``` diff --git a/.cursor/rules/go-imports.mdc b/.cursor/rules/go-imports.mdc new file mode 100644 index 000000000..489593325 --- /dev/null +++ b/.cursor/rules/go-imports.mdc @@ -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" +) +``` diff --git a/.cursor/rules/go-logging.mdc b/.cursor/rules/go-logging.mdc new file mode 100644 index 000000000..fdf25ef3e --- /dev/null +++ b/.cursor/rules/go-logging.mdc @@ -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) +``` diff --git a/.cursor/rules/go-multiline-params.mdc b/.cursor/rules/go-multiline-params.mdc new file mode 100644 index 000000000..32045acf4 --- /dev/null +++ b/.cursor/rules/go-multiline-params.mdc @@ -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 `(`. diff --git a/.cursor/rules/go-naming-conventions.mdc b/.cursor/rules/go-naming-conventions.mdc new file mode 100644 index 000000000..b9e6cbb6d --- /dev/null +++ b/.cursor/rules/go-naming-conventions.mdc @@ -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`). diff --git a/.cursor/rules/go-pg-constraint-check.mdc b/.cursor/rules/go-pg-constraint-check.mdc new file mode 100644 index 000000000..5fb593f6d --- /dev/null +++ b/.cursor/rules/go-pg-constraint-check.mdc @@ -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` diff --git a/.cursor/rules/go-upsert-returning-id.mdc b/.cursor/rules/go-upsert-returning-id.mdc new file mode 100644 index 000000000..0f639059c --- /dev/null +++ b/.cursor/rules/go-upsert-returning-id.mdc @@ -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 +``` diff --git a/.cursor/rules/go-url-construction.mdc b/.cursor/rules/go-url-construction.mdc new file mode 100644 index 000000000..cfd97ad5a --- /dev/null +++ b/.cursor/rules/go-url-construction.mdc @@ -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() +``` diff --git a/.cursor/rules/no-outlet-context-data.mdc b/.cursor/rules/no-outlet-context-data.mdc new file mode 100644 index 000000000..3c6106aef --- /dev/null +++ b/.cursor/rules/no-outlet-context-data.mdc @@ -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 + + +// 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 } + } + } +`; +``` diff --git a/.cursor/rules/relay-fragments-not-data-props.mdc b/.cursor/rules/relay-fragments-not-data-props.mdc new file mode 100644 index 000000000..43fa5566f --- /dev/null +++ b/.cursor/rules/relay-fragments-not-data-props.mdc @@ -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 + + +// 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: + +``` + +Callback props (`onSave`, `onCancel`) and configuration props (`isUpdating`, +`variant`) are fine — only **domain data** must come from fragments. diff --git a/.cursor/rules/template-files.mdc b/.cursor/rules/template-files.mdc new file mode 100644 index 000000000..6e94a8646 --- /dev/null +++ b/.cursor/rules/template-files.mdc @@ -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 `..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). diff --git a/.gitignore b/.gitignore index e2eb715c2..2981171e1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ bin/ node_modules/ .turbo .vscode +.cursor/* +!.cursor/rules/ dist/ .react-email/ sbom.json