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) <noreply@anthropic.com>
This commit is contained in:
parent
bba3f11269
commit
dfee518996
7 changed files with 236 additions and 4 deletions
21
__tests__/lib/idea-code.test.ts
Normal file
21
__tests__/lib/idea-code.test.ts
Normal file
|
|
@ -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.
|
||||||
103
__tests__/lib/idea-plan-parser.test.ts
Normal file
103
__tests__/lib/idea-plan-parser.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
26
lib/idea-code-server.ts
Normal file
26
lib/idea-code-server.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
8
lib/idea-code.ts
Normal file
8
lib/idea-code.ts
Normal file
|
|
@ -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')}`
|
||||||
|
}
|
||||||
73
lib/idea-plan-parser.ts
Normal file
73
lib/idea-plan-parser.ts
Normal file
|
|
@ -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('.') || '<root>'}: ${iss.message}`,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, plan: validation.data, body: body.trimStart() }
|
||||||
|
}
|
||||||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -42,6 +42,7 @@
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"yaml": "^2.8.4",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
|
|
@ -21518,10 +21519,9 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.3",
|
"version": "2.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"yaml": "^2.8.4",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue