Schema sync vanaf upstream Scrum4Me (v77617e8): FAILED toegevoegd aan Task/Story/Pbi/SprintStatus, nieuw SprintRunStatus + PrStrategy enums, SprintRun model, ClaudeJob.sprint_run_id, Product.pr_strategy. T-18 — propagateStatusUpwards in src/lib/tasks-status-update.ts. Real-time cascade Task → Story → PBI → Sprint → SprintRun bij elke task-statuswijziging. Bij FAILED cancelt sibling-jobs in dezelfde SprintRun. PBI-status BLOCKED blijft handmatig. Houd deze helper bit- voor-bit synchroon met Scrum4Me/lib/tasks-status-update.ts. updateTaskStatusWithStoryPromotion blijft als BC-wrapper. T-19 — wait-for-job.ts claim-filter. Task-jobs worden alleen geclaimd als hun SprintRun status QUEUED of RUNNING heeft. Idea-jobs blijven ongefilterd. Bij eerste claim van een QUEUED SprintRun → RUNNING binnen dezelfde tx (race-safe). T-20 — update-job-status.ts roept propagateStatusUpwards aan na elke task DONE/FAILED. Bestaande cancelPbiOnFailure-aanroep blijft voor PR-cleanup; sibling-cancellation overlap is harmless (idempotent). T-21 — classify.ts (verifier) leest nu ook "--- a/<path>" zodat delete-only commits niet meer als EMPTY worden geclassificeerd. Bug had eerder geleid tot ten onrechte FAILED-status op cmotto5h en cmotto5i (06-05-2026); zou met cascade-flow een hele sprint laten falen. Cleanup: create-todo.ts en open_todos in get-claude-context.ts verwijderd (Todo-model is op main gedropt). Endpoint geeft nu open_ideas terug — ideeën die niet PLANNED zijn. Status-mappers (src/status.ts) uitgebreid met failed. Tests: 184/184 groen (180 → 184; vier nieuwe delete-only classify-tests en herwerkte propagate-status tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
5.4 KiB
TypeScript
144 lines
5.4 KiB
TypeScript
export type VerifyResultValue = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT'
|
||
|
||
export interface ClassifyResult {
|
||
result: VerifyResultValue
|
||
reasoning: string
|
||
}
|
||
|
||
// Extract changed file paths from a unified diff. Reads both "+++ b/<path>"
|
||
// (created/modified files) and "--- a/<path>" (deleted/modified files), so
|
||
// pure-delete commits (where +++ is /dev/null) are still recognised.
|
||
function extractDiffPaths(diff: string): string[] {
|
||
const paths = new Set<string>()
|
||
for (const line of diff.split('\n')) {
|
||
const plus = line.match(/^\+\+\+ b\/(.+)$/)
|
||
if (plus && plus[1].trim() !== '/dev/null') paths.add(plus[1].trim())
|
||
const minus = line.match(/^--- a\/(.+)$/)
|
||
if (minus && minus[1].trim() !== '/dev/null') paths.add(minus[1].trim())
|
||
}
|
||
return [...paths]
|
||
}
|
||
|
||
// Extract file paths mentioned in a plan (backtick-quoted, parenthesised, or bullet-list headings).
|
||
function extractPlanPaths(plan: string): string[] {
|
||
const paths = new Set<string>()
|
||
|
||
const backtickRe = /`([^`\s][^`]*[^`\s]|[^`\s])`/g
|
||
let m: RegExpExecArray | null
|
||
while ((m = backtickRe.exec(plan)) !== null) {
|
||
const p = m[1].trim()
|
||
if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p)
|
||
}
|
||
|
||
const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm
|
||
while ((m = bulletRe.exec(plan)) !== null) {
|
||
paths.add(`${m[1]}.${m[2]}`)
|
||
}
|
||
|
||
return [...paths]
|
||
}
|
||
|
||
// Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts".
|
||
function pathMatches(planPath: string, diffPaths: string[]): boolean {
|
||
const norm = planPath.replace(/\\/g, '/')
|
||
return diffPaths.some((dp) => {
|
||
const ndp = dp.replace(/\\/g, '/')
|
||
return ndp === norm || ndp.endsWith(`/${norm}`) || norm.endsWith(`/${ndp}`)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Classify a unified git diff against an implementation plan.
|
||
* Returns a VerifyResult (ALIGNED|PARTIAL|EMPTY|DIVERGENT) plus human-readable reasoning.
|
||
*
|
||
* v1 heuristic — no LLM required:
|
||
* - No file changes in diff → EMPTY
|
||
* - Plan empty, diff ≤50 changed lines → ALIGNED (targeted fix)
|
||
* - Plan empty, diff >50 changed lines → DIVERGENT (too large to trust)
|
||
* - Plan paths < 100% covered in diff → PARTIAL
|
||
* - Plan paths 100% covered, diff ≥3× more paths → DIVERGENT (scope creep)
|
||
* - Plan paths 100% covered, diff <3× more paths → ALIGNED
|
||
*/
|
||
export function classifyDiffAgainstPlan(opts: {
|
||
diff: string
|
||
plan: string | null
|
||
}): ClassifyResult {
|
||
const { diff, plan } = opts
|
||
|
||
const diffPaths = extractDiffPaths(diff)
|
||
if (diffPaths.length === 0) {
|
||
return { result: 'EMPTY', reasoning: 'Geen bestandswijzigingen in de diff.' }
|
||
}
|
||
|
||
// Whitespace-only / no-content edge case: paths are present but every +/-
|
||
// line is a diff header (---/+++) or whitespace-only. Treat as EMPTY so the
|
||
// gate rejects DONE for tasks that didn't really change anything.
|
||
const meaningfulChange = diff.split('\n').some((l) => {
|
||
if (!/^[+-]/.test(l)) return false
|
||
if (/^[+-]{3}\s/.test(l)) return false // diff header line (--- / +++)
|
||
return l.slice(1).trim().length > 0
|
||
})
|
||
if (!meaningfulChange) {
|
||
return {
|
||
result: 'EMPTY',
|
||
reasoning: 'Diff bevat alleen headers of whitespace — geen daadwerkelijke content-wijzigingen.',
|
||
}
|
||
}
|
||
|
||
const changedLines = diff.split('\n').filter((l) => l.startsWith('+') || l.startsWith('-')).length
|
||
|
||
if (!plan || plan.trim().length === 0) {
|
||
if (changedLines > 50) {
|
||
return {
|
||
result: 'DIVERGENT',
|
||
reasoning: `Geen plan-baseline aanwezig; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`,
|
||
}
|
||
}
|
||
return {
|
||
result: 'ALIGNED',
|
||
reasoning: `Geen plan-baseline; ${diffPaths.length} bestand(en) gewijzigd — kleine gerichte wijziging.`,
|
||
}
|
||
}
|
||
|
||
const planPaths = extractPlanPaths(plan)
|
||
|
||
if (planPaths.length === 0) {
|
||
if (changedLines > 50) {
|
||
return {
|
||
result: 'DIVERGENT',
|
||
reasoning: `Plan vermeldt geen specifieke paden; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`,
|
||
}
|
||
}
|
||
return {
|
||
result: 'ALIGNED',
|
||
reasoning: `Plan vermeldt geen specifieke paden; ${diffPaths.length} bestand(en) gewijzigd.`,
|
||
}
|
||
}
|
||
|
||
const covered = planPaths.filter((pp) => pathMatches(pp, diffPaths))
|
||
const coverage = covered.length / planPaths.length
|
||
const ratio = diffPaths.length / planPaths.length
|
||
|
||
if (coverage < 1) {
|
||
const missing = planPaths.filter((pp) => !pathMatches(pp, diffPaths))
|
||
const missingStr = missing.slice(0, 3).join(', ') + (missing.length > 3 ? ` + ${missing.length - 3} meer` : '')
|
||
return {
|
||
result: 'PARTIAL',
|
||
reasoning: `${covered.length}/${planPaths.length} plan-paden aanwezig in diff. Ontbrekend: ${missingStr}.`,
|
||
}
|
||
}
|
||
|
||
if (ratio >= 3) {
|
||
const extra = diffPaths.filter((dp) => !planPaths.some((pp) => pathMatches(pp, [dp])))
|
||
const extraStr = extra.slice(0, 3).join(', ') + (extra.length > 3 ? ` + ${extra.length - 3} meer` : '')
|
||
return {
|
||
result: 'DIVERGENT',
|
||
reasoning: `Alle ${planPaths.length} plan-paden aanwezig, maar diff bevat ${diffPaths.length} paden (${ratio.toFixed(1)}x). Extra: ${extraStr}.`,
|
||
}
|
||
}
|
||
|
||
return {
|
||
result: 'ALIGNED',
|
||
reasoning: `Alle ${planPaths.length} plan-paden aanwezig in diff (${diffPaths.length} totaal; ${ratio.toFixed(1)}x).`,
|
||
}
|
||
}
|