scrum4me-mcp/src/verify/classify.ts
Janpeter Visser f01fab8c38
feat: branch-per-story + worktree-defer + verify EMPTY edge-cases (#12)
Implementeert vier open stories uit PBI 'Veilige Claude-agent-workflow':

**Branch per story (cmon11tbe001zbortx35n155c)**
- `resolveBranchForJob`: zoek sibling-job in dezelfde story; reuse z'n
  branch (1 PR per story i.p.v. per task).
- Branch-naam: `feat/story-<8-char>` voor nieuwe stories.
- `createWorktreeForJob` kent nu `reuseBranch=true`: detecteert stale
  sibling-worktree die de branch nog vasthoudt en verwijdert die eerst.
- `attachWorktreeToJob` neemt `storyId` mee.

**PR-hergebruik (zelfde story)**
- `maybeCreateAutoPr`: als sibling-job in story al een pr_url heeft,
  hergebruik die zonder nieuwe `gh pr create`-call. PR-titel komt nu
  van de story (was task) zodat het als 'story-PR' leest.

**Worktree-cleanup uitgesteld bij actieve siblings**
- `cleanupWorktreeForTerminalStatus`: count active sibling-jobs in
  dezelfde story; defer als > 0 (volgende sub-task gebruikt branch).

**Worktree-cleanup logging (cmon0jc14001ubortjxf2a2ck)**
- Warning bij ontbrekende repoRoot, met productId + jobId in message.
- Warning bij removeWorktreeForJob-failure met keepBranch in message.

**resolveRepoRoot fallback (cmon0jc14001ubortjxf2a2ck)**
- Convention-based fallback: `~/Projects/<repo-name>` afgeleid uit
  `product.repo_url` als noch env-var noch config-bestand iets oplevert.
- `repoNameFromUrl` helper geëxporteerd voor herbruikbaarheid.

**Verify EMPTY-detection edge-case (cmon0kdq6001xbort2kgbcqmr)**
- `classifyDiffAgainstPlan`: na file-paths-check ook content-lines
  checken; als alle +/- regels alleen headers of whitespace zijn,
  return EMPTY met duidelijke reasoning.

Tests: 120/120 groen (3 nieuwe), tsc clean, build clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:04:54 +02:00

140 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export type VerifyResultValue = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT'
export interface ClassifyResult {
result: VerifyResultValue
reasoning: string
}
// Extract changed file paths from a unified diff ("+++ b/<path>" lines).
function extractDiffPaths(diff: string): string[] {
const paths = new Set<string>()
for (const line of diff.split('\n')) {
const m = line.match(/^\+\+\+ b\/(.+)$/)
if (m && m[1].trim() !== '/dev/null') paths.add(m[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).`,
}
}