@dennisrongo/skills 0.1.3 → 0.3.0
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/README.md +87 -38
- package/package.json +1 -1
- package/skills/code-review/SKILL.md +270 -0
- package/skills/codebase-explainer/SKILL.md +112 -0
- package/skills/diagnose/SKILL.md +31 -1
- package/skills/dotnet-onion-api/SKILL.md +1 -0
- package/skills/nextjs-app-router/SKILL.md +229 -175
- package/skills/nextjs-app-router/references/anti-patterns.md +163 -99
- package/skills/nextjs-app-router/references/folder-layout.md +79 -46
- package/skills/nextjs-app-router/references/good-patterns.md +233 -99
- package/skills/nextjs-app-router/references/templates/api-base.md +24 -21
- package/skills/nextjs-app-router/references/templates/auth-slice.md +82 -78
- package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +58 -6
- package/skills/nextjs-app-router/references/templates/db-client.md +48 -0
- package/skills/nextjs-app-router/references/templates/env-and-utils.md +90 -23
- package/skills/nextjs-app-router/references/templates/feature-slice.md +110 -47
- package/skills/nextjs-app-router/references/templates/form-with-zod.md +23 -15
- package/skills/nextjs-app-router/references/templates/middleware.md +69 -59
- package/skills/nextjs-app-router/references/templates/next-config.md +4 -14
- package/skills/nextjs-app-router/references/templates/nextauth-config.md +178 -0
- package/skills/nextjs-app-router/references/templates/package.md +35 -5
- package/skills/nextjs-app-router/references/templates/prisma-schema.md +162 -0
- package/skills/nextjs-app-router/references/templates/providers-and-store.md +35 -21
- package/skills/nextjs-app-router/references/templates/root-layout.md +99 -20
- package/skills/nextjs-app-router/references/templates/route-group-layouts.md +46 -6
- package/skills/nextjs-app-router/references/templates/route-handler.md +168 -0
- package/skills/nextjs-app-router/references/templates/testing.md +105 -31
- package/skills/plan-and-build/SKILL.md +45 -3
- package/skills/pr-review/SKILL.md +50 -3
- package/skills/tauri-2-app/SKILL.md +1 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Route group layouts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Group layouts are **server components** that render zero data — they just delegate to a `'use client'` shell. Pages inside the group are `'use client'`.
|
|
4
4
|
|
|
5
|
-
## `src/app/(public)/layout.tsx` (SERVER)
|
|
5
|
+
## `src/app/(public)/layout.tsx` (SERVER, zero data)
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
8
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
|
@@ -20,12 +20,14 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
|
|
20
20
|
import { AppShell } from './_components/AppShell';
|
|
21
21
|
|
|
22
22
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|
23
|
-
// Server-side session check
|
|
24
|
-
//
|
|
23
|
+
// Server-side session check is done in middleware.ts — by the time we render here,
|
|
24
|
+
// the user is authenticated. We do NOT call `await auth()` here; the gate is the middleware.
|
|
25
25
|
return <AppShell>{children}</AppShell>;
|
|
26
26
|
}
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
**No `await auth()` here.** The middleware already gated the request. Calling `auth()` again from this layout adds a round-trip and doesn't change the outcome.
|
|
30
|
+
|
|
29
31
|
## `src/app/(app)/_components/AppShell.tsx` (CLIENT, holds interactive UI state)
|
|
30
32
|
|
|
31
33
|
```tsx
|
|
@@ -50,9 +52,40 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|
|
50
52
|
}
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
## `src/app/(app)/
|
|
55
|
+
## `src/app/(app)/_components/UserMenu.tsx` (CLIENT — uses NextAuth)
|
|
54
56
|
|
|
55
57
|
```tsx
|
|
58
|
+
'use client';
|
|
59
|
+
|
|
60
|
+
import { signOut, useSession } from 'next-auth/react';
|
|
61
|
+
import { Button } from '@/components/ui/button';
|
|
62
|
+
|
|
63
|
+
export function UserMenu() {
|
|
64
|
+
const { data: session, status } = useSession();
|
|
65
|
+
|
|
66
|
+
if (status === 'loading') return null;
|
|
67
|
+
if (!session?.user) return null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex items-center gap-3">
|
|
71
|
+
<span className="text-sm text-muted-foreground">{session.user.email}</span>
|
|
72
|
+
<Button
|
|
73
|
+
variant="ghost"
|
|
74
|
+
size="sm"
|
|
75
|
+
onClick={() => signOut({ callbackUrl: '/auth/login', redirect: true })}
|
|
76
|
+
>
|
|
77
|
+
Sign out
|
|
78
|
+
</Button>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## `src/app/(app)/loading.tsx` (CLIENT — Suspense fallback)
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
'use client';
|
|
88
|
+
|
|
56
89
|
export default function Loading() {
|
|
57
90
|
return (
|
|
58
91
|
<div className="flex h-[50vh] items-center justify-center">
|
|
@@ -87,4 +120,11 @@ export default function AppError({ error, reset }: { error: Error & { digest?: s
|
|
|
87
120
|
|
|
88
121
|
## `src/app/(admin)/layout.tsx` (OPTIONAL, SERVER)
|
|
89
122
|
|
|
90
|
-
Same shape as `(app)/layout.tsx`. The role check happens in `middleware.ts
|
|
123
|
+
Same shape as `(app)/layout.tsx`. The role check happens in `middleware.ts` (Variant B in [`middleware.md`](middleware.md)); this layout just renders an admin-flavored shell.
|
|
124
|
+
|
|
125
|
+
## Forbidden in any layout
|
|
126
|
+
|
|
127
|
+
- `await auth()` — duplicates middleware's job.
|
|
128
|
+
- `await db.*` — server data fetching is banned.
|
|
129
|
+
- `'use client'` directly on a `layout.tsx` — push interactive chrome into a client child.
|
|
130
|
+
- `redirect()` — middleware handles all redirects.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Route Handler template
|
|
2
|
+
|
|
3
|
+
Route Handlers under `src/app/api/**/route.ts` are the backend. Every one (except `/api/auth/[...nextauth]` and explicit public endpoints like `/api/health`) authenticates with `requireSession()`, validates inputs with the feature's Zod schema, and accesses the DB through the Prisma singleton.
|
|
4
|
+
|
|
5
|
+
## `src/app/api/{{feature}}s/route.ts` — list + create
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { NextResponse } from 'next/server';
|
|
9
|
+
import { db } from '@/lib/db';
|
|
10
|
+
import { requireSession, withApiErrors } from '@/lib/api-auth';
|
|
11
|
+
import { {{feature}}Schema } from '@/app/(app)/{{feature}}s/schema';
|
|
12
|
+
|
|
13
|
+
export const GET = withApiErrors(async () => {
|
|
14
|
+
const session = await requireSession();
|
|
15
|
+
|
|
16
|
+
const rows = await db.{{feature}}.findMany({
|
|
17
|
+
where: { userId: session.user.id },
|
|
18
|
+
orderBy: { createdAt: 'desc' },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return NextResponse.json(rows);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const POST = withApiErrors(async (req: Request) => {
|
|
25
|
+
const session = await requireSession();
|
|
26
|
+
|
|
27
|
+
const body = await req.json().catch(() => null);
|
|
28
|
+
const parsed = {{feature}}Schema.safeParse(body);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const created = await db.{{feature}}.create({
|
|
34
|
+
data: {
|
|
35
|
+
...parsed.data,
|
|
36
|
+
userId: session.user.id,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return NextResponse.json(created, { status: 201 });
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## `src/app/api/{{feature}}s/[id]/route.ts` — get + update + delete
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { NextResponse } from 'next/server';
|
|
48
|
+
import { db } from '@/lib/db';
|
|
49
|
+
import { requireSession, withApiErrors, assertOwnership } from '@/lib/api-auth';
|
|
50
|
+
import { {{feature}}Schema } from '@/app/(app)/{{feature}}s/schema';
|
|
51
|
+
|
|
52
|
+
interface RouteContext {
|
|
53
|
+
params: Promise<{ id: string }>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const GET = withApiErrors(async (_req: Request, ctx: RouteContext) => {
|
|
57
|
+
const session = await requireSession();
|
|
58
|
+
const { id } = await ctx.params;
|
|
59
|
+
|
|
60
|
+
const row = await db.{{feature}}.findFirst({
|
|
61
|
+
where: { id, userId: session.user.id },
|
|
62
|
+
});
|
|
63
|
+
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
64
|
+
|
|
65
|
+
return NextResponse.json(row);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const PATCH = withApiErrors(async (req: Request, ctx: RouteContext) => {
|
|
69
|
+
const session = await requireSession();
|
|
70
|
+
const { id } = await ctx.params;
|
|
71
|
+
|
|
72
|
+
const body = await req.json().catch(() => null);
|
|
73
|
+
const parsed = {{feature}}Schema.partial().safeParse(body);
|
|
74
|
+
if (!parsed.success) {
|
|
75
|
+
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Ownership check via findFirst + userId predicate.
|
|
79
|
+
const existing = await db.{{feature}}.findFirst({
|
|
80
|
+
where: { id, userId: session.user.id },
|
|
81
|
+
select: { id: true },
|
|
82
|
+
});
|
|
83
|
+
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
84
|
+
|
|
85
|
+
const updated = await db.{{feature}}.update({
|
|
86
|
+
where: { id },
|
|
87
|
+
data: parsed.data,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return NextResponse.json(updated);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const DELETE = withApiErrors(async (_req: Request, ctx: RouteContext) => {
|
|
94
|
+
const session = await requireSession();
|
|
95
|
+
const { id } = await ctx.params;
|
|
96
|
+
|
|
97
|
+
const existing = await db.{{feature}}.findFirst({
|
|
98
|
+
where: { id, userId: session.user.id },
|
|
99
|
+
select: { id: true },
|
|
100
|
+
});
|
|
101
|
+
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
102
|
+
|
|
103
|
+
await db.{{feature}}.delete({ where: { id } });
|
|
104
|
+
|
|
105
|
+
return new NextResponse(null, { status: 204 });
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Public handlers (e.g. health check)
|
|
110
|
+
|
|
111
|
+
The matcher in `middleware.ts` already excludes `/api/auth/*`. Other `/api/*` routes aren't redirected by middleware (they run their own `requireSession()`). A public handler simply omits the session check:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// src/app/api/health/route.ts
|
|
115
|
+
import { NextResponse } from 'next/server';
|
|
116
|
+
export const GET = () => NextResponse.json({ ok: true });
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Comment in the file so the absence of `requireSession()` is intentional, not an oversight.
|
|
120
|
+
|
|
121
|
+
## Forbidden
|
|
122
|
+
|
|
123
|
+
- **No `requireSession()` call** in any handler outside `/api/auth/*` and explicitly-public ones (and the public ones must comment why).
|
|
124
|
+
- **Trusting `userId` from the request body.** Always read it from the session: `session.user.id`.
|
|
125
|
+
- **Returning the full record on POST/PATCH without checking ownership first** on updates — the `where: { id, userId }` predicate is the gate.
|
|
126
|
+
- **`prisma.$queryRaw` with string concatenation.** Use parameterized template literals (`prisma.$queryRaw\`... ${value} ...\``) or stick to the typed query builder.
|
|
127
|
+
- **Catching and re-throwing without logging.** `withApiErrors` logs unexpected errors; if you write your own try/catch, log unexpected ones at least once.
|
|
128
|
+
- **Returning 500 with the raw error message.** Leaks stack traces. `withApiErrors` returns a generic message; if you write your own, do the same.
|
|
129
|
+
- **Long-running work in a handler.** Route Handlers run per-request. For background work, dispatch to a queue (the skill doesn't ship one — ask the user to pick).
|
|
130
|
+
- **`Set-Cookie` headers writing your own session cookie.** NextAuth owns the session cookie; setting a parallel one is the "custom JWT alongside NextAuth" anti-pattern.
|
|
131
|
+
|
|
132
|
+
## Why we import the schema from the feature folder
|
|
133
|
+
|
|
134
|
+
The form (`_components/{{Feature}}Form.tsx`) and the handler validate the same shape. If the handler had its own schema, drift would silently let the form post payloads the handler accepts but the form's UI hasn't accounted for — or vice versa. One Zod schema in `(app)/{{feature}}s/schema.ts` is the contract; both sides import it.
|
|
135
|
+
|
|
136
|
+
## Handler test (optional but recommended)
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// tests/unit/{{feature}}.handler.test.ts
|
|
140
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
141
|
+
|
|
142
|
+
vi.mock('@/auth', () => ({ auth: vi.fn() }));
|
|
143
|
+
vi.mock('@/lib/db', () => ({
|
|
144
|
+
db: { {{feature}}: { findMany: vi.fn(), create: vi.fn() } },
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
import { GET, POST } from '@/app/api/{{feature}}s/route';
|
|
148
|
+
import { auth } from '@/auth';
|
|
149
|
+
import { db } from '@/lib/db';
|
|
150
|
+
|
|
151
|
+
describe('GET /api/{{feature}}s', () => {
|
|
152
|
+
beforeEach(() => vi.clearAllMocks());
|
|
153
|
+
|
|
154
|
+
it('401s when unauthenticated', async () => {
|
|
155
|
+
(auth as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
|
156
|
+
const res = await GET();
|
|
157
|
+
expect(res.status).toBe(401);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns the current user's rows', async () => {
|
|
161
|
+
(auth as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ user: { id: 'u1' } });
|
|
162
|
+
(db.{{feature}}.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([{ id: 'r1', userId: 'u1' }]);
|
|
163
|
+
const res = await GET();
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
expect(await res.json()).toEqual([{ id: 'r1', userId: 'u1' }]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Testing setup (Vitest + RTL + Playwright)
|
|
2
2
|
|
|
3
|
-
A new project ships with
|
|
3
|
+
A new project ships with a small set of real tests: a Zod schema test, a Route Handler test, a component test, and an E2E auth spec. Just enough to make CI meaningful.
|
|
4
4
|
|
|
5
5
|
## `vitest.config.ts`
|
|
6
6
|
|
|
@@ -38,61 +38,123 @@ afterEach(() => {
|
|
|
38
38
|
});
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
## `tests/unit/
|
|
41
|
+
## `tests/unit/customer.schema.test.ts`
|
|
42
42
|
|
|
43
43
|
```ts
|
|
44
44
|
import { describe, expect, it } from 'vitest';
|
|
45
|
-
import {
|
|
45
|
+
import { customerSchema } from '@/app/(app)/customers/schema';
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
describe('customerSchema', () => {
|
|
48
|
+
it('accepts a valid payload', () => {
|
|
49
|
+
expect(customerSchema.safeParse({ name: 'Acme', email: 'ops@acme.test' }).success).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects an empty name', () => {
|
|
53
|
+
expect(customerSchema.safeParse({ name: '', email: 'ops@acme.test' }).success).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects an invalid email', () => {
|
|
57
|
+
expect(customerSchema.safeParse({ name: 'Acme', email: 'nope' }).success).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This is the test that pays for itself most often. The same schema validates form input *and* the Route Handler's request body, so regressions on either side fail here.
|
|
63
|
+
|
|
64
|
+
## `tests/unit/customers.handler.test.ts` (Route Handler test with mocked `auth` + `db`)
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
68
|
+
|
|
69
|
+
vi.mock('@/auth', () => ({ auth: vi.fn() }));
|
|
70
|
+
vi.mock('@/lib/db', () => ({
|
|
71
|
+
db: {
|
|
72
|
+
customer: {
|
|
73
|
+
findMany: vi.fn(),
|
|
74
|
+
create: vi.fn(),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
48
78
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
79
|
+
import { GET, POST } from '@/app/api/customers/route';
|
|
80
|
+
import { auth } from '@/auth';
|
|
81
|
+
import { db } from '@/lib/db';
|
|
82
|
+
|
|
83
|
+
const mockAuth = auth as ReturnType<typeof vi.fn>;
|
|
84
|
+
const mockFindMany = db.customer.findMany as ReturnType<typeof vi.fn>;
|
|
85
|
+
const mockCreate = db.customer.create as ReturnType<typeof vi.fn>;
|
|
86
|
+
|
|
87
|
+
describe('GET /api/customers', () => {
|
|
88
|
+
beforeEach(() => vi.clearAllMocks());
|
|
89
|
+
|
|
90
|
+
it('401s when unauthenticated', async () => {
|
|
91
|
+
mockAuth.mockResolvedValueOnce(null);
|
|
92
|
+
const res = await GET();
|
|
93
|
+
expect(res.status).toBe(401);
|
|
53
94
|
});
|
|
54
95
|
|
|
55
|
-
it('
|
|
56
|
-
|
|
57
|
-
|
|
96
|
+
it('returns the current user\'s customers', async () => {
|
|
97
|
+
mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
|
|
98
|
+
mockFindMany.mockResolvedValueOnce([{ id: 'c1', userId: 'u1', name: 'Acme' }]);
|
|
99
|
+
const res = await GET();
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
expect(await res.json()).toEqual([{ id: 'c1', userId: 'u1', name: 'Acme' }]);
|
|
58
102
|
});
|
|
103
|
+
});
|
|
59
104
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
105
|
+
describe('POST /api/customers', () => {
|
|
106
|
+
beforeEach(() => vi.clearAllMocks());
|
|
107
|
+
|
|
108
|
+
it('400s on an invalid payload', async () => {
|
|
109
|
+
mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
|
|
110
|
+
const req = new Request('http://x/api/customers', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
body: JSON.stringify({ name: '', email: 'nope' }),
|
|
113
|
+
});
|
|
114
|
+
const res = await POST(req);
|
|
115
|
+
expect(res.status).toBe(400);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('attaches the session userId to the created row', async () => {
|
|
119
|
+
mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
|
|
120
|
+
mockCreate.mockResolvedValueOnce({ id: 'c2', userId: 'u1', name: 'Acme', email: 'ops@acme.test' });
|
|
121
|
+
const req = new Request('http://x/api/customers', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: JSON.stringify({ name: 'Acme', email: 'ops@acme.test' }),
|
|
124
|
+
});
|
|
125
|
+
await POST(req);
|
|
126
|
+
expect(mockCreate).toHaveBeenCalledWith({
|
|
127
|
+
data: { name: 'Acme', email: 'ops@acme.test', userId: 'u1' },
|
|
128
|
+
});
|
|
63
129
|
});
|
|
64
130
|
});
|
|
65
131
|
```
|
|
66
132
|
|
|
133
|
+
This test catches three common regressions: missing `requireSession`, body validation that silently accepts garbage, and userId tampering.
|
|
134
|
+
|
|
67
135
|
## `tests/unit/login-form.test.tsx`
|
|
68
136
|
|
|
69
137
|
```tsx
|
|
70
|
-
import { describe, expect, it } from 'vitest';
|
|
138
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
71
139
|
import { render, screen } from '@testing-library/react';
|
|
72
140
|
import userEvent from '@testing-library/user-event';
|
|
73
|
-
import { Provider } from 'react-redux';
|
|
74
|
-
import { configureStore } from '@reduxjs/toolkit';
|
|
75
|
-
import { api } from '@/redux/api/api';
|
|
76
|
-
import { authSlice } from '@/redux/features/authSlice';
|
|
77
141
|
import { LoginForm } from '@/app/(public)/auth/login/_components/LoginForm';
|
|
78
142
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
middleware: (gdm) => gdm().concat(api.middleware),
|
|
83
|
-
});
|
|
84
|
-
return render(<Provider store={store}>{ui}</Provider>);
|
|
85
|
-
}
|
|
143
|
+
vi.mock('next-auth/react', () => ({
|
|
144
|
+
signIn: vi.fn().mockResolvedValue({ ok: true, error: null }),
|
|
145
|
+
}));
|
|
86
146
|
|
|
87
147
|
describe('<LoginForm />', () => {
|
|
88
|
-
it('shows a validation error
|
|
89
|
-
|
|
148
|
+
it('shows a validation error when fields are empty', async () => {
|
|
149
|
+
render(<LoginForm />);
|
|
90
150
|
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
|
91
151
|
expect(await screen.findByText(/required|invalid/i)).toBeInTheDocument();
|
|
92
152
|
});
|
|
93
153
|
});
|
|
94
154
|
```
|
|
95
155
|
|
|
156
|
+
Mocking `next-auth/react` lets the form test run in isolation — we're testing the form, not NextAuth.
|
|
157
|
+
|
|
96
158
|
## `playwright.config.ts`
|
|
97
159
|
|
|
98
160
|
```ts
|
|
@@ -124,14 +186,26 @@ export default defineConfig({
|
|
|
124
186
|
import { test, expect } from '@playwright/test';
|
|
125
187
|
|
|
126
188
|
test('unauthenticated user is redirected to login', async ({ page }) => {
|
|
127
|
-
await page.goto('/dashboard');
|
|
189
|
+
await page.goto('/app/dashboard');
|
|
128
190
|
await expect(page).toHaveURL(/\/auth\/login/);
|
|
129
191
|
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
|
130
192
|
});
|
|
193
|
+
|
|
194
|
+
// Requires `pnpm db:seed` to have run — provides test@example.com / test1234.
|
|
195
|
+
test('seeded user can sign in and land on the dashboard', async ({ page }) => {
|
|
196
|
+
await page.goto('/auth/login');
|
|
197
|
+
await page.getByLabel(/email/i).fill('test@example.com');
|
|
198
|
+
await page.getByLabel(/password/i).fill('test1234');
|
|
199
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
200
|
+
await expect(page).toHaveURL(/\/app\/dashboard/);
|
|
201
|
+
});
|
|
131
202
|
```
|
|
132
203
|
|
|
133
|
-
|
|
204
|
+
Together those two specs validate: middleware fires, the login page renders, NextAuth's Credentials provider works against the seeded user, and the route-group split holds.
|
|
134
205
|
|
|
135
206
|
**Notes:**
|
|
136
|
-
|
|
207
|
+
|
|
208
|
+
- Do not include Playwright in the default `pnpm test` script — keep it as `pnpm test:e2e`.
|
|
209
|
+
- The seed-then-login test requires the CI workflow's `pnpm db:seed` step (see [`ci-and-hooks.md`](ci-and-hooks.md)).
|
|
137
210
|
- Real backend mocking (MSW) is *not* in the default scaffold. Add only if the user asks.
|
|
211
|
+
- **Forbidden:** an `authSlice.test.ts`. There is no `authSlice` in this project — NextAuth owns session state. If you see a test importing `@/redux/features/authSlice`, delete it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plan-and-build
|
|
3
|
-
description: Plan-first feature builder. Grills the user about the feature until the design is unambiguous, presents a plan, gates on `ExitPlanMode` approval, then builds the feature using the project's existing patterns — TDD-first with NUnit when the work touches a .NET API (appending to the matching test class if one already exists), and writing migration files but never executing SQL or `dotnet ef database update`. Use this skill whenever the user says "build a feature", "add a feature", "implement this feature", "/plan-and-build", "plan and build", "new feature", or pastes a feature spec and asks you to design + implement it — even if they don't name the skill.
|
|
3
|
+
description: Plan-first feature builder. Grills the user about the feature until the design is unambiguous, presents a plan, gates on `ExitPlanMode` approval, then builds the feature using the project's existing patterns — TDD-first with NUnit when the work touches a .NET API (appending to the matching test class if one already exists), and writing migration files but never executing SQL or `dotnet ef database update`. When Phase 3 hits a genuine design fork (e.g. service-layer vs. CQRS handler, sync vs. async, new table vs. nullable columns), runs **Design It Twice**: two parallel sub-agents each draft the strongest plan for one approach, then a debate round names the trade-offs and recommends one — the user sees a recommended plan with the rejected alternative noted so they can redirect. Skipped when an existing pattern in the repo already dictates the approach. Use this skill whenever the user says "build a feature", "add a feature", "implement this feature", "/plan-and-build", "plan and build", "new feature", or pastes a feature spec and asks you to design + implement it — even if they don't name the skill.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plan and Build
|
|
@@ -66,9 +66,43 @@ Before designing, look at what the repo already does so the plan reuses it. Run
|
|
|
66
66
|
|
|
67
67
|
Record what you find. The plan will reference these directly so the user can see you're not inventing new patterns.
|
|
68
68
|
|
|
69
|
+
**Library API lookup — use `context7` when available.** If the plan needs an API from a third-party library or framework that's pinned in this repo (e.g. EF Core 8, Next.js 15, Prisma 5, NextAuth v5, Stripe SDK), check `context7` (or any docs-MCP server in the environment) for the *current* docs before drafting the plan: `context7__resolve-library-id` → `context7__query-docs` for the specific topic. Training-data API knowledge can be a major version behind, and a plan built on the wrong API shape wastes a Plan Mode round. Skip the lookup for libraries the user has already named in this session or for general programming concepts — only reach for it when the plan depends on a specific library symbol or pattern.
|
|
70
|
+
|
|
69
71
|
### Phase 3 — Plan
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
#### Decision gate: single plan vs. Design It Twice
|
|
74
|
+
|
|
75
|
+
Before drafting, ask: **is there a genuine design fork here that the existing codebase doesn't already resolve?**
|
|
76
|
+
|
|
77
|
+
- **Single plan** — when Phase 2 found a clear precedent (e.g. every other feature in this codebase uses CQRS handlers, or the project's slice template dictates the layout). Draft one plan and proceed.
|
|
78
|
+
- **Design It Twice** — when there's a real choice with real trade-offs, and Phase 2 didn't settle it. Typical forks:
|
|
79
|
+
- Service-layer method vs. CQRS handler
|
|
80
|
+
- New table vs. nullable columns on an existing table
|
|
81
|
+
- Sync request/response vs. background job + status endpoint
|
|
82
|
+
- In-process event vs. message-queue publish
|
|
83
|
+
- REST endpoint vs. WebSocket / SSE
|
|
84
|
+
- Lifting state up vs. introducing a context/store
|
|
85
|
+
- One denormalised read model vs. join at query time
|
|
86
|
+
|
|
87
|
+
Don't manufacture a fork to perform ceremony. If the answer is clear, the single plan is faster *and* better.
|
|
88
|
+
|
|
89
|
+
#### Design It Twice (when convened)
|
|
90
|
+
|
|
91
|
+
1. **Name the axis.** State the single design choice the two plans disagree on in one sentence (e.g. *"Add an `OrderAuditLog` table vs. extend `Orders` with `priority_set_by_user_id` + `priority_set_at`."*). If you can't name the axis cleanly, you don't have a genuine fork — drop to single plan.
|
|
92
|
+
2. **Spawn two planners in parallel.** Send a **single message** with 2 `Agent` calls (general-purpose). Each gets:
|
|
93
|
+
- Phase 1 grilling outputs (verbatim, including the canonical terminology).
|
|
94
|
+
- Phase 2 stack and convention findings (verbatim).
|
|
95
|
+
- The named axis and **one** side of it to defend.
|
|
96
|
+
- Instructions: "Draft the BEST plan for your assigned approach. Include the seven items below ([Plan contents](#plan-contents)). Defend why this approach beats the alternative *on this codebase*. Where you must invent a new pattern (no Phase 2 precedent), justify it. Report in ≤700 words."
|
|
97
|
+
3. **Debate round.** When both plans return, write inline:
|
|
98
|
+
- **3 things Plan A does better than Plan B.**
|
|
99
|
+
- **3 things Plan B does better than Plan A.**
|
|
100
|
+
- **The recommendation** (one paragraph) — which plan, and why it wins on this codebase. Cite the Phase 1 / Phase 2 evidence that broke the tie.
|
|
101
|
+
4. **Pick the recommended plan** as the one to present. Carry forward only the winning plan's contents — but keep a one-line note of the rejected alternative for the user.
|
|
102
|
+
|
|
103
|
+
#### Plan contents
|
|
104
|
+
|
|
105
|
+
Whether you drafted one plan or chose between two, the presented plan must include:
|
|
72
106
|
|
|
73
107
|
1. **Restatement of the feature** in one short paragraph, using the project's canonical terminology from `CONTEXT.md` (if present).
|
|
74
108
|
2. **Files to create**, grouped by layer, each with a one-line purpose.
|
|
@@ -82,7 +116,11 @@ Enter Plan Mode (`EnterPlanMode`). The plan must include:
|
|
|
82
116
|
6. **Pattern reuse table.** Two columns: "New code I'm about to write" and "Existing example it follows" with a path. If a row has no existing example, justify the new pattern.
|
|
83
117
|
7. **Open questions** still unresolved, if any. If there are any, loop back to Phase 1 — do not exit plan mode with open questions.
|
|
84
118
|
|
|
85
|
-
|
|
119
|
+
If Design It Twice ran, end the plan with: **"Considered alternative: `<approach B name>`. Rejected because `<one-line reason>`."** This lets the user redirect to B with a single sentence if they disagree.
|
|
120
|
+
|
|
121
|
+
#### Enter and exit plan mode
|
|
122
|
+
|
|
123
|
+
Enter Plan Mode (`EnterPlanMode`) to present. Then exit with `ExitPlanMode` and wait. **Do not write a single file until the user approves the plan.** If the user pushes back — including "actually let's go with the alternative" — revise and re-present; don't half-implement against an unapproved plan.
|
|
86
124
|
|
|
87
125
|
### Phase 4 — Build (test-first when an API changes)
|
|
88
126
|
|
|
@@ -173,6 +211,10 @@ When appending:
|
|
|
173
211
|
- ❌ Writing production code before the test it makes pass (when the feature touches an API).
|
|
174
212
|
- ❌ Creating `FooServiceTests2.cs` because `FooServiceTests.cs` already exists. Always append.
|
|
175
213
|
- ❌ Inventing a new pattern for something the codebase already has a pattern for. If you can't find the precedent, ask before forking.
|
|
214
|
+
- ❌ Running Design It Twice when the codebase already dictates the approach. Ceremony, not value.
|
|
215
|
+
- ❌ Manufacturing a fake axis to perform a debate. If you can't name the axis in one sentence, you don't have a fork.
|
|
216
|
+
- ❌ Spawning the two planners serially instead of in parallel — one message, two agents.
|
|
217
|
+
- ❌ Presenting both plans to the user. The user sees the recommended plan plus a one-line "considered alternative" note — not two full plans to grade.
|
|
176
218
|
- ❌ Running `dotnet ef database update`, `prisma migrate deploy`, or any DDL against the user's DB.
|
|
177
219
|
- ❌ Running ad-hoc `SELECT` / `UPDATE` queries against the user's DB to "check something" — ask the user instead.
|
|
178
220
|
- ❌ Block comments and "what this does" comments. Names should do that work.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pr-review
|
|
3
|
-
description: Conduct a thorough, structured code review of a local branch, grouped by task number (#NNN) referenced in commit messages. Use this skill whenever the user asks to review a PR, asks for feedback on a diff or branch, mentions "review my changes", or pastes code asking what could be improved. Auto-detects all tasks on the branch and produces one verdict per task. Covers correctness, design, tests, security, performance, and readability in that priority order.
|
|
3
|
+
description: Conduct a thorough, structured code review of a local branch, grouped by task number (#NNN) referenced in commit messages. On non-trivial tasks, convenes a **per-task lens council** — parallel `Explore` sub-agents reviewing through distinct lenses (correctness / design / security / tests) — then an adversarial critique round that challenges each lens's blocking flags against context from the others, demoting false positives and surfacing contradictions for the user. Small tasks skip the council. Use this skill whenever the user asks to review a PR, asks for feedback on a diff or branch, mentions "review my changes", or pastes code asking what could be improved. Auto-detects all tasks on the branch and produces one verdict per task. Covers correctness, design, tests, security, performance, and readability in that priority order.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Pull Request Review
|
|
@@ -36,8 +36,10 @@ Review in this order — earlier categories matter more, and finding issues ther
|
|
|
36
36
|
4. **For each task, read its intent first.** What is `#123` trying to accomplish? Infer from the commit subjects/bodies in that bucket. If intent is unclear, ask the user before reviewing — "is the code correct" is unanswerable without it.
|
|
37
37
|
5. **Get the per-task diff.** For each task, view its commits with `git show <sha>` per commit, or `git diff <first>^..<last>` if the commits are contiguous. Don't merge tasks into one combined diff — keep them scoped.
|
|
38
38
|
6. **Skim each task once, top to bottom.** Get a mental model of what changed before commenting on specifics.
|
|
39
|
-
7. **
|
|
40
|
-
|
|
39
|
+
7. **Per-task decision: single-pass vs. lens council** (see [Lens council](#lens-council-for-non-trivial-tasks)):
|
|
40
|
+
- **Single-pass (inline)** when the task is small and tightly scoped — roughly: < 100 lines of task-scoped diff, < 5 files, no security-sensitive paths. Walk the priority list yourself.
|
|
41
|
+
- **Lens council** otherwise. Spawn parallel `Explore` sub-agents per lens, then run a critique round before the per-task verdict.
|
|
42
|
+
8. **Look at the tests in each task.** Do they test the new behavior or just exercise the new lines? (The lens council's Tests agent handles this when convened.)
|
|
41
43
|
9. **Check what's *not* in each task's diff.** Missing error handling, missing tests for edge cases, missing migration for a schema change.
|
|
42
44
|
|
|
43
45
|
## How to phrase feedback
|
|
@@ -85,6 +87,46 @@ Lead with the *why*, not just the *what*. "This will deadlock if two callers hit
|
|
|
85
87
|
- Synchronous I/O in hot paths
|
|
86
88
|
- Repeated work that could be hoisted out of a loop
|
|
87
89
|
|
|
90
|
+
## Lens council (for non-trivial tasks)
|
|
91
|
+
|
|
92
|
+
A single chain of reasoning across all priority categories anchors on whatever was seen first. For non-trivial tasks, run the lenses **in parallel** per task, then critique before the verdict.
|
|
93
|
+
|
|
94
|
+
### When to convene the council (per task)
|
|
95
|
+
|
|
96
|
+
- The task's diff is ≥ ~100 lines, **or** touches ≥ 5 files, **or** edits security-sensitive paths (auth, crypto, input validation, SQL/shell/HTML sinks, file paths from user input).
|
|
97
|
+
- Or first-skim turned up contradictions you can't resolve from memory.
|
|
98
|
+
|
|
99
|
+
Otherwise stay single-pass for that task. Some branches have one trivial task and one huge one — convene only for the huge one.
|
|
100
|
+
|
|
101
|
+
### Lenses
|
|
102
|
+
|
|
103
|
+
Spawn one sub-agent per lens for the task being reviewed. Default set:
|
|
104
|
+
|
|
105
|
+
| Lens | Looks for |
|
|
106
|
+
|---|---|
|
|
107
|
+
| **Correctness** | Off-by-one, null/undefined at boundaries, concurrent-access bugs, swallowed exceptions, default-value behaviour shifts |
|
|
108
|
+
| **Design** | New abstractions that don't earn their keep, tight coupling, duplication that should unify, public-API breakage, state in the wrong place |
|
|
109
|
+
| **Security** | Untrusted input into sinks, secrets in code/logs/errors, missing authn/authz on new endpoints, weak/hand-rolled crypto |
|
|
110
|
+
| **Tests** | Assertion-free / over-mocked tests, missing edge cases, flaky-by-design tests, new behaviour in the diff with **no** new test |
|
|
111
|
+
|
|
112
|
+
Performance + Readability usually fold into Design — only spawn a dedicated lens for them if the task is genuinely perf-sensitive or the project has no linter.
|
|
113
|
+
|
|
114
|
+
### How to run it (per task)
|
|
115
|
+
|
|
116
|
+
1. **Spawn in parallel.** Send a **single message** with N `Agent` calls (`subagent_type=Explore`), one per lens. Each gets:
|
|
117
|
+
- The per-task diff (commits from this `#NNN` only — not the whole branch).
|
|
118
|
+
- The inferred task intent from step 4.
|
|
119
|
+
- **One** lens with its checklist verbatim from [What to look for](#what-to-look-for).
|
|
120
|
+
- Instructions: "Find issues only in your lens. Categorize each as `blocking` / `suggestion` / `question` / `nit`. Cite `file:line` for every finding. Lead each with the *why*. If no findings, say 'no findings' explicitly. Report in ≤500 words."
|
|
121
|
+
2. **Critique round.** Read all lenses side by side, then:
|
|
122
|
+
- **Challenge every `blocking`** against context from the other lenses. Demote to `suggestion` or drop if it's defused by another lens's evidence.
|
|
123
|
+
- **Surface contradictions** as `question` items the user adjudicates (e.g. Correctness flags missing null guard; Security found the caller validates).
|
|
124
|
+
- **Merge near-duplicates** across lenses.
|
|
125
|
+
- **Promote** any finding multiple lenses raised independently — that's a real signal.
|
|
126
|
+
3. **Verdict.** With the surviving findings, decide: Approve / Approve with suggestions / Request changes / Comment.
|
|
127
|
+
|
|
128
|
+
Each task gets its own council pass. Tasks remain independent — the council scope **never** crosses task boundaries.
|
|
129
|
+
|
|
88
130
|
## Output format
|
|
89
131
|
|
|
90
132
|
Produce one section per task, with comments grouped by file and line inside each. Each task gets its own verdict — tasks are independent units of work and should be approvable or blockable on their own.
|
|
@@ -130,3 +172,8 @@ End with a one-line roll-up: e.g. `Overall: 2 approve, 1 request changes, 1 unsc
|
|
|
130
172
|
- ❌ "I would have done it differently" without a concrete reason
|
|
131
173
|
- ❌ Collapsing multiple tasks into one verdict — each `#NNN` is independent and should stand or fall on its own
|
|
132
174
|
- ❌ Silently ignoring commits with no task reference — surface them in the `unscoped` bucket
|
|
175
|
+
- ❌ Convening the lens council on a 20-line task. Single-pass it.
|
|
176
|
+
- ❌ Spawning lens agents serially instead of in parallel — one message, N agents, per task.
|
|
177
|
+
- ❌ Letting the council span tasks. Each `#NNN` is its own review — sub-agents see only that task's diff.
|
|
178
|
+
- ❌ Publishing the raw union of lens findings without the critique round. False-positive blockers erode trust.
|
|
179
|
+
- ❌ Dumping per-agent transcripts into the user's report. The council is internal deliberation — the user sees the synthesized per-task verdict.
|
|
@@ -191,6 +191,7 @@ Full templates are in [`references/templates/`](references/templates/). The full
|
|
|
191
191
|
- **`tauri.conf.json` `windows[].devtools: false`** (or omitted — Tauri defaults to off in release). DevTools should be opened from a build-time feature flag, not the production config.
|
|
192
192
|
- **Reset-state-on-startup pattern** — in `pub fn run()`'s `.setup(|app| { ... })`, clear any in-progress / stuck state from a previous run before showing the window. Crashes leave globals in odd places; treat each startup as recovering from "the app was force-quit".
|
|
193
193
|
- **Show window FIRST in `.setup()`** before any blocking initialization. Heavy work (ML model loading, large config parses) goes on a background thread that emits events back to the frontend.
|
|
194
|
+
- **Minimal comments in generated code (Rust and TS).** Default to no comments. Only add one when the *why* is non-obvious — `// SAFETY:` on an `unsafe` block, a workaround for a specific upstream bug (with a link), a non-trivial invariant the code depends on, a platform quirk that isn't visible from the names. Never write Rust doc-comment blocks (`///`) on internal items; reserve them for genuinely public API surface (`pub` types crossing crate boundaries). Never restate *what* the next line does, never leave `// TODO` without an issue link. One short line max — no multi-line comment blocks. `// SAFETY:` on `unsafe` is the one place verbose justification is *required*; everywhere else, well-named identifiers carry the *what* and comments earn their place only when they carry *why*.
|
|
194
195
|
|
|
195
196
|
### Eliminate (anti-patterns)
|
|
196
197
|
|