From 2a7b557ab99a5602ff1f19df75c43aeb0d62f5e6 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:09:40 +0200 Subject: [PATCH 1/6] test(hygiene): recognize Draft Ready and In Arbeit PLAN statuses Test-First: scripts/lib/dev-hygiene.test.mjs::current PLAN Draft Ready and In Arbeit statuses are recognized Co-authored-by: Cursor --- scripts/lib/dev-hygiene.test.mjs | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/scripts/lib/dev-hygiene.test.mjs b/scripts/lib/dev-hygiene.test.mjs index 1bd3c6eb..2059b0c9 100644 --- a/scripts/lib/dev-hygiene.test.mjs +++ b/scripts/lib/dev-hygiene.test.mjs @@ -324,3 +324,75 @@ test("hygiene reports a malformed Z goal table of docs/PLAN.md instead of passin assert.match(broken.notChecked.join("\n"), /Z3.*keine Tabelle/); assert.ok(countFindings(broken) >= 2); }); + +// Z2-HYGIENE-STATUS — current PLAN status tokens (Draft / Ready / In Arbeit). +const Z_HEAD = + "| ID | Ziel | Owner | Hängt ab von | Ort | Stufe | Status | Abnahme | Ticket |\n|---|---|---|---|---|---|---|---|---|\n"; + +test("current PLAN Draft Ready and In Arbeit statuses are recognized", () => { + const plan = [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + "| Z2-D1 | draft row | o | – | egal | B | Draft #1 | a | – |", + "| Z2-R2 | ready row | o | – | egal | B | Ready #2 | a | – |", + "| Z2-IA | running | o | – | egal | B | In Arbeit | a | – |", + // siblings: legacy tokens still recognized + "| Z2-OLD | legacy | o | – | egal | B | in Arbeit PR #10 | a | – |", + "| Z2-PR | legacy pr | o | – | egal | B | PR #11 | a | – |", + // two candidate PRs + "| Z2-DS | drafts | o | – | egal | B | Drafts #3/#4 | a | – |", + // explained predecessor / historical numbers must not become candidates + "| Z2-PRED | pred | o | – | egal | B | Draft #5 (Vorgänger PR #99; früher #88) | a | – |", + // negatives: not startable / not in progress + "| Z2-E | done | o | – | egal | B | Erledigt | a | – |", + "| Z2-B | blocked | o | – | egal | B | Blockiert | a | – |", + "| Z2-P | pending | o | – | egal | B | Pending | a | – |", + "| Z2-G | planned | o | – | egal | B | Geplant | a | – |", + "", + ].join("\n"); + + const rows = inProgressPackages(plan); + assert.deepEqual( + rows.map((r) => [r.id, r.prNumbers]), + [ + ["Z2-D1", [1]], + ["Z2-R2", [2]], + ["Z2-IA", []], + ["Z2-OLD", [10]], + ["Z2-PR", [11]], + ["Z2-DS", [3, 4]], + ["Z2-PRED", [5]], + ], + ); + + // collectHygiene: open candidate PR suppresses the finding; missing does not. + const openOk = collectHygiene({ + ...input, + planText: plan, + prsOpen: [ + { number: 1, title: "Z2-D1", headRefName: "cursor/z2-d1", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }, + { number: 2, title: "Z2-R2", headRefName: "cursor/z2-r2", updatedAt: "2026-09-25T11:00:00Z", isDraft: false }, + { number: 3, title: "Z2-DS", headRefName: "cursor/z2-ds-a", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }, + { number: 4, title: "Z2-DS", headRefName: "cursor/z2-ds-b", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }, + { number: 5, title: "Z2-PRED", headRefName: "cursor/z2-pred", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }, + { number: 10, title: "Z2-OLD", headRefName: "cursor/z2-old", updatedAt: "2026-09-25T11:00:00Z", isDraft: false }, + { number: 11, title: "Z2-PR", headRefName: "cursor/z2-pr", updatedAt: "2026-09-25T11:00:00Z", isDraft: false }, + ], + }); + assert.deepEqual(openOk.inProgressWithoutPr.map((r) => r.id), ["Z2-IA"]); + + const missing = collectHygiene({ + ...input, + planText: [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + "| Z2-MISS | missing draft | o | – | egal | B | Draft #9 | a | – |", + "", + ].join("\n"), + prsOpen: [], + }); + assert.deepEqual(missing.inProgressWithoutPr.map((r) => r.id), ["Z2-MISS"]); + assert.deepEqual(missing.inProgressWithoutPr[0].prNumbers, [9]); +}); From 5cb73e124779e0572b47d7c6da366bd0e625ac5c Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:10:35 +0200 Subject: [PATCH 2/6] fix(hygiene): parse Draft Ready and In Arbeit PLAN status tokens Test-First: scripts/lib/dev-hygiene.test.mjs::current PLAN Draft Ready and In Arbeit statuses are recognized Co-authored-by: Cursor --- scripts/dev/hygiene.mjs | 27 +++++++++++++++++++++++++-- scripts/lib/dev-hygiene.test.mjs | 2 +- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/dev/hygiene.mjs b/scripts/dev/hygiene.mjs index 011ffd99..c82e57d7 100644 --- a/scripts/dev/hygiene.mjs +++ b/scripts/dev/hygiene.mjs @@ -77,6 +77,29 @@ export function hasStatusTable(md) { return lines.some((_, i) => statusColumn(lines, i) !== -1); } +// Current PLAN tokens (Draft / Ready / In Arbeit) plus the legacy "in Arbeit" / +// "PR #n" forms. Candidate PRs come from the leading status marker only — +// bare "#n" or "PR #n" inside later explanations (Vorgänger, früher, …) is ignored. +const IN_PROGRESS_STATUS = /^(?:in Arbeit|PR #\d|Drafts? #\d|Ready #\d)/i; + +function candidatePrNumbers(status) { + const s = String(status); + let m = /^Drafts #(\d+)((?:\/#\d+)*)/i.exec(s); + if (m) { + const out = [Number(m[1])]; + for (const n of m[2].matchAll(/\/#(\d+)/g)) out.push(Number(n[1])); + return out; + } + m = /^(?:Draft|Ready|PR) #(\d+)/i.exec(s); + if (m) return [Number(m[1])]; + if (/^in Arbeit/i.test(s)) { + // Drop parenthetical asides so "Vorgänger PR #99" inside them is not a candidate. + const head = s.replace(/\([^)]*\)/g, ""); + return [...head.matchAll(/\bPR #(\d+)/gi)].map((x) => Number(x[1])); + } + return []; +} + export function inProgressPackages(md) { const lines = String(md).split(/\r?\n/); const out = []; @@ -86,8 +109,8 @@ export function inProgressPackages(md) { for (let j = i + 2; j < lines.length && lines[j].startsWith("|"); j++) { const c = cells(lines[j]); const status = c[statusIdx] || ""; - if (!/^(in Arbeit|PR #\d)/.test(status)) continue; - out.push({ id: c[0], title: c[1] || "", status, prNumbers: [...status.matchAll(/PR #(\d+)/g)].map((m) => Number(m[1])) }); + if (!IN_PROGRESS_STATUS.test(status)) continue; + out.push({ id: c[0], title: c[1] || "", status, prNumbers: candidatePrNumbers(status) }); } } return out; diff --git a/scripts/lib/dev-hygiene.test.mjs b/scripts/lib/dev-hygiene.test.mjs index 2059b0c9..bcd16c8b 100644 --- a/scripts/lib/dev-hygiene.test.mjs +++ b/scripts/lib/dev-hygiene.test.mjs @@ -92,7 +92,7 @@ test("inProgressPackages reads the Stand column of the milestone tables in docs/ function assertInProgressRows(rows) { for (const r of rows) { - assert.match(r.status, /^(in Arbeit|PR #\d)/); + assert.match(r.status, /^(?:in Arbeit|PR #\d|Drafts? #\d|Ready #\d)/i); assert.match(r.id, /^[A-Z]/); } } From 1149a73eb0fa4c6b519266a53b1acc768f613395 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:38:33 +0200 Subject: [PATCH 3/6] test(hygiene): tighten In Arbeit Drafts and PR number boundaries Test-First: scripts/lib/dev-hygiene.test.mjs::In Arbeit candidate PRs exclude later historical PR mentions Test-First: scripts/lib/dev-hygiene.test.mjs::Drafts candidates allow SHA and text between PR numbers Test-First: scripts/lib/dev-hygiene.test.mjs::Draft and Ready PR numbers require a boundary after the digits Co-authored-by: Cursor --- scripts/lib/dev-hygiene.test.mjs | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/scripts/lib/dev-hygiene.test.mjs b/scripts/lib/dev-hygiene.test.mjs index bcd16c8b..cd6625ae 100644 --- a/scripts/lib/dev-hygiene.test.mjs +++ b/scripts/lib/dev-hygiene.test.mjs @@ -396,3 +396,66 @@ test("current PLAN Draft Ready and In Arbeit statuses are recognized", () => { assert.deepEqual(missing.inProgressWithoutPr.map((r) => r.id), ["Z2-MISS"]); assert.deepEqual(missing.inProgressWithoutPr[0].prNumbers, [9]); }); + +test("In Arbeit candidate PRs exclude later historical PR mentions", () => { + const plan = [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + "| Z2-IA1 | running | o | – | egal | B | In Arbeit PR #1; Vorgänger PR #99 | a | – |", + "", + ].join("\n"); + const rows = inProgressPackages(plan); + assert.deepEqual(rows.map((r) => [r.id, r.prNumbers]), [["Z2-IA1", [1]]]); + + // Only the unrelated predecessor is open — the status's own candidate #1 is missing. + const f = collectHygiene({ + ...input, + planText: plan, + prsOpen: [{ number: 99, title: "other", headRefName: "cursor/other", updatedAt: "2026-09-25T11:00:00Z", isDraft: false }], + }); + assert.deepEqual(f.inProgressWithoutPr.map((r) => r.id), ["Z2-IA1"]); + assert.deepEqual(f.inProgressWithoutPr[0].prNumbers, [1]); +}); + +test("Drafts candidates allow SHA and text between PR numbers", () => { + const status = "Drafts #671 `5d31ef5` / #674 `39bc839`; earlier note PR #12"; + const plan = [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + `| DR-04c | dual draft | o | – | egal | B | ${status} | a | – |`, + "", + ].join("\n"); + const rows = inProgressPackages(plan); + assert.deepEqual(rows.map((r) => [r.id, r.prNumbers]), [["DR-04c", [671, 674]]]); + + // One of the two candidates open — must not report "without open PR". + const f = collectHygiene({ + ...input, + planText: plan, + prsOpen: [{ number: 674, title: "DR-04c", headRefName: "cursor/dr-04c-b", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }], + }); + assert.deepEqual(f.inProgressWithoutPr.map((r) => r.id), []); +}); + +test("Draft and Ready PR numbers require a boundary after the digits", () => { + const plan = [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + "| Z2-BAD-D | typo draft | o | – | egal | B | Draft #1oops | a | – |", + "| Z2-BAD-R | typo ready | o | – | egal | B | Ready #2oops | a | – |", + "| Z2-OK-D | ok draft | o | – | egal | B | Draft #1 | a | – |", + "| Z2-OK-R | ok ready | o | – | egal | B | Ready #2 | a | – |", + "", + ].join("\n"); + const rows = inProgressPackages(plan); + assert.deepEqual( + rows.map((r) => [r.id, r.prNumbers]), + [ + ["Z2-OK-D", [1]], + ["Z2-OK-R", [2]], + ], + ); +}); From 794d9f5f0b71dece4fab731df5884aa52c020975 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:39:07 +0200 Subject: [PATCH 4/6] fix(hygiene): parse In Arbeit and Drafts candidate PRs precisely Test-First: scripts/lib/dev-hygiene.test.mjs::In Arbeit candidate PRs exclude later historical PR mentions Test-First: scripts/lib/dev-hygiene.test.mjs::Drafts candidates allow SHA and text between PR numbers Test-First: scripts/lib/dev-hygiene.test.mjs::Draft and Ready PR numbers require a boundary after the digits Co-authored-by: Cursor --- scripts/dev/hygiene.mjs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/dev/hygiene.mjs b/scripts/dev/hygiene.mjs index c82e57d7..9adae48d 100644 --- a/scripts/dev/hygiene.mjs +++ b/scripts/dev/hygiene.mjs @@ -80,23 +80,25 @@ export function hasStatusTable(md) { // Current PLAN tokens (Draft / Ready / In Arbeit) plus the legacy "in Arbeit" / // "PR #n" forms. Candidate PRs come from the leading status marker only — // bare "#n" or "PR #n" inside later explanations (Vorgänger, früher, …) is ignored. -const IN_PROGRESS_STATUS = /^(?:in Arbeit|PR #\d|Drafts? #\d|Ready #\d)/i; +// Digits must end at a non-alphanumeric boundary so "Draft #1oops" is not a match. +const PR_NUM = String.raw`#(\d+)(?![0-9A-Za-z])`; +const IN_PROGRESS_STATUS = new RegExp(String.raw`^(?:in Arbeit|PR ${PR_NUM}|Drafts? ${PR_NUM}|Ready ${PR_NUM})`, "i"); function candidatePrNumbers(status) { const s = String(status); - let m = /^Drafts #(\d+)((?:\/#\d+)*)/i.exec(s); + // Drafts: take every #n in the leading candidate list (SHA/text between + // "/ #n" is allowed); stop before ";" or "(" explanations. + let m = new RegExp(String.raw`^Drafts ${PR_NUM}`, "i").exec(s); if (m) { - const out = [Number(m[1])]; - for (const n of m[2].matchAll(/\/#(\d+)/g)) out.push(Number(n[1])); - return out; + const cut = s.search(/[;(]/); + const region = cut === -1 ? s : s.slice(0, cut); + return [...region.matchAll(new RegExp(PR_NUM, "g"))].map((x) => Number(x[1])); } - m = /^(?:Draft|Ready|PR) #(\d+)/i.exec(s); + m = new RegExp(String.raw`^(?:Draft|Ready|PR) ${PR_NUM}`, "i").exec(s); if (m) return [Number(m[1])]; - if (/^in Arbeit/i.test(s)) { - // Drop parenthetical asides so "Vorgänger PR #99" inside them is not a candidate. - const head = s.replace(/\([^)]*\)/g, ""); - return [...head.matchAll(/\bPR #(\d+)/gi)].map((x) => Number(x[1])); - } + // In Arbeit: only the optional PR immediately after the status token. + m = new RegExp(String.raw`^in Arbeit(?: PR ${PR_NUM})?`, "i").exec(s); + if (m) return m[1] ? [Number(m[1])] : []; return []; } From 47801c17a70e18610b69a304a8c87e53287b13fa Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:16:44 +0200 Subject: [PATCH 5/6] test(hygiene): Drafts list must ignore historical PR after a comma Test-First: scripts/lib/dev-hygiene.test.mjs::Drafts candidates exclude later historical PR mentions after a comma Co-authored-by: Cursor --- scripts/lib/dev-hygiene.test.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/scripts/lib/dev-hygiene.test.mjs b/scripts/lib/dev-hygiene.test.mjs index cd6625ae..79717d69 100644 --- a/scripts/lib/dev-hygiene.test.mjs +++ b/scripts/lib/dev-hygiene.test.mjs @@ -459,3 +459,28 @@ test("Draft and Ready PR numbers require a boundary after the digits", () => { ], ); }); + +test("Drafts candidates exclude later historical PR mentions after a comma", () => { + const planText = [ + "| ID | Title | Status |", + "|---|---|---|", + "| Z2-PROBE | probe | Drafts #1/#2, Vorgänger PR #99 |", + "", + ].join("\n"); + const rows = inProgressPackages(planText); + assert.deepEqual(rows[0].prNumbers, [1, 2]); + + // Only the unrelated predecessor is open — candidates #1/#2 are missing. + const hints = collectHygiene({ + now: NOW, + planText, + prsOpen: [{ number: 99, title: "unrelated", headRefName: "cursor/unrelated", updatedAt: "2026-09-25T11:00:00Z" }], + prsAll: [], + remoteBranches: [], + standText: "## Aktive Specs\n", + erledigtText: "| Datum | ID |\n", + untracked: [], + }).inProgressWithoutPr; + assert.equal(hints.length, 1); + assert.deepEqual(hints[0].prNumbers, [1, 2]); +}); From dab22744b1ded2f93b8be8c1d10522c44cb2f126 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:17:17 +0200 Subject: [PATCH 6/6] fix(hygiene): stop Drafts candidate scan before free-text explanations Test-First: scripts/lib/dev-hygiene.test.mjs::Drafts candidates exclude later historical PR mentions after a comma Co-authored-by: Cursor --- scripts/dev/hygiene.mjs | 48 +++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/scripts/dev/hygiene.mjs b/scripts/dev/hygiene.mjs index 9adae48d..3c8ef0a2 100644 --- a/scripts/dev/hygiene.mjs +++ b/scripts/dev/hygiene.mjs @@ -84,17 +84,47 @@ export function hasStatusTable(md) { const PR_NUM = String.raw`#(\d+)(?![0-9A-Za-z])`; const IN_PROGRESS_STATUS = new RegExp(String.raw`^(?:in Arbeit|PR ${PR_NUM}|Drafts? ${PR_NUM}|Ready ${PR_NUM})`, "i"); +// Leading Drafts candidate list: #n, optional `sha` / bare hex between `/` +// separators. Later free-text explanations (", Vorgänger PR #99", "; …") stop +// the scan — they are not candidates. +function draftsCandidateNumbers(status) { + const prefix = /^Drafts\s+/i.exec(status); + if (!prefix) return null; + let pos = prefix[0].length; + const nums = []; + const takeNum = () => { + const m = /^#(\d+)(?![0-9A-Za-z])/.exec(status.slice(pos)); + if (!m) return null; + pos += m[0].length; + return Number(m[1]); + }; + const skipDecor = () => { + while (true) { + const m = /^(?:\s+|`[^`]*`|[0-9a-f]{7,40}\b)/i.exec(status.slice(pos)); + if (!m) break; + pos += m[0].length; + } + }; + const first = takeNum(); + if (first == null) return []; + nums.push(first); + skipDecor(); + while (status[pos] === "/") { + pos += 1; + skipDecor(); + const n = takeNum(); + if (n == null) break; + nums.push(n); + skipDecor(); + } + return nums; +} + function candidatePrNumbers(status) { const s = String(status); - // Drafts: take every #n in the leading candidate list (SHA/text between - // "/ #n" is allowed); stop before ";" or "(" explanations. - let m = new RegExp(String.raw`^Drafts ${PR_NUM}`, "i").exec(s); - if (m) { - const cut = s.search(/[;(]/); - const region = cut === -1 ? s : s.slice(0, cut); - return [...region.matchAll(new RegExp(PR_NUM, "g"))].map((x) => Number(x[1])); - } - m = new RegExp(String.raw`^(?:Draft|Ready|PR) ${PR_NUM}`, "i").exec(s); + const drafts = draftsCandidateNumbers(s); + if (drafts) return drafts; + let m = new RegExp(String.raw`^(?:Draft|Ready|PR) ${PR_NUM}`, "i").exec(s); if (m) return [Number(m[1])]; // In Arbeit: only the optional PR immediately after the status token. m = new RegExp(String.raw`^in Arbeit(?: PR ${PR_NUM})?`, "i").exec(s);