DEV Community

Olivia Craft
Olivia Craft

Posted on

How I Automated My Entire Dev Workflow With 8 Cursor Rules (and What Changed)

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.
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Before: My git log was a graveyard of update, fix bug, and wip.

* wip
* fix
* update stuff
* more changes
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`.
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)