How I Automated My Entire Dev Workflow With 8 Cursor Rules (and What Changed)
It was 11:47 PM on a Tuesday when I realized I'd just spent forty minutes fixing a bug that Cursor had written for me that morning.
The AI had happily generated a fetch() call with no error handling, no types on the response, a stray console.log('here') left in for good measure, and a commit message that read fixes stuff. Every one of those choices was fine in the moment. Every one of those choices cost me an hour at midnight.
That's when I stopped prompting and started writing rules.
Over the next few weeks I replaced the nagging, the re-prompting, and the "please remember we use TypeScript strict" with eight entries in my .cursorrules file. My workflow stopped being something I executed and became something the AI executed for me — correctly, every time.
Here are the eight rules, with concrete before/after examples and what changed once they were in place.
Rule 1: Test-First — Write the Failing Test Before the Code
Before implementing any function, write a failing test for it first.
Place the test next to the source file as *.test.ts. Only then
write the minimum code needed to make the test pass.
Before: I'd accept a 40-line function, then try to retrofit tests around its actual (often wrong) behavior.
// Cursor's default: implementation first, tests never
export function parseDuration(input: string): number {
// ...40 lines of regex and edge cases, no tests
}
After: Cursor generates the test first, which forces it to clarify the contract before writing any logic.
// parseDuration.test.ts — written first
describe('parseDuration', () => {
it('parses "2h 30m" as 9000 seconds', () => {
expect(parseDuration('2h 30m')).toBe(9000);
});
it('throws on invalid input', () => {
expect(() => parseDuration('banana')).toThrow();
});
});
// parseDuration.ts — written to pass the tests
export function parseDuration(input: string): number {
// minimal implementation, every branch covered
}
One rule, zero retrofitted tests.
Rule 2: Conventional Commits — Never Commit Without a Type
Every commit message must follow Conventional Commits:
<type>(<scope>): <description>. Types: feat, fix, refactor, test,
docs, chore, perf. Keep the subject under 72 characters. Add a body
for any non-trivial change.
Before: My git log was a graveyard of update, fix bug, and wip.
* wip
* fix
* update stuff
* more changes
After: Every commit tells me what it does without opening the diff.
* feat(auth): add refresh token rotation
* fix(billing): prevent double charge on retry
* refactor(api): extract error middleware
* test(auth): cover expired-token edge case
Semantic-release picks up the types, my changelog writes itself, and I can actually git bisect because every commit means something.
Rule 3: No console.log in Committed Code — Use a Logger
Never use console.log, console.error, or console.warn in committed
code. Import the project logger (e.g., `import { logger } from
'@/lib/logger'`) and use logger.info / logger.error with a structured
context object. Debug prints must be removed before the commit.
Before: Prod logs were a graffiti wall of console.log('here'), console.log('hmm'), and the occasional unredacted API key.
console.log('user', user); // leaks PII
console.log('token', token); // leaks secrets
console.log('here'); // leaks my sanity
After: Structured logs, every time, with levels that actually mean something.
logger.info('user.login', { userId: user.id, method: 'oauth' });
logger.error('payment.failed', { orderId, error: err.message });
Datadog queries work. PagerDuty alerts work. I stopped getting paged at 3 AM by logs I couldn't search.
Rule 4: Typed Errors at Every Async Boundary
Every async function that can fail must have explicit try/catch and
return a discriminated union: { ok: true, data: T } | { ok: false,
error: AppError }. Never let unhandled rejections propagate. AppError
must have a `code` field for programmatic handling.
Before: Errors vanished into .catch(console.error) and reappeared as user-facing white screens.
async function charge(amount: number) {
const res = await stripe.charges.create({ amount });
return res; // what if stripe throws? who knows
}
After: Every call site is forced to handle both branches.
async function charge(amount: number): Promise<Result<Charge>> {
try {
const res = await stripe.charges.create({ amount });
return { ok: true, data: res };
} catch (err) {
return { ok: false, error: { code: 'STRIPE_FAILED', cause: err } };
}
}
const result = await charge(1999);
if (!result.ok) return handleChargeError(result.error);
// TypeScript now knows result.data exists
The compiler caught three unhandled error paths the day I added this rule.
Rule 5: Config via a Typed Module — Never Touch process.env Directly
Never access process.env directly outside of src/config/env.ts.
That file must validate all env vars with zod at startup and export
a typed `env` object. All other code imports `env` and gets
autocomplete + runtime guarantees.
Before: Every file reached into process.env like it was a shared snack drawer.
const apiKey = process.env.STRIPE_KEY; // string | undefined, maybe?
stripe.setApiKey(apiKey!); // the ! is doing a lot of work
After: One source of truth, validated on boot.
// src/config/env.ts
import { z } from 'zod';
export const env = z.object({
STRIPE_KEY: z.string().min(1),
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'test', 'production']),
}).parse(process.env);
// everywhere else:
import { env } from '@/config/env';
stripe.setApiKey(env.STRIPE_KEY); // typed, non-null, validated
The app now refuses to start if an env var is missing. No more "works on my machine" outages.
Rule 6: One File, One Purpose — 200 Lines Max
A file must have a single responsibility. Max 200 lines. If a file
grows past 200 lines, split it by responsibility — not by arbitrary
chunks. Co-locate related files in a folder rather than stacking
them into one mega-module.
Before: utils.ts was 900 lines of unrelated functions, and every new utility got dumped into it.
src/
utils.ts ← 900 lines, 47 exports, nobody knows what's in it
After: Cursor refuses to grow files past the limit and proposes a folder split instead.
src/
utils/
date/
formatDate.ts
parseDuration.ts
string/
slugify.ts
truncate.ts
money/
formatCents.ts
Every file fits on one screen. Reviewers can actually read PRs.
Rule 7: Validate at the Boundary — Every API Input Through a Schema
Every HTTP handler must validate its input (body, query, params) with
a zod schema before any business logic runs. Reject invalid input
with a 400 and a structured error. Never trust `req.body`.
Before: Business logic was defensive-coded against missing fields, wrong types, and whatever else the internet threw at it.
app.post('/api/orders', async (req, res) => {
const { items, userId } = req.body; // any? undefined? an attack?
if (!items) return res.status(400).send('bad');
if (!Array.isArray(items)) return res.status(400).send('bad');
// ...20 more lines of manual validation
});
After: One schema, one parse call, typed body downstream.
const CreateOrderBody = z.object({
userId: z.string().uuid(),
items: z.array(z.object({
sku: z.string(),
quantity: z.number().int().positive(),
})).min(1),
});
app.post('/api/orders', async (req, res) => {
const body = CreateOrderBody.parse(req.body); // throws → 400 middleware
await createOrder(body); // body is fully typed here
res.status(201).end();
});
SQL injection attempts, missing fields, and the intern sending quantity: -1 all die at the door.
Rule 8: The PR-Ready Checklist — Nothing Ships Without It
Before declaring any task complete, Cursor must run and report:
1. Typecheck passes (npm run typecheck)
2. Tests pass (npm test)
3. Lint passes (npm run lint)
4. No new `any` types introduced
5. No `console.log` in committed code
If any step fails, fix it before claiming the task is done.
Before: "Done" meant "the code compiles on my branch," and CI caught it three hours later.
me: "done!"
CI: ❌ 4 type errors, 12 lint warnings, 1 failing test
me: (fixing for the next hour)
After: Cursor self-checks before handing the PR back.
✓ typecheck (0 errors)
✓ tests (142 passed)
✓ lint (0 warnings)
✓ no new `any` types
✓ no console.log
Ready for review.
My CI pipeline now passes on the first try about 90% of the time. The other 10% is usually a flaky integration test, not a mistake I made.
What Actually Changed
Four weeks after these rules went live, I measured.
- Speed. Feature work that used to take a day now takes a morning. Not because Cursor is faster — it was always fast — but because I stopped rewriting its first draft.
- Consistency. Every file in the repo looks like it was written by the same person. Because effectively, it was.
- Bugs caught. The typed-error rule alone caught 3 unhandled promise rejections in the first week. The env-validation rule caught 2 missing config values before they hit staging. The test-first rule caught a dozen off-by-one errors I would have merged otherwise.
- Review fatigue. My PR review comments dropped by maybe 70%. The nits — missing types, stray logs, inconsistent commits — just don't appear anymore.
The meta-lesson: prompting is a loop. Rules are a state machine. Every rule you write is one conversation you never have to repeat.
Copy-Paste Starter: All 8 Rules
Drop this into .cursorrules or .cursor/rules/workflow.mdc:
# Workflow Automation Rules
## Testing
- Write a failing test before any implementation
- Tests live next to source as *.test.ts
## Commits
- Conventional Commits format: <type>(<scope>): <description>
- Subject under 72 chars; body for non-trivial changes
## Logging
- Never use console.log/error/warn in committed code
- Use the project logger with structured context
## Errors
- Every async function returns { ok: true, data } | { ok: false, error }
- AppError must have a `code` field
- No unhandled promise rejections
## Config
- Never access process.env outside src/config/env.ts
- Validate all env vars with zod at startup
## File Structure
- One responsibility per file
- Max 200 lines; split by responsibility when exceeded
## Input Validation
- Every HTTP handler validates input with zod before logic runs
- Never trust req.body/query/params
## PR-Ready
- Before declaring done: typecheck + tests + lint must pass
- No new `any` types; no console.log
Want the Full Set?
These 8 rules are the workflow layer. The rest of my stack — React, TypeScript, Next.js, Prisma, testing patterns — runs on 50+ more rules I've built out over the last year.
I packaged them into the Cursor Rules Pack v2 — organized by framework, priority-tagged, and production-tested. If you've ever felt like you're re-prompting Cursor for the same fixes every day, this is the file that stops it.
Stop prompting. Start ruling.
Top comments (0)