From fe1e292ea3ca46fa8aabbf972dd86e0d004a60e3 Mon Sep 17 00:00:00 2001 From: Remon Panman <228601219+Tradebaas@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:07:48 +0200 Subject: [PATCH] fix(checks): a finished plan is history, so only plans in flight can clash The board warned that a scope item "is being worked on from 2 plans at once" whenever two specs named it, a finished one included. That is not what the sentence says and not what a reader needs: an item worth two rounds of work carries two specs forever, so the heads-up would stand for the life of the project and teach the reader to skim the one place the board raises its voice. Closing spec 011 made it concrete: SC-8 was shipped first by the archived baseline and owned since by 011, both done, and no ordering of those two would ever clear the line. A later spec supersedes an earlier claim, which is ordinary history. The clash worth reporting is two unfinished plans on one item, where nobody has decided yet who owns it, so the warning now counts only the specs that have not finished, and names those. The item's own state is untouched: the furthest spec still wins, so an item one plan already shipped keeps reading done while a second plan reworks it. Three self-tests hold the rule from both sides: two unfinished plans still warn, a finished plan plus a later one in flight does not, and two finished plans are history rather than a clash. The two older fixtures that paired a building spec with a done one moved to two unfinished specs, because they were written to exercise the clash and that pairing is no longer one. Traces-to: SC-10 --- checks/progress.mjs | 9 +++++++-- checks/progress.test.mjs | 30 +++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/checks/progress.mjs b/checks/progress.mjs index 269efd3..e33f9d9 100644 --- a/checks/progress.mjs +++ b/checks/progress.mjs @@ -274,8 +274,13 @@ export function derive({ scopeItems, specs: allSpecs }) { let state = 'todo'; if (mine.some((s) => s.status === SPEC_DONE)) state = 'done'; else if (mine.length) state = 'doing'; - if (mine.length > 1) { - warnings.push({ kind: 'doubleClaim', title: item.title, specs: mine.map((s) => s.file) }); + // Only plans still in flight can be working on it at once. A finished spec has shipped its + // part and a later spec supersedes that claim, which is ordinary history: a scope item worth + // two rounds of work carries two specs forever, and warning about it every run teaches the + // reader to skim the heads-up. Two unfinished plans on one item is the real clash. + const together = mine.filter((s) => s.status !== SPEC_DONE); + if (together.length > 1) { + warnings.push({ kind: 'doubleClaim', title: item.title, specs: together.map((s) => s.file) }); } return { ...item, state, specs: mine.map((s) => s.file) }; }); diff --git a/checks/progress.test.mjs b/checks/progress.test.mjs index 6c59eaa..eb9373e 100644 --- a/checks/progress.test.mjs +++ b/checks/progress.test.mjs @@ -124,16 +124,40 @@ test('a spec pointing at an unknown scope item is surfaced, not swallowed', () = assert.deepEqual(p.warnings, [{ kind: 'unknownItem', spec: 'ghost' }]); }); -test('two specs claiming one scope item: the furthest wins and the clash is reported', () => { +test('two unfinished specs claiming one scope item: the furthest wins and the clash is reported', () => { const p = derive({ scopeItems: items, specs: [ { file: 'a', status: 'building', traces: ['SC-1'] }, + { file: 'b', status: 'approved', traces: ['SC-1'] }, + ], + }); + assert.equal(p.items[0].state, 'doing'); + assert.deepEqual(p.warnings, [{ kind: 'doubleClaim', title: items[0].title, specs: ['a', 'b'] }]); +}); + +test('a later spec supersedes a finished claim: the item still counts done, and nobody is warned', () => { + const p = derive({ + scopeItems: items, + specs: [ + { file: 'shipped-it-first', status: 'done', traces: ['SC-1'] }, + { file: 'doing-it-again', status: 'building', traces: ['SC-1'] }, + ], + }); + assert.equal(p.items[0].state, 'done'); + assert.deepEqual(p.warnings, []); +}); + +test('two finished specs on one scope item are history, not a clash', () => { + const p = derive({ + scopeItems: items, + specs: [ + { file: 'a', status: 'done', traces: ['SC-1'] }, { file: 'b', status: 'done', traces: ['SC-1'] }, ], }); assert.equal(p.items[0].state, 'done'); - assert.deepEqual(p.warnings, [{ kind: 'doubleClaim', title: items[0].title, specs: ['a', 'b'] }]); + assert.deepEqual(p.warnings, []); }); test('no scope items means not defined, never a zero count', () => { @@ -167,7 +191,7 @@ test('warnings speak the project language and name no internal identifiers', () const clash = derive({ scopeItems: items, - specs: [{ file: 'a', status: 'building', traces: ['SC-1'] }, { file: 'b', status: 'done', traces: ['SC-1'] }], + specs: [{ file: 'a', status: 'building', traces: ['SC-1'] }, { file: 'b', status: 'draft', traces: ['SC-1'] }], }); const en = renderFull(project({ lang: 'en' }), clash); assert.match(en, /is being worked on from 2 plans at once \(a, b\)/);