Scrum4Me/prisma/seed-data/parse-backlog.ts
Janpeter Visser fb4d2e093f
M10: Password-loze inlog via QR-pairing — backlog + implementatie-plan (#11)
* docs(ST-1001..1008): add M10 — QR-pairing login milestone to backlog

Plant acht stories ST-1001..ST-1008 voor password-loze inlog via QR-pairing.
Mobiele bevestiging met UA+IP, demo-blokkade, paired-sessie 8u TTL.
Security-uitgangspunt: mobileSecret reist alleen via QR-fragment + POST-body,
desktop-SSE/claim via HttpOnly pre-auth cookie — geheim materiaal nooit in
URL-paden, querystrings, access logs of browsergeschiedenis. Twee gescheiden
hashes in DB (secret_hash + desktop_token_hash). Bouwt voort op M8 LISTEN/NOTIFY-
infra met eigen channel scrum4me_pairing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ST-1001..1008): teach backlog parser about M9 + M10

M9 (Actief Product Backlog) was bij eerdere merge per ongeluk overgeslagen in
de drie milestone-maps; viel terug op fallbacks. Nu expliciet, samen met M10
(QR-pairing). Parser self-test toont 12 milestones / 118 stories / 190 tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ST-1001..1008): document QR-login flow in functional spec + persona

Voeg F-01b (Inloggen via mobiel via QR-pairing) toe aan de functional spec met
acceptatiecriteria, randgevallen en datamodel. Beveiligingsuitgangspunt
expliciet: mobileSecret in URL-fragment en HttpOnly desktop-cookie zodat geheim
materiaal nooit in URL-paden of access logs belandt.

Lars-persona krijgt de bijbehorende use-case (publieke/geleende laptops bij
klantbezoek of familie) zodat de feature een herkenbare aanleiding heeft in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ST-1001..1008): add M10 implementation plan + link from backlog

Volledig implementatie-plan per story (Bestanden / Stappen / Aandachtspunten /
Verificatie) in dezelfde stijl als M9. Citeert de patronen uit
docs/patterns/iron-session.md, route-handler.md en server-action.md, en
hergebruikt het LISTEN/NOTIFY-pattern uit app/api/realtime/solo/route.ts.

Bevat ook commit/branch-strategie per laag, reseed-stap voor de MCP-context, en
verificatie-acceptatie inclusief log-controle dat geheim materiaal niet in
access logs belandt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: enforce one-branch-per-milestone policy to limit Vercel builds

Vercel preview-deployments worden bij elke push naar een feature-branch
getriggered en kosten op het Hobby-account budget. Voeg expliciete Branch & PR
Strategy toe aan CLAUDE.md: één branch per milestone, commits accumuleren
lokaal, push + PR pas na handmatige gebruiker-acceptatie. Uitzonderingen voor
planning-only PR's (alleen docs) en hotfixes.

Update tegelijk de branch/commit-strategie-tabel in het M10-implementatieplan
zodat die de nieuwe policy weerspiegelt (één branch feat/M10-qr-login,
chronologische commits per stap, push pas bij groene happy-path-acceptatie).

Bevat een 'Wanneer aanpassen'-sectie zodat de regel makkelijk teruggedraaid kan
worden zodra het account naar Pro gaat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:49:56 +02:00

258 lines
7.3 KiB
TypeScript

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<string, 1 | 2 | 3 | 4> = {
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<string, string> = {
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<string, ParsedMilestone['sprint_status']> = {
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<ParsedMilestone[]> {
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)
})
}