diff --git a/src/story-mode.ts b/src/story-mode.ts index f3d29e9a..5dc6da7a 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -290,7 +290,8 @@ export function balloonText(text: string): string { const KEY_KINDS: Array<[StoryKeyKind, RegExp]> = [ [ 'problem', - /\b(problem|bug|broken|known (issue|quirk)s?|quirks?|caveat|limitation|heads up|warning|unfixed|left (open|unfixed|out)|still (fails?|failing|broken)|(won't|doesn't|does not|will not) (work|fix)|(isn't|is not|not) fixed|never (moves|runs|loads|works|starts|shows))\b/i, + // "bug-fixing" and "cron-broken-network" are names, and a bare "warning" is often one too. + /\b(problems?|bugs?(?!-| fix)|(? = [ ], [ 'pending', - /\b(not yet|haven't|have not|can't tell|isn't (confirmed|committed|pushed|tested|done|checked)|not (checked|tested|verified|confirmed|committed|pushed|opened|run)|still (running|working|open|missing)|didn't (open|try|check|test|run|verify) it)\b/i, + // "Left out" is open work, unless Claude chose it ("left out on purpose", "rather than guessed"). + /(? = [ ], ]; const KEY_WEAK = /\b(didn't|did not|couldn't|could not|wasn't able|won't|doesn't)\b/i; +/** A problem sentence that is history: "I found and fixed a bug", "is fixed by …", "the caveat is gone". */ +const PROBLEM_CLOSED = + /\b(?:(?:i|we|claude|and|is|are|was|were|been|be|now|got|gets?|it's|that's|already|also)\s+(?:\w+\s+)?(?:fixed|resolved)|(?:bugs?|problems?|issues?)\s+fixed|fixed (?:it|that|this|them|by|in|with|at|before|during)|(?:was|were|had been) broken|(?:was|were) the (?:\w+ )?(?:problem|bug|cause)|fixes|(?:is|are) gone|no longer|went away|works now|now (?:works|passes))\b/i; +/** …unless it says the fix is NOT there: "is not fixed", "wasn't fixed yet". */ +const NOT_FIXED = /\b(?:not|never)\s+(?:yet\s+)?(?:fixed|resolved)\b|n't\s+(?:\w+\s+)?(?:fixed|resolved)\b/i; +/** A negation: "found no broken links", "not a problem", "without any memory problem", "couldn't find a bug". */ +const NO_PROBLEM = + /\b(?:no|not an?|(?:isn't|is not|wasn't|was not|aren't) an?|without (?:any|an?)|found no|(?:couldn't|could not|didn't|did not) find (?:any|an?)|none of the)\s+(?:\S+\s+){0,2}?(?:problems?|bugs?|issues?|quirks?|caveats?|limitations?|broken)\b|\b(?:never|not|n't)\s+(?:been\s+)?broken\b/i; +const openProblem = (s: string): boolean => (NOT_FIXED.test(s) || !PROBLEM_CLOSED.test(s)) && !NO_PROBLEM.test(s); +/** A sentence's kind: the first that matches, where a problem must still be open. */ +const sentenceKind = (s: string): StoryKeyKind | undefined => + KEY_KINDS.find(([kind, re]) => re.test(s) && (kind !== 'problem' || openProblem(s)))?.[0]; /** A sentence that only points elsewhere ("described below"): nothing a balloon can show. */ const POINTS_ELSEWHERE = /\b(described|listed|shown|details?) below\b|\bsee below\b|\bas follows\b/i; /** The fix that follows a problem sentence: then the problem is not open. */ @@ -333,7 +347,7 @@ const ASKS_YOU = export function keyKindOf(point: string): StoryKeyKind | undefined { // A problem with its fix is history, not an open problem. if (/\b(so i (changed|fixed)|i fixed|fixed it)\b/i.test(point)) return undefined; - return KEY_KINDS.find(([, re]) => re.test(point))?.[0]; + return sentenceKind(point); } /** @@ -367,7 +381,7 @@ function keyPointsOf(text: string): string[] { let sentence = sentences[i]; if (sentence.length < 12 || POINTS_ELSEWHERE.test(sentence)) continue; // Judged on its own words (a list intro too: "Known quirks:", "Two places where I didn't…:"). - const kind = KEY_KINDS.find(([, re]) => re.test(sentence))?.[0]; + const kind = sentenceKind(sentence); if (!kind && (weak || !KEY_WEAK.test(sentence))) continue; // A list intro brings its first items, while short, never one that only points elsewhere. if (/:$/.test(sentence)) diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index b4b94a0d..2226cde7 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -13,6 +13,7 @@ import { exactDuration, firstSentence, heredocWrites, + keyKindOf, parseTestCounts, reportsProblems, planFromText, @@ -1004,6 +1005,43 @@ describe('buildStoryPage', () => { expect(run('git config user.name; ./deploy.sh', 'Exit code 1\nme')).toBe('Claude tries a command, and it fails.'); }); + it('a problem in the last words is an open one: not history, not a negation, not a name', () => { + // Open: a catch. + for (const open of [ + "Prompts sent through the API didn't submit (separate bug, not fixed).", + 'The main problem is RAM: the box has 30 GB.', + "Opening the page: it won't work if you double-click index.html.", + 'Limitation: the page does not follow the OS theme until a reload.', + 'The bug is still on master.', + ]) + expect(keyKindOf(open), open).toBe('problem'); + // History, a negation or a name: no catch. + for (const closed of [ + 'I found and fixed a model bug.', + 'Bug fixed: the server quietly rewrote a leading slash.', + "The bridge's model-name problem is fixed by infra-guard in commit 19081f6.", + 'This also fixes an old bug: an HTTP error used to show the wrong toast.', + 'The old sockets were the problem: they lacked that option.', + 'The "don\'t write confirmed yet" caveat is gone.', + "The build's link check found no broken links.", + 'Not a problem, just not the one-shot I described.', + "I couldn't find a real bug in any of these four.", + 'The shutdown path was never broken.', + "It isn't a key problem or a wrong username.", + 'SWE-bench is real bug-fixing in large repositories.', + 'Requirements are listed at https://example.com/docs#limitations today.', + 'A bug fix this round turned up.', + 'The memory warning now fires at 90 percent.', + ]) + expect(['problem'], closed).not.toContain(keyKindOf(closed)); + // "Left out" is open work, unless Claude chose it. + expect(keyKindOf('The README update is left out for now.')).toBe('pending'); + expect( + keyKindOf('Left out on purpose: the search in the working tree belongs to another session.') + ).toBeUndefined(); + expect(keyKindOf('Anything unknown is left out rather than guessed.')).toBeUndefined(); + }); + it('a stop names the step Claude was on and what its to-do list still had open', () => { const stopped = (lines: unknown[]) => pageOf([...lines, prompt('[Request interrupted by user]')]).panels.find((p) => p.kind === 'complete')!.caption