import { readFile } from 'node:fs/promises' import path from 'node:path' export type ParsedTask = { title: string description: string sort_order: number } export type ParsedStory = { ref: string title: string acceptance_criteria: string status: 'DONE' | 'OPEN' sort_order: number tasks: ParsedTask[] } export type ParsedMilestone = { key: string title: string goal: string priority: 1 | 2 | 3 | 4 sprint_status: 'ACTIVE' | 'COMPLETED' sort_order: number stories: ParsedStory[] } const MILESTONE_HEADER = /^### (M[\d.]+|PBI-\d+):\s*(.+?)\s*$/ const TASK_BULLET = /^- \[(x| )\] \*\*(ST-\d+)\*\*\s+(.+?)\s*$/ const SUB_BULLET = /^ {2}- (.+?)\s*$/ const NESTED_LINE = /^ {4,}\S/ const SECTION_BREAK = /^---\s*$/ const BOLD_PREFIX = /^\*\*([^*]+?)\*\*\s*:?\s*(.*)$/ const MILESTONE_PRIORITY: Record = { M0: 1, M1: 1, M2: 1, M3: 1, 'M3.5': 2, M4: 2, M5: 3, M6: 4, M7: 4, M8: 4, M9: 4, M10: 4, } const MILESTONE_GOAL: Record = { M0: 'Project, database, auth, navigatieshell', M1: "Producten, PBI's, gesplitst scherm", M2: 'Stories als blokken, dnd-kit, Zustand', M3: 'Sprint aanmaken, stories slepen, taken', 'M3.5': 'Story-claim, persoonlijk Kanban-bord per product', M4: 'Alle endpoints, tokenbeheer', M5: 'Todo CRUD, promotie naar PBI/story; Data Table + detail-kaart', M6: 'Foutafhandeling, toegankelijkheid, CI/CD, beveiliging', M7: 'MCP-server voor Claude Code', M8: 'Realtime updates voor Solo Paneel', M9: 'Actief Product Backlog — persistent gekozen product', M10: 'Password-loze inlog via QR-pairing', } const MILESTONE_SPRINT_STATUS: Record = { M0: 'COMPLETED', M1: 'COMPLETED', M2: 'COMPLETED', M3: 'COMPLETED', 'M3.5': 'ACTIVE', M4: 'COMPLETED', M5: 'COMPLETED', M6: 'COMPLETED', M7: 'COMPLETED', M8: 'COMPLETED', M9: 'COMPLETED', M10: 'COMPLETED', } const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/ type SubBullet = { headLine: string nestedLines: string[] isAcceptance: boolean } function buildTask(b: SubBullet, sort_order: number): ParsedTask { const nested = b.nestedLines.join('\n') const m = b.headLine.match(BOLD_PREFIX) if (m) { const title = m[1].replace(/`/g, '').replace(/:$/, '').trim() const description = [m[2].trim(), nested].filter(Boolean).join('\n').trim() return { title, description, sort_order } } // No bold prefix: derive a short title from the first clause; full bullet stays in description. const head = b.headLine.replace(/`/g, '').trim() const firstClause = head.split(/[;.]\s/, 1)[0].trim() const title = firstClause.length > 80 ? firstClause.slice(0, 79).trim() + '…' : firstClause const description = [b.headLine, nested].filter(Boolean).join('\n').trim() return { title, description, sort_order } } function buildAcceptance(b: SubBullet): string { const head = b.headLine.replace(/^Done when:\s*/i, '').trim() const nested = b.nestedLines.join('\n').trim() return [head, nested].filter(Boolean).join('\n').trim() } export async function loadBacklog( repoRoot: string, options: { strict?: boolean } = {}, ): Promise { const { strict = true } = options const file = path.join(repoRoot, 'docs/scrum4me-backlog.md') const md = await readFile(file, 'utf8') const milestones: ParsedMilestone[] = [] let current: ParsedMilestone | null = null let pending: { story: ParsedStory bullets: SubBullet[] activeBullet: SubBullet | null } | null = null const flushPending = () => { if (!pending) return if (pending.activeBullet) { pending.bullets.push(pending.activeBullet) pending.activeBullet = null } const taskBullets = pending.bullets.filter((b) => !b.isAcceptance) const acceptanceBullets = pending.bullets.filter((b) => b.isAcceptance) pending.story.tasks = taskBullets.map((b, i) => buildTask(b, i + 1)) pending.story.acceptance_criteria = acceptanceBullets .map(buildAcceptance) .filter(Boolean) .join('\n') pending = null } for (const raw of md.split('\n')) { const headerMatch = raw.match(MILESTONE_HEADER) if (headerMatch && MILESTONE_KEY.test(headerMatch[1])) { flushPending() const key = headerMatch[1] const title = headerMatch[2] current = { key, title, goal: MILESTONE_GOAL[key] ?? title, priority: MILESTONE_PRIORITY[key] ?? 4, sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'COMPLETED', sort_order: milestones.length + 1, stories: [], } milestones.push(current) continue } if (SECTION_BREAK.test(raw)) { flushPending() continue } if (!current) continue const taskMatch = raw.match(TASK_BULLET) if (taskMatch) { flushPending() const story: ParsedStory = { ref: taskMatch[2], title: taskMatch[3], acceptance_criteria: '', status: taskMatch[1] === 'x' ? 'DONE' : 'OPEN', sort_order: current.stories.length + 1, tasks: [], } current.stories.push(story) pending = { story, bullets: [], activeBullet: null } continue } if (!pending) continue const subMatch = raw.match(SUB_BULLET) if (subMatch) { if (pending.activeBullet) { pending.bullets.push(pending.activeBullet) } const content = subMatch[1] pending.activeBullet = { headLine: content.replace(/^Done when:\s*/i, ''), nestedLines: [], isAcceptance: /^Done when:/i.test(content), } continue } if (NESTED_LINE.test(raw) && pending.activeBullet) { pending.activeBullet.nestedLines.push(raw.trim()) continue } if (raw.trim() === '') continue flushPending() } flushPending() if (strict) { if (milestones.length < 8) { throw new Error( `Backlog parser found only ${milestones.length} milestones (expected 8). Format may have drifted in ${file}.`, ) } const totalStories = milestones.reduce((acc, m) => acc + m.stories.length, 0) if (totalStories < 60) { throw new Error( `Backlog parser found only ${totalStories} stories (expected ≥ 60). Format may have drifted in ${file}.`, ) } } return milestones } if (import.meta.url === `file://${process.argv[1]}`) { const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..') loadBacklog(repoRoot) .then((milestones) => { const total = milestones.reduce((acc, m) => acc + m.stories.length, 0) const done = milestones.reduce( (acc, m) => acc + m.stories.filter((s) => s.status === 'DONE').length, 0, ) const open = total - done const totalTasks = milestones.reduce( (acc, m) => acc + m.stories.reduce((a, s) => a + s.tasks.length, 0), 0, ) console.log( `Parsed ${milestones.length} milestones, ${total} stories (${done} DONE, ${open} OPEN), ${totalTasks} tasks`, ) for (const m of milestones) { const ms = m.stories.reduce((a, s) => a + s.tasks.length, 0) console.log( ` ${m.key.padEnd(5)} ${m.title.padEnd(36)} priority=${m.priority} sprint=${m.sprint_status} stories=${m.stories.length} tasks=${ms}`, ) } }) .catch((err) => { console.error(err) process.exit(1) }) }