diff --git a/sdk/src/backlog-picker.ts b/sdk/src/backlog-picker.ts index 3cf86e1f6..ff0f7c73d 100644 --- a/sdk/src/backlog-picker.ts +++ b/sdk/src/backlog-picker.ts @@ -15,9 +15,11 @@ /** First bold top-level bullet: `- **Title** rest`. */ const ENTRY = /^- \*\*(.+?)\*\*\s*(.*(?:\n .*)*)/m; -const ACTION_TITLE = - /^(?:add|build|change|close|create|document|fix|implement|persist|refuse|release|remove|rename|replace|sharpen|update|validate|wire)\b/i; -const NOTES_TITLE = /^(?:notes?|release notes|upstream issues)\s*(?:\(|:|$)/i; +const CODE_REFERENCE = /`([^`]+)`/g; +// Outcomes include explicit changes and concrete defect statements. A list of +// identifiers or links alone carries neither, so it cannot qualify as work. +const ENGINEERING_OUTCOME = + /\b(?:add|asserts?|breaks?|build|cannot|capture|catches?|change|close|collapses?|cross-compile|delete|document|drift|duplicates?|fails?|fix|implement|invoke|leaks?|make|missing|must|needs?|no (?:end-to-end )?coverage|persist|refuse|register|replace|reserve|restore|run|scope|should|spawn|untested|update|use|validate|verified?|wire|wrong)\b/i; export interface BacklogEntry { title: string; @@ -108,18 +110,27 @@ function isNonEmptyStringArray(value: unknown): value is string[] { */ export function packageFromEntry(entry: BacklogEntry): Record { const blob = `${entry.title} ${entry.body}`; - const files = scopeReferences(blob); + const references = [...entry.body.matchAll(CODE_REFERENCE)].map((match) => match[1] ?? ''); + const files = [ + ...new Set( + references + .filter( + (candidate): candidate is string => + /^[A-Za-z_][A-Za-z0-9._-]*(?:\/[A-Za-z0-9._-]*)+$/.test(candidate) && + !/^\/|\/\//.test(candidate), + ), + ), + ]; + const hasEngineeringOutcome = ENGINEERING_OUTCOME.test(entry.body); + // A symbol or command locates work in this repository, but not necessarily + // one file. Preserve that honest breadth instead of discarding the signal. + if (files.length === 0 && references.length > 0 && hasEngineeringOutcome) { + files.push('.'); + } const gate = blob.match(/\bgate[ -]?(\d+)\b/i); - const explicitChecks = (entry.body.match(/`[^`]+`/g) || []) - .map((candidate) => candidate.slice(1, -1)) - .filter((candidate) => /\s/.test(candidate)); - const definitionOfDone = NOTES_TITLE.test(entry.title) - ? [] - : explicitChecks.length > 0 - ? explicitChecks - : ACTION_TITLE.test(entry.title) - ? [entry.title.replace(/[.:]\s*$/, '')] - : []; + const definitionOfDone = hasEngineeringOutcome + ? [entry.title.replace(/[.:]\s*$/, '')] + : []; return { title: entry.title, description: entry.body, @@ -128,15 +139,3 @@ export function packageFromEntry(entry: BacklogEntry): Record { definition_of_done: definitionOfDone, }; } - -/** Backticked paths, symbols, and command references are explicit scope. */ -function scopeReferences(blob: string): string[] { - const references = [...blob.matchAll(/`([^`]+)`/g)] - .map((match) => match[1]) - .filter((candidate): candidate is string => candidate !== undefined) - .filter((candidate) => { - if (/^\/|\/\//.test(candidate)) return false; - return !/\s/.test(candidate) || /--|<[^>]+>|\$[A-Za-z]/.test(candidate); - }); - return [...new Set(references)]; -} diff --git a/sdk/tests/backlog-picker.test.ts b/sdk/tests/backlog-picker.test.ts index 2c9af1fb0..ab7df736f 100644 --- a/sdk/tests/backlog-picker.test.ts +++ b/sdk/tests/backlog-picker.test.ts @@ -93,13 +93,35 @@ describe('backlog picker', () => { }); describe('work package validation', () => { - it('uses a backticked code symbol as scope evidence', () => { + it('uses a referenced code symbol as evidence of repository scope', async () => { const work = packageFromEntry({ - title: 'Refuse malformed work packages', - body: 'Make `validateWorkPackage` reject an unterminated backtick.', + title: 'Refuse an entry with unterminated backticks', + body: 'Update `validateWorkPackage` to return a typed refusal for malformed input.', }); - expect(work['files_in_scope']).toEqual(['validateWorkPackage']); + expect(await validate(work)).toMatchObject({ + accepted: true, + work: { + files_in_scope: ['.'], + definition_of_done: ['Refuse an entry with unterminated backticks'], + }, + }); + }); + + it('keeps at least twenty real backlog entries actionable', async () => { + const backlog = readFileSync(join(__dirname, '..', '..', 'ops', 'BACKLOG.md'), 'utf8'); + const entries = [...backlog.matchAll(/^- \*\*(.+?)\*\*\s*(.*(?:\n .*)*)/gm)].map( + (match) => ({ + title: match[1] ?? '', + body: (match[2] ?? '').replace(/\s+/g, ' ').trim(), + }), + ); + const verdicts = await Promise.all(entries.map((entry) => validate(packageFromEntry(entry)))); + + const actionable = verdicts.filter( + (verdict) => (verdict as { accepted: boolean }).accepted, + ).length; + expect(actionable).toBeGreaterThanOrEqual(20); }); it('accepts an engineering task stated as an imperative outcome', async () => {