@openora/create 0.4.1-canary.31 → 0.4.1-canary.310
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/generated/core-version.d.ts +1 -1
- package/dist/generated/core-version.d.ts.map +1 -1
- package/dist/generated/core-version.js +1 -1
- package/dist/generated/core-version.js.map +1 -1
- package/dist/index.js +47 -92
- package/dist/index.js.map +1 -1
- package/dist/render.d.ts +15 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +125 -0
- package/dist/render.js.map +1 -0
- package/package.json +7 -5
- package/template/README.md.tpl +12 -7
- package/template/__dot__claude/settings.json.tpl +22 -12
- package/template/__dot__env.example +10 -0
- package/template/__dot__gitignore +6 -1
- package/template/__dot__rulesync/commands/adr.md +32 -0
- package/template/__dot__rulesync/commands/check.md +11 -10
- package/template/__dot__rulesync/commands/doctor.md.tpl +17 -0
- package/template/__dot__rulesync/commands/scaffold-module.md +3 -2
- package/template/__dot__rulesync/commands/scaffold-plugin.md +19 -3
- package/template/__dot__rulesync/commands/scaffold-route.md +1 -0
- package/template/__dot__rulesync/commands/start.md +2 -2
- package/template/__dot__rulesync/hooks/_shared.mjs +5 -0
- package/template/__dot__rulesync/hooks/guard-core.mjs.tpl +75 -26
- package/template/__dot__rulesync/hooks/guard-generated.mjs +12 -5
- package/template/__dot__rulesync/hooks/guard-mr.mjs +81 -0
- package/template/__dot__rulesync/hooks/guard-subagent.mjs +22 -6
- package/template/__dot__rulesync/hooks/post-edit.mjs.tpl +106 -0
- package/template/__dot__rulesync/hooks.json +27 -4
- package/template/__dot__rulesync/rules/conventions.md +114 -0
- package/template/__dot__rulesync/rules/db-conventions.md +6 -72
- package/template/__dot__rulesync/rules/e2e-conventions.md +52 -0
- package/template/__dot__rulesync/rules/frontend-conventions.md.tpl +35 -0
- package/template/__dot__rulesync/rules/oss-boundaries.md.tpl +78 -0
- package/template/__dot__rulesync/rules/overview.md +18 -73
- package/template/__dot__rulesync/skills/create-plugin/{SKILL.md → SKILL.md.tpl} +13 -24
- package/template/__dot__rulesync/skills/create-pr/SKILL.md.tpl +38 -0
- package/template/__dot__rulesync/skills/create-task/{SKILL.md → SKILL.md.tpl} +14 -22
- package/template/__dot__rulesync/skills/create-ui-module/SKILL.md.tpl +60 -0
- package/template/__dot__rulesync/skills/enhance-prompt/{SKILL.md → SKILL.md.tpl} +4 -4
- package/template/__dot__rulesync/skills/handoff/SKILL.md +95 -0
- package/template/__dot__rulesync/skills/review/SKILL.md.tpl +277 -0
- package/template/__dot__rulesync/skills/ship/SKILL.md.tpl +149 -0
- package/template/__dot__rulesync/skills/ship/handoff.md.tpl +48 -0
- package/template/__dot__rulesync/skills/ship/watch-pr.mjs +168 -0
- package/template/__dot__rulesync/subagents/builder.md.tpl +115 -0
- package/template/__dot__rulesync/subagents/compliance-reviewer.md +78 -0
- package/template/__dot__rulesync/subagents/debugger.md.tpl +86 -0
- package/template/__dot__rulesync/subagents/deployer.md.tpl +54 -0
- package/template/__dot__rulesync/subagents/expert.md +28 -34
- package/template/__dot__rulesync/subagents/planner.md.tpl +39 -0
- package/template/__dot__rulesync/subagents/quality-reviewer.md.tpl +112 -0
- package/template/__dot__rulesync/subagents/security-reviewer.md.tpl +85 -0
- package/template/__dot__rulesync/subagents/tester.md.tpl +88 -0
- package/template/__dot__rulesync/sync.json.tpl +26 -0
- package/template/apps/api/package.json.tpl +1 -2
- package/template/apps/api/src/extensions.config.ts.tpl +12 -0
- package/template/apps/api/src/main.ts.tpl +1 -4
- package/template/apps/api/src/seed.ts.tpl +8 -1
- package/template/docker-compose.yml +13 -0
- package/template/docs/agents/cross-repo.md.tpl +37 -0
- package/template/docs/agents/forge.md.tpl +54 -0
- package/template/docs/agents/issue-tracker.md.tpl +42 -0
- package/template/docs/standards/comments.md +16 -0
- package/template/docs/standards/database.md +77 -0
- package/template/docs/standards/enforcement.md +12 -0
- package/template/docs/standards/errors.md +26 -0
- package/template/docs/standards/frontend.md +60 -0
- package/template/docs/standards/functions.md +38 -0
- package/template/docs/standards/git-delivery.md +20 -0
- package/template/docs/standards/module-structure.md +37 -0
- package/template/docs/standards/testing.md +72 -0
- package/template/docs/standards/types.md +31 -0
- package/template/package.json.tpl +11 -5
- package/template/rulesync.jsonc +1 -1
- package/template/tools/oss-worktree.mjs.tpl +105 -0
- package/template/tools/review-precheck.mjs +531 -0
- package/template/tools/sync-agents.mjs +257 -0
- package/template/turbo/generators/config.ts +1 -1
- package/template/turbo.json +14 -2
- package/template/__dot__rulesync/hooks/post-edit.mjs +0 -57
- package/template/__dot__rulesync/skills/add-feature/SKILL.md +0 -113
- package/template/__dot__rulesync/skills/add-feature/handoff.md +0 -58
- package/template/__dot__rulesync/skills/code-review/SKILL.md +0 -111
- package/template/__dot__rulesync/skills/create-pr/SKILL.md +0 -55
- package/template/__dot__rulesync/subagents/builder.md +0 -93
- package/template/__dot__rulesync/subagents/debugger.md +0 -83
- package/template/__dot__rulesync/subagents/deployer.md +0 -66
- package/template/__dot__rulesync/subagents/qa.md +0 -88
- package/template/apps/api/src/migrate.ts.tpl +0 -46
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Database conventions (SQL / Drizzle)
|
|
2
|
+
|
|
3
|
+
Read this in full before editing a schema, Drizzle query, migration config, or seed under
|
|
4
|
+
`apps/api/**`. Tables live in `@openora/*` core for platform domains - never edit those; these
|
|
5
|
+
rules govern the tables your overlay adds
|
|
6
|
+
(`apps/api/src/extensions/<name>/src/schema/`). Import/boundary rules live in `oss-boundaries`;
|
|
7
|
+
this file is SQL only.
|
|
8
|
+
|
|
9
|
+
## Identifiers - snake_case everywhere
|
|
10
|
+
|
|
11
|
+
Every drizzle instance sets `casing: 'snake_case'`, so the SQL name derives from the camelCase
|
|
12
|
+
key. Pass an explicit name only where casing can't derive it: table names, `pgEnum` types, index
|
|
13
|
+
names.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// good - key derives the column; const camelCase; explicit snake_case only for table + index
|
|
17
|
+
export const playerNote = pgTable(
|
|
18
|
+
'player_note',
|
|
19
|
+
{
|
|
20
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
21
|
+
playerId: uuid().notNull(), // -> player_id
|
|
22
|
+
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
23
|
+
},
|
|
24
|
+
(t) => [index('player_note_player_id_idx').on(t.playerId)],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// bad - explicit/camelCase column names, PascalCase table
|
|
28
|
+
pgTable('PlayerNote', { player_id: uuid('playerId') });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Timestamps - always timestamptz
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
createdAt: timestamp({ withTimezone: true }); // good - Postgres timestamptz, store UTC
|
|
35
|
+
createdAt: timestamp(); // bad - naive, drops the zone
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every datetime column (`createdAt`, `updatedAt`, `expiresAt`, any `*At`) carries the zone.
|
|
39
|
+
|
|
40
|
+
## Keys, references, indexes
|
|
41
|
+
|
|
42
|
+
- UUID primary keys (`uuid().primaryKey().defaultRandom()`).
|
|
43
|
+
- **No foreign keys across a module/overlay boundary** - store a plain ID string and resolve via
|
|
44
|
+
the oRPC client or a schema subpath. FKs only within the same add-on.
|
|
45
|
+
- `NOT NULL` by default; push defaults to the DB (`.notNull().default(...)`), not app code.
|
|
46
|
+
- Index every column you filter or join on; name it `<table>_<cols>_idx`.
|
|
47
|
+
|
|
48
|
+
## Efficient operations
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// bad - N+1
|
|
52
|
+
for (const id of ids) await db.select().from(wallet).where(eq(wallet.playerId, id));
|
|
53
|
+
// good - one batched query
|
|
54
|
+
await db.select().from(wallet).where(inArray(wallet.playerId, ids));
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- Select only the columns you use; don't `select(*)` wide rows to read one field.
|
|
58
|
+
- **Build rows by spread + override, never hand-copy field-by-field.** When a row mostly mirrors a
|
|
59
|
+
validated input, spread it and set only the server-computed fields - `db.insert(x).values({
|
|
60
|
+
...input, id, createdAt })` - never re-list `field: input.field` per key (it silently drifts the
|
|
61
|
+
moment a column is added). Keep fields explicit only for: an order-sensitive hash/signature
|
|
62
|
+
payload; a source carrying columns the row must not receive (spread then omit them); or a
|
|
63
|
+
null-vs-undefined boundary that won't coerce (`actorId: input.actorId ?? null`).
|
|
64
|
+
- **Money / critical paths are transactional and idempotent** - a DB guard inside the transaction,
|
|
65
|
+
not just an `idempotencyKey` (delivery is at-least-once).
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
await db.transaction(async (t) => {
|
|
69
|
+
if (await ledgerExists(t, idempotencyKey)) return; // guard, not just a key
|
|
70
|
+
await insertLedger(t, { idempotencyKey, amount, currency });
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Migrations
|
|
75
|
+
|
|
76
|
+
- Never hand-edit generated migrations. Change the `pgTable`, then `pnpm db:migrate`.
|
|
77
|
+
- One migration per schema change; review the generated SQL before committing.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Enforcement
|
|
2
|
+
|
|
3
|
+
Read this before working around a failing gate or adding a lint rule.
|
|
4
|
+
|
|
5
|
+
- `pnpm check:types` + `pnpm check:lint` (oxlint) + `pnpm test:unit` is the fast gate;
|
|
6
|
+
In repos that ship `pnpm check:boundaries` (dependency-cruiser), that is the whole-graph
|
|
7
|
+
boundary/cycle check, also run by the pre-commit hook and CI.
|
|
8
|
+
- oxlint extends the platform's shared config (`./node_modules/@openora/core/oxlint/oxlintrc.json`)
|
|
9
|
+
- add local rules on top, never fork it.
|
|
10
|
+
- Don't work around a lint/boundary violation - fix the import.
|
|
11
|
+
- Agent rules are generated from `.rulesync/` via `pnpm gen:agents` - never hand-edit a generated
|
|
12
|
+
file.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
Read this before adding an error class, a catch, or a money-handling path.
|
|
4
|
+
|
|
5
|
+
- Fail fast at boundaries with typed errors (`CreateOrderSchema.parse(raw)` throws early).
|
|
6
|
+
- No silent catches - log with context and rethrow.
|
|
7
|
+
- Typed, named error classes via the shared factories (`makeNotFoundError`/`makeOwnershipError`/
|
|
8
|
+
`makeConflictError`); the router's `mapErrors` keys off the exported class.
|
|
9
|
+
- `ORPCError.message` is an English fallback for logs, not player-facing copy - UI copy keys off
|
|
10
|
+
`.code` plus typed `.data` fields.
|
|
11
|
+
- Money/critical paths are transactional AND idempotent: a DB guard inside the transaction, not
|
|
12
|
+
just an `idempotencyKey` (delivery is at-least-once).
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// bad - swallows the error, no context, no rethrow
|
|
16
|
+
try {
|
|
17
|
+
await chargeWallet(tx, amount);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
// good - a DB guard makes the mutation idempotent under at-least-once delivery
|
|
22
|
+
await db.transaction(async (t) => {
|
|
23
|
+
if (await ledgerExists(t, idempotencyKey)) return;
|
|
24
|
+
await insertLedger(t, { idempotencyKey, amount });
|
|
25
|
+
});
|
|
26
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Frontend conventions
|
|
2
|
+
|
|
3
|
+
React/UI rules for this repo's `apps/*` UI apps and shared UI package. Read this before touching
|
|
4
|
+
`apps/web/**`, `apps/backoffice/**`, or `packages/ui/**`. Not applicable if this repo is
|
|
5
|
+
headless/api-only - delete this file and its row in `conventions`'s routing table in that case.
|
|
6
|
+
|
|
7
|
+
## Component library and styling
|
|
8
|
+
|
|
9
|
+
- React Compiler is ON. Never hand-write `useMemo`/`useCallback`/`React.memo` - a compiler bail is
|
|
10
|
+
a Rules-of-React violation to fix. Exception: your shared UI package is consumed pre-built, so
|
|
11
|
+
hand-write stability there when it's part of a hook's contract (same reason `@openora/core/react`
|
|
12
|
+
does).
|
|
13
|
+
- Pick ONE component library and use its classes customized via utility classes + theme tokens.
|
|
14
|
+
Don't hand-roll what the library provides.
|
|
15
|
+
- Never hand-write component CSS selectors (`.btn-*{}`, `.table th{}`). A per-app `styles.css`
|
|
16
|
+
holds only the theme plus custom properties for raw values with no token; every other style is
|
|
17
|
+
a utility class on the element.
|
|
18
|
+
- App-specific looks live in that app's `styles.css`; shared UI components stay visually neutral.
|
|
19
|
+
- No hardcoded user-facing copy - every label goes through `t()` with a key in the module's
|
|
20
|
+
`locales/`. Pattern: `locales/index.ts` exports `export const ns = registerTranslations('<module>',
|
|
21
|
+
locales)`; components use `useTranslation(ns)`. Non-`en` files mirror `en.json` keys exactly.
|
|
22
|
+
- Theme tokens and CSS variables are declared once in the shared UI package - style with semantic
|
|
23
|
+
tokens (`bg-base-100`, `text-base-content`, `btn-primary`), never inline raw hex. A design color
|
|
24
|
+
with no token: add it to BOTH dark and light theme blocks. A new token without a design source:
|
|
25
|
+
ask the user first.
|
|
26
|
+
- Hoist long class strings into a module-scope `const styles = { ... } as const` keyed by role -
|
|
27
|
+
never inline long strings in JSX; merge with `cn()`.
|
|
28
|
+
- Extract a repeated utility-class recipe into a shared component or constant on the third
|
|
29
|
+
occurrence.
|
|
30
|
+
- Import helpers like `cn()` from the shared UI package barrel - never deep-import.
|
|
31
|
+
- Server state is not client state - key/cache/invalidate via the query lib, never
|
|
32
|
+
`useEffect(fetch)`, never shadowed in ad-hoc caches.
|
|
33
|
+
- No label or text is allowed to overflow its container - never leave text unconstrained by
|
|
34
|
+
default. Pick per element, based on whether the full text is load-bearing: `truncate` + a
|
|
35
|
+
`title` attribute holding the full string when a single line is the right shape and hiding the
|
|
36
|
+
tail is acceptable (a table cell, a narrow badge); `line-clamp-N` + a reserved `min-h-[NlH]` when
|
|
37
|
+
several lines are the right shape or the content differs in length across siblings that must
|
|
38
|
+
stay visually aligned (a KPI tile label, a card title) - the reserved height keeps every sibling
|
|
39
|
+
the same height whether or not its text actually wraps. A tooltip/popover with the full text is
|
|
40
|
+
the fallback only when neither fits the UX.
|
|
41
|
+
|
|
42
|
+
## Modular architecture (every app)
|
|
43
|
+
|
|
44
|
+
- Feature modules live in `src/modules/<m>/` with the same internal folders: `pages/`,
|
|
45
|
+
`components/`, `hooks/`, `utils/`, `locales/`, and a public barrel `index.ts`.
|
|
46
|
+
- App-level (non-module) code splits the same way: `src/lib/` holds stateful/integration code
|
|
47
|
+
(API clients, SDK wrappers, config); `src/utils/` holds pure stateless helpers (formatters,
|
|
48
|
+
converters).
|
|
49
|
+
- No cross-module imports, ever. Cross-module communication is query invalidation or a domain
|
|
50
|
+
event, never a direct import.
|
|
51
|
+
- Outer composition code (`src/app/`, `src/routes/`, `extensions.config.ts`) imports modules only
|
|
52
|
+
through their barrel; deep-importing internals is forbidden. Files inside a module use relative
|
|
53
|
+
paths to siblings only, never `../../` out of the module.
|
|
54
|
+
- Components in `components/` and `pages/` are presentation-only: props in, JSX out - no
|
|
55
|
+
fetching, no side effects. Business logic, queries, and mutations live in `hooks/`, which accept
|
|
56
|
+
external dependencies (oRPC calls, API clients) as parameters.
|
|
57
|
+
- Next.js client components: `'use client'` on line 1 + `.client.tsx` suffix; server components
|
|
58
|
+
are the default (no marker). All-client apps (Vite/TanStack) use no suffix.
|
|
59
|
+
- One concept per file, exported name matches the kebab-case filename; split hooks and components
|
|
60
|
+
into separate files.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Functions and modules
|
|
2
|
+
|
|
3
|
+
Read this before writing or refactoring a function, service method, or module.
|
|
4
|
+
|
|
5
|
+
- Pure functions with dependencies passed in as arguments; side effects only at the edges
|
|
6
|
+
(services, adapters, plugins, handlers) - never in helpers.
|
|
7
|
+
- Immutability: derive new objects (`{ ...user, roles: [...] }`), don't mutate.
|
|
8
|
+
- Construct objects by spread + override, never field-by-field hand-copy. Keep fields explicit
|
|
9
|
+
only for order-sensitive serialization, when the target must not receive some source fields, or
|
|
10
|
+
when null-vs-undefined matters at a boundary.
|
|
11
|
+
- A `class` is only a thin dependency-holding shell at a composition root; methods delegate to
|
|
12
|
+
pure functions. No inheritance for reuse, no decorators - compose.
|
|
13
|
+
- Short, single-purpose functions - if you'd write `// step 2` inside one, extract it.
|
|
14
|
+
- Guard clauses first, main path last.
|
|
15
|
+
- More than 3 parameters -> a single named-object param.
|
|
16
|
+
- Don't annotate a return type TypeScript can infer. Annotate only when: inference can't
|
|
17
|
+
(recursion), you deliberately widen/narrow, or it's the exported public API of a shared
|
|
18
|
+
`packages/*` module - there the explicit type IS the contract. Argument types always stay
|
|
19
|
+
explicit.
|
|
20
|
+
- Named exports only - no default exports (exceptions: `*.config.*` files, `plugin.ts` whose
|
|
21
|
+
loader reads `mod.default`, and Next.js App Router files; lint-enforced).
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// bad - the service reaches into a global/container to hide what it depends on
|
|
25
|
+
export class WalletService {
|
|
26
|
+
constructor(private readonly container: Container) {}
|
|
27
|
+
async deposit() {
|
|
28
|
+
const psp = this.container.get(PAYMENT_ADAPTER);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// good - deps are constructor params of their port type
|
|
32
|
+
export class WalletService {
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly db: DrizzleService,
|
|
35
|
+
private readonly payments: PaymentAdapter,
|
|
36
|
+
) {}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Git and delivery
|
|
2
|
+
|
|
3
|
+
Read this before a commit, PR, or branch operation.
|
|
4
|
+
|
|
5
|
+
- Conventional commits, enforced by commitlint (husky locally, CI on the PR/MR title): `feat`, `fix`, `refactor`, `chore`,
|
|
6
|
+
`docs`, `test`, `ci`, `perf`. E.g. `feat(wallet): atomic debit command port`.
|
|
7
|
+
- Subject starts lowercase, acronyms included (`feat(pam): kyc status filter`). This applies to
|
|
8
|
+
the PR title too - squash merges derive the commit message from it.
|
|
9
|
+
- One PR = one concern. Stage files explicitly; never `git add -A` when foreign changes are in
|
|
10
|
+
the tree.
|
|
11
|
+
- Green before review: `pnpm verify` on the committed tree. It is the full gate and records the
|
|
12
|
+
verified commit; the `guard-mr` hook blocks opening the PR/MR without it.
|
|
13
|
+
- Branch off `dev`; never commit directly to a shared branch; never push without an explicit
|
|
14
|
+
per-action "yes push", except the ticket branch `/ship` opens its MR from.
|
|
15
|
+
- PR description carries intent: what / why / acceptance criteria / ticket link. No secrets,
|
|
16
|
+
internal hostnames, or PII - it is the public record.
|
|
17
|
+
- A PR that adds or changes a flow across components (request path, state machine, async job or
|
|
18
|
+
webhook, a new module, overlay or vendor binding) carries a Mermaid `flowchart` or
|
|
19
|
+
`sequenceDiagram` of it: nodes named as in the code, under ~15 nodes, refusal and failure
|
|
20
|
+
branches included. No flow, no diagram.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Package and module structure
|
|
2
|
+
|
|
3
|
+
Read this before creating a package, an overlay add-on, or wiring a new module's internal layout.
|
|
4
|
+
|
|
5
|
+
- One package = one concern, named `@<scope>/<kebab>`, with an explicit `exports` map. The
|
|
6
|
+
entrypoint IS the public API; everything else is internal and off-limits to consumers
|
|
7
|
+
(`oss-boundaries`).
|
|
8
|
+
- Overlay/add-on packages mirror the platform module shape: `contract/`, `schema/`, `service/`,
|
|
9
|
+
`router/`, `adapters/`, `__tests__/`, plus `plugin.ts` at the root as the single wiring point.
|
|
10
|
+
|
|
11
|
+
| Layer | File | Holds | Must NOT hold |
|
|
12
|
+
| -------- | --------------------------- | --------------------------------------------------------------- | -------------------------------- |
|
|
13
|
+
| schema | `schema/index.ts` | Drizzle `pgTable`s; row types via `$inferSelect`/`$inferInsert` | logic |
|
|
14
|
+
| contract | `contract/index.ts` | oRPC route contract + req/res Zod schemas | logic, transport wiring |
|
|
15
|
+
| service | `service/<name>.service.ts` | ALL business logic; emits events after DB commit | HTTP/transport knowledge |
|
|
16
|
+
| router | `router/index.ts` | thin oRPC wiring: resolve caller, call service, map errors | business rules |
|
|
17
|
+
| plugin | `plugin.ts` | DI wiring only: `ctx.provide(...)`, route registration | logic |
|
|
18
|
+
| adapters | `adapters/<vendor>/` | concrete impls of adapter ports | being imported by another module |
|
|
19
|
+
|
|
20
|
+
- Each overlay owns its `drizzle.config.ts` and its own migration history - never share one
|
|
21
|
+
migration folder across packages.
|
|
22
|
+
- Pin exact dependency versions in every package; a package never depends on an app.
|
|
23
|
+
- Add a dependency deliberately - std lib or a few lines often beat a tree.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// bad - a service reaches into the container and hides what it depends on
|
|
27
|
+
export class WalletService {
|
|
28
|
+
constructor(private readonly container: Container) {}
|
|
29
|
+
}
|
|
30
|
+
// good - deps are constructor params of their port type; plugin.ts does the resolving
|
|
31
|
+
export class WalletService {
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly db: DrizzleService,
|
|
34
|
+
private readonly payments: PaymentAdapter,
|
|
35
|
+
) {}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
Read this before adding or restructuring a test.
|
|
4
|
+
|
|
5
|
+
## When a test is written
|
|
6
|
+
|
|
7
|
+
A feature or fix PR carries **its own tests**, written in the same branch from the manual
|
|
8
|
+
verification pass that proves the change works. There is no separate test PR.
|
|
9
|
+
|
|
10
|
+
Before the PR opens:
|
|
11
|
+
|
|
12
|
+
1. Drive the change against the running stack. Reach for the **Playwright CLI** first
|
|
13
|
+
(`playwright-cli` with `video-start` / `video-stop`, or a throwaway spec with
|
|
14
|
+
`test.use({ video: 'on' })` that clicks through the flow) - it costs a fraction of the tokens a
|
|
15
|
+
browser MCP does. Use a browser MCP only for a live console or network read the CLI cannot give
|
|
16
|
+
you.
|
|
17
|
+
2. Capture the evidence: one short video per acceptance criterion, ending on the outcome it names,
|
|
18
|
+
and the before/after pair when you fixed something. A screenshot only where a video adds
|
|
19
|
+
nothing, such as a second theme. An API-only change attaches the request/response trace instead.
|
|
20
|
+
3. Attach that evidence **to the PR description**, not only to your report - a reviewer confirms a
|
|
21
|
+
change by looking at it.
|
|
22
|
+
4. Write the tests that pass exercised, in this branch, at the tier the table below names. Include
|
|
23
|
+
the hostile paths and the authz negatives. A fix carries the test that fails without it.
|
|
24
|
+
5. End the description with a **"Tests"** list: one line per test added, naming its tier
|
|
25
|
+
(unit / API E2E / browser E2E) and path.
|
|
26
|
+
|
|
27
|
+
A changed behaviour with no test is a review `[WARN]`, never a `[BLOCK]`.
|
|
28
|
+
|
|
29
|
+
## Pick the tier
|
|
30
|
+
|
|
31
|
+
Pick the OUTERMOST tier that can reach the behaviour - a test earns its keep by running real code,
|
|
32
|
+
not by being cheap.
|
|
33
|
+
|
|
34
|
+
| What you changed | Tier |
|
|
35
|
+
| ------------------------------------------------------------- | --------------------------------------------- |
|
|
36
|
+
| A screen, a form, a player/admin journey | Browser E2E (`apps/e2e/tests/<app>/**`) |
|
|
37
|
+
| An API route, an overlay, a vendor adapter, anything with SQL | API E2E (`apps/e2e/tests/api/**`) |
|
|
38
|
+
| A pure function - parser, resolver, mapper, money calculation | Unit (`src/__tests__/<name>.test.ts`, Vitest) |
|
|
39
|
+
|
|
40
|
+
The API tier drives the real API over HTTP against real Postgres, with each external vendor
|
|
41
|
+
replaced by a stub HTTP server the API is pointed at by env. It is the default tier for
|
|
42
|
+
`apps/api/src/extensions/**`: an overlay is wiring, and wiring is exactly what a unit test skips.
|
|
43
|
+
|
|
44
|
+
## What is real, what is doubled
|
|
45
|
+
|
|
46
|
+
- **Anything that touches the database is tested against real Postgres.** Never fake a query
|
|
47
|
+
builder: a stubbed chain proves a call order, not a result, so it misses the regressions that
|
|
48
|
+
matter - a unique-index conflict, a wrong `where`, a lost race, a rollback that never happened.
|
|
49
|
+
- **External vendors are stubbed at their HTTP boundary** (a `node:http` stand-in the API's
|
|
50
|
+
base-URL env var points at), never by mocking our own adapter - a mocked adapter skips exactly
|
|
51
|
+
the wiring the test exists to check. Every vendor adapter therefore accepts a base-URL override.
|
|
52
|
+
- **A spy assertion is never the point of a test.** `expect(client.x).toHaveBeenCalledWith(...)`
|
|
53
|
+
only restates the line above it. A spy may stand in for an outbound vendor call, never for the
|
|
54
|
+
thing under test.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// bad - a mocked builder chain proves a call order, not a result
|
|
58
|
+
const db = { select: vi.fn().mockReturnValue({ from: vi.fn().mockResolvedValue([row]) }) };
|
|
59
|
+
expect(db.select).toHaveBeenCalled();
|
|
60
|
+
// good - assert the outcome
|
|
61
|
+
expect(await service.get(id)).toEqual(row);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How to write them
|
|
65
|
+
|
|
66
|
+
- Test behavior, not implementation - tests must survive a safe refactor (assert outputs, not
|
|
67
|
+
private caches).
|
|
68
|
+
- Cover the change in the same PR as the change; always include the authz negatives.
|
|
69
|
+
- Drive a vendor's inbound side the way the vendor does: post the real webhook shape to the real
|
|
70
|
+
route with a signature the stub's key material produces. Never call the adapter directly.
|
|
71
|
+
- Deterministic and isolated: no shared mutable state, no real outbound network, seedable data.
|
|
72
|
+
Own the rows a test creates and clean them up - the API suite shares one database.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Types
|
|
2
|
+
|
|
3
|
+
Read this before adding or changing a schema, type, or enum-like value set.
|
|
4
|
+
|
|
5
|
+
- One source of truth per shape - infer, never hand-write a type that already exists:
|
|
6
|
+
`z.infer<typeof UserSchema>`, `typeof users.$inferSelect`, `Omit<User, 'id'>`.
|
|
7
|
+
- Schema-first at every boundary (HTTP, config, env, messages, events): validate once at the edge,
|
|
8
|
+
trust the type after. oRPC + Zod does this for routes; do the same elsewhere.
|
|
9
|
+
- Never re-infer a schema you imported - the owning contract exports the type once; import it.
|
|
10
|
+
- No `any` outside tests - `unknown` + narrowing.
|
|
11
|
+
- No `as` casts to silence the compiler - fix the root cause (`as const` is fine).
|
|
12
|
+
- Never `!` non-null assertions - narrow explicitly, or restructure so the value is provably
|
|
13
|
+
present (carry it on the object instead of re-looking it up).
|
|
14
|
+
- Under `noUncheckedIndexedAccess`: `.at()`, destructure-with-default
|
|
15
|
+
(`const [first = ''] = parts`), or an explicit guard - never `arr[i]!`.
|
|
16
|
+
- `type` over `interface` (lint-enforced).
|
|
17
|
+
- Type entity ids through their owning type (`playerId: Player['id']`), never a bare `string`.
|
|
18
|
+
- Derive related schemas with `.pick()/.omit()/.partial()/.extend()/.merge()` - never re-type
|
|
19
|
+
fields.
|
|
20
|
+
- Enum-like value sets are a values + schema + type triple declared once on the contract surface:
|
|
21
|
+
`X_STATUSES = [...] as const` -> `XStatusSchema = z.enum(X_STATUSES)` -> inferred `XStatus`.
|
|
22
|
+
Never a TS `enum`, never a second hand-typed copy - import the one from `@openora/*`.
|
|
23
|
+
- Make illegal states unrepresentable - discriminated unions
|
|
24
|
+
(`{ status: 'ok'; data } | { status: 'error'; error }`) over optional-flag soup.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// bad - hand-written duplicate of an inferrable type
|
|
28
|
+
type User = { id: string; email: string; roles: string[] };
|
|
29
|
+
// good - infer from the schema that already validates the shape
|
|
30
|
+
type User = z.infer<typeof UserSchema>;
|
|
31
|
+
```
|
|
@@ -6,15 +6,21 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "turbo run dev",
|
|
8
8
|
"build": "turbo run build",
|
|
9
|
-
"
|
|
10
|
-
"lint": "oxlint .",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
9
|
+
"check:types": "turbo run check:types",
|
|
10
|
+
"check:lint": "oxlint .",
|
|
11
|
+
"test:unit": "turbo run test:unit",
|
|
12
|
+
"verify": "turbo run check:types check:lint test:unit && node .rulesync/hooks/guard-mr.mjs --stamp",
|
|
13
|
+
"gen:agents": "rulesync generate",
|
|
14
|
+
"sync:agents": "node tools/sync-agents.mjs",
|
|
15
|
+
"oss:worktree": "node tools/oss-worktree.mjs",
|
|
16
|
+
"review:precheck": "node tools/review-precheck.mjs",
|
|
17
|
+
"prepare": "node tools/sync-agents.mjs && rulesync generate",
|
|
18
|
+
"db:migrate": "pnpm -F @{{name}}/api exec openora-migrate",
|
|
14
19
|
"db:seed": "pnpm -F @{{name}}/api db:seed",
|
|
15
20
|
"gen": "turbo gen"
|
|
16
21
|
},
|
|
17
22
|
"devDependencies": {
|
|
23
|
+
"@openora/create": "{{coreVersion}}",
|
|
18
24
|
"@openora/mcp": "latest",
|
|
19
25
|
"@turbo/gen": "2.9.14",
|
|
20
26
|
"@types/node": "25.9.0",
|
package/template/rulesync.jsonc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// rulesync config - single source of truth for all AI-agent instruction files.
|
|
2
2
|
// Source lives in .rulesync/ (rules, subagents, commands, mcp.json).
|
|
3
|
-
// Regenerate with `pnpm
|
|
3
|
+
// Regenerate with `pnpm gen:agents`.
|
|
4
4
|
{
|
|
5
5
|
"$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/config-schema.json",
|
|
6
6
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A git worktree of the sibling OSS checkout - the only place an agent in this repo may change
|
|
3
|
+
// OSS code (the guard-core hook allows {{ossFromRoot}}/.worktrees/ and denies the rest of the
|
|
4
|
+
// checkout). Same command for every agent and every human, so a paired change looks the same
|
|
5
|
+
// on every machine.
|
|
6
|
+
//
|
|
7
|
+
// pnpm oss:worktree <branch> create or reuse the worktree, install its deps
|
|
8
|
+
// pnpm oss:worktree <branch> --link ...then build it and point `pnpm link:oss` at it
|
|
9
|
+
// pnpm oss:worktree <branch> --remove remove it; relinks the main checkout if it was linked
|
|
10
|
+
//
|
|
11
|
+
// Use the same branch name as this repo's branch: that is how skills pair the two requests.
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join, relative, resolve } from 'node:path';
|
|
15
|
+
// oxlint-disable no-console
|
|
16
|
+
|
|
17
|
+
// This repo is often checked out as a git worktree, whose `.git` is a file pointing at
|
|
18
|
+
// <main>/.git/worktrees/<name>. The sibling OSS checkout sits next to the MAIN checkout, so
|
|
19
|
+
// resolve from there or every path below lands inside the worktree.
|
|
20
|
+
const mainCheckoutOf = (dir) => {
|
|
21
|
+
try {
|
|
22
|
+
const pointer = readFileSync(join(dir, '.git'), 'utf8');
|
|
23
|
+
const gitdir = pointer.match(/^gitdir:\s*(.*[/\\]worktrees[/\\][^/\\]+?)\s*$/m)?.[1];
|
|
24
|
+
return gitdir ? resolve(dir, gitdir, '..', '..', '..') : dir;
|
|
25
|
+
} catch {
|
|
26
|
+
return dir; // `.git` is a directory (the main checkout) or missing
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const OSS = resolve(mainCheckoutOf(process.cwd()), '{{ossFromRoot}}');
|
|
31
|
+
const USAGE = 'usage: pnpm oss:worktree <branch> [--link | --remove]';
|
|
32
|
+
|
|
33
|
+
const die = (message) => {
|
|
34
|
+
console.error(`oss:worktree - ${message}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const [branch, flag] = process.argv.slice(2);
|
|
39
|
+
if (!branch || branch.startsWith('--') || (flag && !['--link', '--remove'].includes(flag))) {
|
|
40
|
+
die(USAGE);
|
|
41
|
+
}
|
|
42
|
+
if (!existsSync(join(OSS, '.git'))) {
|
|
43
|
+
die(`no OSS checkout at ${OSS} - clone it next to this repo first.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const worktree = join(OSS, '.worktrees', branch.replaceAll('/', '+'));
|
|
47
|
+
const worktreeFromHere = relative(process.cwd(), worktree);
|
|
48
|
+
// A fresh worktree has no node_modules yet, so the checkout's own post-checkout hook would run
|
|
49
|
+
// against tools it cannot resolve and fail the command. The install below does that setup.
|
|
50
|
+
const GIT = ['-C', OSS, '-c', 'core.hooksPath=/dev/null'];
|
|
51
|
+
const git = (...args) =>
|
|
52
|
+
execFileSync('git', [...GIT, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }).trim();
|
|
53
|
+
const gitOrEmpty = (...args) => {
|
|
54
|
+
try {
|
|
55
|
+
return execFileSync('git', [...GIT, ...args], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
56
|
+
} catch {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const run = (cmd, args) => execFileSync(cmd, args, { stdio: 'inherit' });
|
|
61
|
+
const hasLinkScript = () =>
|
|
62
|
+
Boolean(JSON.parse(readFileSync('package.json', 'utf8')).scripts?.['link:oss']);
|
|
63
|
+
const isLinkedTo = (dir) =>
|
|
64
|
+
existsSync('pnpm-workspace.yaml') &&
|
|
65
|
+
readFileSync('pnpm-workspace.yaml', 'utf8').includes(`link:${relative(process.cwd(), dir)}/`);
|
|
66
|
+
|
|
67
|
+
if (flag === '--remove') {
|
|
68
|
+
const wasLinked = isLinkedTo(worktree);
|
|
69
|
+
if (existsSync(worktree)) {
|
|
70
|
+
try {
|
|
71
|
+
git('worktree', 'remove', worktree);
|
|
72
|
+
} catch {
|
|
73
|
+
die(`${worktreeFromHere} has uncommitted changes - commit or discard them, then rerun.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (wasLinked && hasLinkScript()) {
|
|
77
|
+
run('pnpm', ['link:oss']);
|
|
78
|
+
}
|
|
79
|
+
console.log(`removed ${worktreeFromHere}`);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!existsSync(worktree)) {
|
|
84
|
+
git('fetch', 'origin', '--quiet');
|
|
85
|
+
const base = gitOrEmpty('rev-parse', '--abbrev-ref', 'origin/HEAD') || 'origin/dev';
|
|
86
|
+
if (gitOrEmpty('rev-parse', '--verify', '--quiet', `refs/heads/${branch}`)) {
|
|
87
|
+
git('worktree', 'add', worktree, branch);
|
|
88
|
+
} else if (gitOrEmpty('rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`)) {
|
|
89
|
+
git('worktree', 'add', '--track', '-b', branch, worktree, `origin/${branch}`);
|
|
90
|
+
} else {
|
|
91
|
+
git('worktree', 'add', '--no-track', '-b', branch, worktree, base);
|
|
92
|
+
}
|
|
93
|
+
run('pnpm', ['-C', worktree, 'install', '--frozen-lockfile']);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (flag === '--link') {
|
|
97
|
+
if (!hasLinkScript()) {
|
|
98
|
+
die('this repo has no `link:oss` script - link the worktree through pnpm overrides by hand.');
|
|
99
|
+
}
|
|
100
|
+
// @openora/* resolve from dist/, so a linked worktree works only once it is built.
|
|
101
|
+
run('pnpm', ['-C', worktree, 'build']);
|
|
102
|
+
run('pnpm', ['link:oss', worktreeFromHere]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(worktreeFromHere);
|