From dfee5189960809f6bbeb4852be4ff3abc5b398f3 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:40:39 +0200 Subject: [PATCH] lib: idea-code generator + plan_md yaml-frontmatter parser (M12 T-494) - lib/idea-code.ts: pure formatIdeaCode helper (client-safe, no prisma) - lib/idea-code-server.ts: atomic nextIdeaCode via Prisma row-lock, accepts optional TransactionClient for combining with idea.create - lib/idea-plan-parser.ts: parsePlanMd extracts ---yaml---/body, runs yaml.parse + ideaPlanMdFrontmatterSchema, returns line-info on failure; CRLF-tolerant - adds yaml@^2.8.4 dependency - 8 unit tests (parser happy/missing/yaml-error/zod-error/empty-stories/CRLF; formatIdeaCode pad-3 + overflow) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/idea-code.test.ts | 21 +++++ __tests__/lib/idea-plan-parser.test.ts | 103 +++++++++++++++++++++++++ lib/idea-code-server.ts | 26 +++++++ lib/idea-code.ts | 8 ++ lib/idea-plan-parser.ts | 73 ++++++++++++++++++ package-lock.json | 8 +- package.json | 1 + 7 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 __tests__/lib/idea-code.test.ts create mode 100644 __tests__/lib/idea-plan-parser.test.ts create mode 100644 lib/idea-code-server.ts create mode 100644 lib/idea-code.ts create mode 100644 lib/idea-plan-parser.ts diff --git a/__tests__/lib/idea-code.test.ts b/__tests__/lib/idea-code.test.ts new file mode 100644 index 0000000..f0a9150 --- /dev/null +++ b/__tests__/lib/idea-code.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' + +import { formatIdeaCode } from '@/lib/idea-code' + +describe('formatIdeaCode', () => { + it('pads to 3 digits', () => { + expect(formatIdeaCode(1)).toBe('IDEA-001') + expect(formatIdeaCode(42)).toBe('IDEA-042') + expect(formatIdeaCode(999)).toBe('IDEA-999') + }) + + it('does not truncate beyond pad-width', () => { + expect(formatIdeaCode(1000)).toBe('IDEA-1000') + expect(formatIdeaCode(99999)).toBe('IDEA-99999') + }) +}) + +// Integration-style concurrency-test op nextIdeaCode is in +// __tests__/integration/ tests die de echte DB raken (zie M12 verificatie-stap). +// Hier alleen de pure formatter; de increment-logica leunt op Prisma's +// row-lock in $transaction die we per-database vertrouwen. diff --git a/__tests__/lib/idea-plan-parser.test.ts b/__tests__/lib/idea-plan-parser.test.ts new file mode 100644 index 0000000..30169aa --- /dev/null +++ b/__tests__/lib/idea-plan-parser.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest' + +import { parsePlanMd } from '@/lib/idea-plan-parser' + +const VALID = `--- +pbi: + title: Test PBI + priority: 2 +stories: + - title: Eerste flow + priority: 2 + tasks: + - title: Setup + priority: 2 + implementation_plan: | + 1. Doe X + 2. Doe Y +--- + +# Overwegingen + +Dit is de body, niet geparsed. +` + +describe('parsePlanMd', () => { + it('parses a valid plan', () => { + const r = parsePlanMd(VALID) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.plan.pbi.title).toBe('Test PBI') + expect(r.plan.stories).toHaveLength(1) + expect(r.plan.stories[0].tasks).toHaveLength(1) + expect(r.plan.stories[0].tasks[0].implementation_plan).toContain('Doe X') + expect(r.body).toContain('# Overwegingen') + } + }) + + it('rejects when frontmatter is missing', () => { + const r = parsePlanMd('# Just markdown\n\nNo frontmatter here.') + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors[0].line).toBe(1) + expect(r.errors[0].message).toMatch(/frontmatter/i) + } + }) + + it('reports yaml syntax error with line info', () => { + const broken = `--- +pbi: + title: Test + priority: [unclosed +stories: + - foo +--- + +body +` + const r = parsePlanMd(broken) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors[0].message.length).toBeGreaterThan(0) + } + }) + + it('reports schema-validation error when pbi-section missing', () => { + const noPbi = `--- +stories: + - title: x + priority: 2 + tasks: + - title: y + priority: 2 +--- + +body +` + const r = parsePlanMd(noPbi) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors.some((e) => e.message.includes('pbi'))).toBe(true) + } + }) + + it('rejects empty stories array', () => { + const noStories = `--- +pbi: + title: x + priority: 2 +stories: [] +--- + +body +` + const r = parsePlanMd(noStories) + expect(r.ok).toBe(false) + }) + + it('handles CRLF line endings', () => { + const crlf = VALID.replace(/\n/g, '\r\n') + const r = parsePlanMd(crlf) + expect(r.ok).toBe(true) + }) +}) diff --git a/lib/idea-code-server.ts b/lib/idea-code-server.ts new file mode 100644 index 0000000..9f26aed --- /dev/null +++ b/lib/idea-code-server.ts @@ -0,0 +1,26 @@ +// Atomic per-user idea-code generator (DB-side). +// Schema: User.idea_code_counter Int @default(0) — increment-and-return via +// Prisma `update` (which acquires a row-lock for the duration of the +// transaction; concurrent calls serialize). Format: "IDEA-001", "IDEA-002", … +// +// Concurrency: vertrouwt op Postgres row-locking binnen Prisma `update`. +// Geen aparte $transaction nodig voor enkelvoudige update — de update is +// atomisch op één rij. Voor combineren met een idea.create wordt +// nextIdeaCode aangeroepen binnen de bredere $transaction van de caller. + +import { prisma } from '@/lib/prisma' +import { formatIdeaCode } from '@/lib/idea-code' + +import type { Prisma } from '@prisma/client' + +export async function nextIdeaCode( + userId: string, + client: Prisma.TransactionClient | typeof prisma = prisma, +): Promise { + const u = await client.user.update({ + where: { id: userId }, + data: { idea_code_counter: { increment: 1 } }, + select: { idea_code_counter: true }, + }) + return formatIdeaCode(u.idea_code_counter) +} diff --git a/lib/idea-code.ts b/lib/idea-code.ts new file mode 100644 index 0000000..dfb1536 --- /dev/null +++ b/lib/idea-code.ts @@ -0,0 +1,8 @@ +// Pure helpers voor IDEA-codes. Geen DB-imports — daarom client-safe. +// De DB-mutating nextIdeaCode staat in lib/idea-code-server.ts. + +const PAD = 3 // "IDEA-001". Bumps to 4 digits at counter 1000 organically. + +export function formatIdeaCode(n: number): string { + return `IDEA-${String(n).padStart(PAD, '0')}` +} diff --git a/lib/idea-plan-parser.ts b/lib/idea-plan-parser.ts new file mode 100644 index 0000000..02b968b --- /dev/null +++ b/lib/idea-plan-parser.ts @@ -0,0 +1,73 @@ +// Parser voor de plan_md die make-plan-job produceert. +// Format: yaml-frontmatter (structuur, parseerbaar) + markdown-body (vrije +// reasoning). Frontmatter wordt gevalideerd via ideaPlanMdFrontmatterSchema. +// +// Wordt zowel door de server-action materializeIdeaPlanAction als door de +// MCP-tool update_idea_plan_md gebruikt. Synchroon — geen LLM-call. +// +// Zie docs/plans/M12-ideas.md "Plan-md formaat A" voor het format-voorbeeld. + +import { parse as parseYaml, YAMLParseError } from 'yaml' +import { + ideaPlanMdFrontmatterSchema, + type IdeaPlanFrontmatter, +} from '@/lib/schemas/idea' + +export type PlanParseError = { line?: number; message: string } + +export type PlanParseResult = + | { ok: true; plan: IdeaPlanFrontmatter; body: string } + | { ok: false; errors: PlanParseError[] } + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ + +export function parsePlanMd(md: string): PlanParseResult { + const match = md.match(FRONTMATTER_RE) + if (!match) { + return { + ok: false, + errors: [ + { + line: 1, + message: + 'Plan ontbreekt yaml-frontmatter. Verwacht eerste regel: ---', + }, + ], + } + } + + const [, frontmatterRaw, body] = match + + let parsed: unknown + try { + parsed = parseYaml(frontmatterRaw) + } catch (err) { + if (err instanceof YAMLParseError) { + return { + ok: false, + errors: [ + { + line: err.linePos?.[0]?.line, + message: err.message, + }, + ], + } + } + return { + ok: false, + errors: [{ message: err instanceof Error ? err.message : String(err) }], + } + } + + const validation = ideaPlanMdFrontmatterSchema.safeParse(parsed) + if (!validation.success) { + return { + ok: false, + errors: validation.error.issues.map((iss) => ({ + message: `${iss.path.join('.') || ''}: ${iss.message}`, + })), + } + } + + return { ok: true, plan: validation.data, body: body.trimStart() } +} diff --git a/package-lock.json b/package-lock.json index cd1cafe..15a386a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "yaml": "^2.8.4", "zod": "^3.25.76", "zustand": "^5.0.12" }, @@ -21518,10 +21519,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index fe08b37..f6c340d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "yaml": "^2.8.4", "zod": "^3.25.76", "zustand": "^5.0.12" },