Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions scripts/dev/hygiene.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,61 @@ 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.
// 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");

// 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);
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);
if (m) return m[1] ? [Number(m[1])] : [];
return [];
}

export function inProgressPackages(md) {
const lines = String(md).split(/\r?\n/);
const out = [];
Expand All @@ -86,8 +141,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;
Expand Down
162 changes: 161 additions & 1 deletion scripts/lib/dev-hygiene.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]/);
}
}
Expand Down Expand Up @@ -324,3 +324,163 @@ 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]);
});

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]],
],
);
});

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]);
});
Loading