From 26879ba5a60667ec321b57ae60a1e679f25d6ebf Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 2 Jul 2026 21:31:14 -0700 Subject: [PATCH 1/7] ci: add self-contained stacked-PR breadcrumb workflow Ports taskless/taskless's stack-breadcrumb as a portable GitHub Actions workflow: a zero-dependency CJS module loaded via actions/github-script, no pnpm/tsx/package. Two-stage dispatch/reconcile serialized per stack root; logic covered by a node:test suite wired into CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/stack-breadcrumb.cjs | 324 ++++++++++++++++++++++ .github/scripts/stack-breadcrumb.test.cjs | 260 +++++++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/stack-breadcrumb.yml | 145 ++++++++++ eslint.config.js | 3 + 5 files changed, 735 insertions(+) create mode 100644 .github/scripts/stack-breadcrumb.cjs create mode 100644 .github/scripts/stack-breadcrumb.test.cjs create mode 100644 .github/workflows/stack-breadcrumb.yml diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs new file mode 100644 index 00000000..dd8d83e2 --- /dev/null +++ b/.github/scripts/stack-breadcrumb.cjs @@ -0,0 +1,324 @@ +"use strict"; + +/** + * Stack breadcrumb — pure, zero-dependency logic. + * + * A portable port of the `@taskless/stack-breadcrumb` package's core: it derives + * a stacked-PR forest from `base`/`head` relationships, renders a breadcrumb + * region, and plans the minimal body edits to keep every PR in a stack pointing + * at its siblings. There is NO GitHub I/O here — the workflow supplies the PR + * list (via `actions/github-script`'s octokit) and a `writeBody` callback, so + * this file needs no npm install, no build step, and stays unit-testable with + * `node --test`. + * + * The two-stage workflow keeps reconciliation serialized per stack root: + * stage 1 (dispatch) — on PR shape events, resolve the affected root(s) and + * fire a `repository_dispatch`. + * stage 2 (reconcile) — on that dispatch, render and propagate the breadcrumb. + */ + +// --------------------------------------------------------------------------- +// Marker-region surgery +// +// Each managed PR body carries at most one region delimited by +// `` … ``. Splicing leaves all other +// bytes untouched (so a git-town breadcrumb or any other content survives); +// removing a region collapses the blank lines it left behind. +// --------------------------------------------------------------------------- + +/** Matches the whole region, opening marker through ``. */ +const REGION_PATTERN = /[\S\s]*?/; + +/** Matches just the opening marker, capturing `root` and the `pr=` list. */ +const OPEN_MARKER_PATTERN = //; + +/** Parse the opening marker's `root` and ordered `pr=` membership list, if present. */ +function parseStackComment(body) { + const match = OPEN_MARKER_PATTERN.exec(body); + if (!match) { + return undefined; + } + const root = Number(match[1]); + const members = match[2] + .split(",") + .filter((part) => part.length > 0) + .map(Number); + return { root, members }; +} + +/** Return the existing region (including both markers), or `undefined` if absent. */ +function getRegion(body) { + const match = REGION_PATTERN.exec(body); + return match ? match[0] : undefined; +} + +/** + * Splice `region` into `body`: + * - an empty `region` removes an existing region (non-stacked PR); + * - an existing region is replaced in place; + * - otherwise the region is appended after a blank line. + * Content outside the markers is preserved. + */ +function spliceRegion(body, region) { + const existing = getRegion(body); + + if (region.length === 0) { + if (!existing) { + return body; + } + return body + .replace(REGION_PATTERN, "") + .replace(/\n{3,}/g, "\n\n") + .trimEnd(); + } + + if (existing) { + // Function replacer so `$` sequences in a PR title are not treated as + // `String.replace` special patterns ($&, $1, …). + return body.replace(REGION_PATTERN, () => region); + } + + const trimmed = body.trimEnd(); + return trimmed.length === 0 ? region : `${trimmed}\n\n${region}`; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +const HERE_PREFIX = "➡️ "; // ➡️ +const HERE_SUFFIX = " (you are here)"; +const STACK_HEADING = "**Stack** (root → tip):"; // root → tip + +/** Render the full `` … `` region, or `''` for a non-stacked PR. */ +function renderRegion(tree, currentNumber) { + if (tree.members.length <= 1) { + return ""; + } + + const open = ``; + const lines = [open, STACK_HEADING, ""]; + + const renderNode = (number_, depth) => { + const indent = " ".repeat(depth); + lines.push( + number_ === currentNumber + ? `${indent}- ${HERE_PREFIX}#${number_}${HERE_SUFFIX}` + : `${indent}- #${number_}` + ); + for (const child of tree.childrenOf.get(number_) ?? []) { + renderNode(child, depth + 1); + } + }; + + renderNode(tree.root, 0); + lines.push(""); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Tree derivation (pure, from the open-PR list) +// --------------------------------------------------------------------------- + +function indexPullRequests(pullRequests) { + const byNumber = new Map(); + const byHead = new Map(); + for (const pullRequest of pullRequests) { + byNumber.set(pullRequest.number, pullRequest); + byHead.set(pullRequest.headRefName, pullRequest); + } + return { byNumber, byHead }; +} + +function parentOf(pullRequest, byHead, defaultBranch) { + if (pullRequest.baseRefName === defaultBranch) { + return undefined; + } + return byHead.get(pullRequest.baseRefName); +} + +/** Walk `base` pointers up from `startNumber` to the root of its stack. */ +function findRoot(startNumber, pullRequests, defaultBranch) { + const { byNumber, byHead } = indexPullRequests(pullRequests); + let current = byNumber.get(startNumber); + if (!current) { + return undefined; + } + const seen = new Set(); + let parent = parentOf(current, byHead, defaultBranch); + while (parent && !seen.has(current.number)) { + seen.add(current.number); + current = parent; + parent = parentOf(current, byHead, defaultBranch); + } + return current.number; +} + +/** Build the tree rooted at `rootNumber`: DFS pre-order members + sorted child map. */ +function buildTree(rootNumber, pullRequests) { + const { byNumber } = indexPullRequests(pullRequests); + + const childrenByHead = new Map(); + for (const pullRequest of pullRequests) { + const siblings = childrenByHead.get(pullRequest.baseRefName) ?? []; + siblings.push(pullRequest.number); + childrenByHead.set(pullRequest.baseRefName, siblings); + } + + const childrenOf = new Map(); + const members = []; + const visited = new Set(); + + const visit = (number_) => { + const node = byNumber.get(number_); + // Guard against a base/head cycle (A←B and B←A): never visit a PR twice. + if (!node || visited.has(number_)) { + return; + } + visited.add(number_); + members.push(number_); + const childNumbers = (childrenByHead.get(node.headRefName) ?? []) + .filter((candidate) => candidate !== number_) + .toSorted((a, b) => a - b); + childrenOf.set(number_, childNumbers); + for (const child of childNumbers) { + visit(child); + } + }; + + visit(rootNumber); + return { root: rootNumber, members, childrenOf }; +} + +/** Find the root of `triggerNumber`'s stack, then build the tree. */ +function resolveTree(triggerNumber, pullRequests, defaultBranch) { + const root = findRoot(triggerNumber, pullRequests, defaultBranch); + if (root === undefined) { + return undefined; + } + return buildTree(root, pullRequests); +} + +/** + * Resolve the root(s) of the stack(s) a PR event affects. + * - An OPEN trigger affects exactly its own root. + * - A CLOSED/merged trigger's stack still needs a redraw: union the survivors + * found via the parent route (open PR headed at the closed PR's base) and the + * marker route (open PRs whose last breadcrumb listed the closed PR), then map + * each surviving member to its (possibly new) root. + */ +function resolveAffectedRoots(trigger, pullRequests, defaultBranch) { + const isOpen = pullRequests.some( + (pullRequest) => pullRequest.number === trigger.number + ); + if (isOpen) { + const root = findRoot(trigger.number, pullRequests, defaultBranch); + return root === undefined ? [] : [root]; + } + + const affected = new Set(); + for (const candidate of pullRequests) { + const isParentOfClosed = candidate.headRefName === trigger.baseRefName; + const marker = parseStackComment(candidate.body); + const markerNamedClosed = + marker !== undefined && + (marker.root === trigger.number || + marker.members.includes(trigger.number)); + if (isParentOfClosed || markerNamedClosed) { + affected.add(candidate.number); + } + } + + const roots = new Set(); + for (const number_ of affected) { + const root = findRoot(number_, pullRequests, defaultBranch); + if (root !== undefined) { + roots.add(root); + } + } + return [...roots].toSorted((a, b) => a - b); +} + +/** Every distinct stack root among the open PRs (deduped, ascending). */ +function findAllRoots(pullRequests, defaultBranch) { + const roots = new Set(); + for (const pullRequest of pullRequests) { + const root = findRoot(pullRequest.number, pullRequests, defaultBranch); + if (root !== undefined) { + roots.add(root); + } + } + return [...roots].toSorted((a, b) => a - b); +} + +// --------------------------------------------------------------------------- +// Reconcile orchestration +// --------------------------------------------------------------------------- + +/** Compute the new body for one PR given its desired region (`''` removes it). */ +function planUpdate(pullRequest, region) { + const body = spliceRegion(pullRequest.body, region); + return { + number: pullRequest.number, + changed: body !== pullRequest.body, + body, + }; +} + +/** + * Reconcile the tree rooted at `root`: render the breadcrumb for each member and + * write only changed bodies via `deps.writeBody`. A write that throws is logged + * and the PR is recorded as failed; the rest of the tree still reconciles. + */ +async function reconcile(root, deps) { + const { pullRequests, writeBody } = deps; + const byNumber = new Map( + pullRequests.map((pullRequest) => [pullRequest.number, pullRequest]) + ); + const tree = buildTree(root, pullRequests); + + const updated = []; + const skipped = []; + const failed = []; + + for (const number_ of tree.members) { + const pullRequest = byNumber.get(number_); + if (!pullRequest) { + continue; + } + const region = renderRegion(tree, number_); + const plan = planUpdate(pullRequest, region); + if (!plan.changed) { + skipped.push(number_); + continue; + } + try { + await writeBody(number_, plan.body); + updated.push(number_); + } catch (error) { + console.error( + `stack-breadcrumb: could not update PR #${number_}: ${String(error)}` + ); + failed.push(number_); + } + } + + return { root, updated, skipped, failed }; +} + +module.exports = { + parseStackComment, + getRegion, + spliceRegion, + renderRegion, + findRoot, + buildTree, + resolveTree, + resolveAffectedRoots, + findAllRoots, + planUpdate, + reconcile, + HERE_PREFIX, + HERE_SUFFIX, + STACK_HEADING, +}; diff --git a/.github/scripts/stack-breadcrumb.test.cjs b/.github/scripts/stack-breadcrumb.test.cjs new file mode 100644 index 00000000..6c0df7f5 --- /dev/null +++ b/.github/scripts/stack-breadcrumb.test.cjs @@ -0,0 +1,260 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + parseStackComment, + getRegion, + spliceRegion, + renderRegion, + findRoot, + buildTree, + resolveTree, + resolveAffectedRoots, + findAllRoots, + planUpdate, + reconcile, +} = require("./stack-breadcrumb.cjs"); + +const DEFAULT_BRANCH = "main"; + +function pr(number, head, base, body = "") { + return { number, title: `PR ${number}`, headRefName: head, baseRefName: base, body }; +} + +const REGION = [ + "", + "**Stack** (root → tip):", + "", + "- #10", + " - ➡️ #11 (you are here)", + " - #12", + "", +].join("\n"); + +test("parseStackComment: parses root and ordered membership", () => { + assert.deepEqual(parseStackComment(`intro\n\n${REGION}`), { + root: 10, + members: [10, 11, 12], + }); +}); + +test("parseStackComment: parses an empty membership list", () => { + assert.deepEqual( + parseStackComment("\nx\n"), + { root: 10, members: [] } + ); +}); + +test("parseStackComment: undefined when no marker", () => { + assert.equal(parseStackComment("just a normal PR body"), undefined); +}); + +test("getRegion: extracts the full region", () => { + assert.equal(getRegion(`before\n${REGION}\nafter`), REGION); +}); + +test("getRegion: undefined when absent", () => { + assert.equal(getRegion("no region here"), undefined); +}); + +test("spliceRegion: appends to a body with none", () => { + assert.equal(spliceRegion("Original body.", REGION), `Original body.\n\n${REGION}`); +}); + +test("spliceRegion: returns just the region for an empty body", () => { + assert.equal(spliceRegion("", REGION), REGION); +}); + +test("spliceRegion: replaces an existing region in place", () => { + const oldRegion = "\nold\n"; + const body = `top\n\n${oldRegion}\n\nbottom`; + assert.equal(spliceRegion(body, REGION), `top\n\n${REGION}\n\nbottom`); +}); + +test("spliceRegion: removes the region when given an empty region", () => { + assert.equal(spliceRegion(`keep this\n\n${REGION}`, ""), "keep this"); +}); + +test("spliceRegion: no-op removing when no region exists", () => { + assert.equal(spliceRegion("nothing to remove", ""), "nothing to remove"); +}); + +test("spliceRegion: skip-when-equal is a no-op", () => { + const body = `intro\n\n${REGION}\n\noutro`; + assert.equal(spliceRegion(body, REGION), body); +}); + +test("spliceRegion: does not treat $ sequences as replacement patterns", () => { + const dollarRegion = + "\ncost is $1 and $& too\n"; + const body = "\nold\n"; + assert.equal(spliceRegion(body, dollarRegion), dollarRegion); +}); + +const chain = [pr(10, "a", DEFAULT_BRANCH), pr(11, "b", "a"), pr(12, "c", "b")]; +const branching = [ + pr(10, "a", DEFAULT_BRANCH), + pr(11, "b", "a"), + pr(12, "c", "a"), + pr(13, "d", "b"), +]; + +test("renderRegion: nested list with current PR marked", () => { + assert.equal(renderRegion(buildTree(10, chain), 11), REGION); +}); + +test("renderRegion: empty for a non-stacked single-member tree", () => { + assert.equal(renderRegion(buildTree(10, [pr(10, "a", "main")]), 10), ""); +}); + +test("findRoot: walks a chain up to the root", () => { + assert.equal(findRoot(12, chain, DEFAULT_BRANCH), 10); + assert.equal(findRoot(11, chain, DEFAULT_BRANCH), 10); + assert.equal(findRoot(10, chain, DEFAULT_BRANCH), 10); +}); + +test("findRoot: resolves from any branch of a branching tree", () => { + assert.equal(findRoot(13, branching, DEFAULT_BRANCH), 10); + assert.equal(findRoot(12, branching, DEFAULT_BRANCH), 10); +}); + +test("findRoot: a PR whose base has no open PR is its own root", () => { + assert.equal(findRoot(11, [pr(11, "b", "deleted-parent")], DEFAULT_BRANCH), 11); +}); + +test("findRoot: undefined for a PR that is not open", () => { + assert.equal(findRoot(99, chain, DEFAULT_BRANCH), undefined); +}); + +test("buildTree: DFS pre-order for a chain", () => { + assert.deepEqual(buildTree(10, chain).members, [10, 11, 12]); +}); + +test("buildTree: DFS pre-order with sorted children for a branching tree", () => { + assert.deepEqual(buildTree(10, branching).members, [10, 11, 13, 12]); +}); + +test("buildTree: records sorted children per node", () => { + const tree = buildTree(10, branching); + assert.deepEqual(tree.childrenOf.get(10), [11, 12]); + assert.deepEqual(tree.childrenOf.get(11), [13]); + assert.deepEqual(tree.childrenOf.get(13), []); +}); + +test("buildTree: terminates on a base/head cycle, visiting each once", () => { + const cycle = [pr(10, "a", "b"), pr(11, "b", "a")]; + assert.deepEqual(buildTree(10, cycle).members, [10, 11]); +}); + +test("resolveTree: finds root and builds tree in one step", () => { + const tree = resolveTree(12, chain, DEFAULT_BRANCH); + assert.equal(tree.root, 10); + assert.deepEqual(tree.members, [10, 11, 12]); +}); + +test("resolveTree: undefined when the trigger is not open", () => { + assert.equal(resolveTree(99, chain, DEFAULT_BRANCH), undefined); +}); + +test("findAllRoots: each distinct root once, ascending", () => { + const twoStacks = [...chain, pr(20, "x", DEFAULT_BRANCH), pr(21, "y", "x")]; + assert.deepEqual(findAllRoots(twoStacks, DEFAULT_BRANCH), [10, 20]); +}); + +test("findAllRoots: empty when there are no open PRs", () => { + assert.deepEqual(findAllRoots([], DEFAULT_BRANCH), []); +}); + +test("resolveAffectedRoots: open trigger returns its own root", () => { + assert.deepEqual( + resolveAffectedRoots({ number: 12, baseRefName: "b" }, chain, DEFAULT_BRANCH), + [10] + ); +}); + +test("resolveAffectedRoots: parent route when a leaf closes", () => { + const open = [pr(10, "a", DEFAULT_BRANCH), pr(11, "b", "a")]; + assert.deepEqual( + resolveAffectedRoots({ number: 12, baseRefName: "b" }, open, DEFAULT_BRANCH), + [10] + ); +}); + +test("resolveAffectedRoots: marker route when the root merges and children retarget", () => { + const marker = "\nx\n"; + const open = [pr(11, "b", DEFAULT_BRANCH, marker), pr(12, "c", "b", marker)]; + assert.deepEqual( + resolveAffectedRoots( + { number: 10, baseRefName: DEFAULT_BRANCH }, + open, + DEFAULT_BRANCH + ), + [11] + ); +}); + +test("resolveAffectedRoots: nothing when a closed PR affects no open stack", () => { + assert.deepEqual( + resolveAffectedRoots( + { number: 99, baseRefName: "gone" }, + [pr(10, "a", DEFAULT_BRANCH)], + DEFAULT_BRANCH + ), + [] + ); +}); + +test("planUpdate: appends the region and flags a change", () => { + const plan = planUpdate(pr(10, "a", "main", "intro"), REGION); + assert.equal(plan.changed, true); + assert.ok(plan.body.includes(REGION)); +}); + +test("planUpdate: skips when the body already has the region", () => { + const plan = planUpdate(pr(10, "a", "main", `intro\n\n${REGION}`), REGION); + assert.equal(plan.changed, false); +}); + +test("planUpdate: removes the region when the desired region is empty", () => { + const plan = planUpdate(pr(10, "a", "main", `x\n\n${REGION}`), ""); + assert.equal(plan.changed, true); + assert.ok(!plan.body.includes(" region, so a stray/pasted opening marker without a closing tag is no longer mistaken for a managed region (which could wrongly pull a PR into the resolveAffectedRoots marker route). Also correct the marker-surgery comment to describe spliceRegion's actual whitespace normalization. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/stack-breadcrumb.cjs | 20 +++++++++++++++----- .github/scripts/stack-breadcrumb.test.cjs | 8 ++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs index dd8d83e2..95f3bd02 100644 --- a/.github/scripts/stack-breadcrumb.cjs +++ b/.github/scripts/stack-breadcrumb.cjs @@ -21,9 +21,11 @@ // Marker-region surgery // // Each managed PR body carries at most one region delimited by -// `` … ``. Splicing leaves all other -// bytes untouched (so a git-town breadcrumb or any other content survives); -// removing a region collapses the blank lines it left behind. +// `` … ``. Replacing a region in place +// leaves every other byte untouched, so a git-town breadcrumb or any other +// content is never disturbed. Appending trims the body's trailing whitespace +// before adding the region after a blank line; removing collapses the blank +// lines the region left behind and trims trailing whitespace. // --------------------------------------------------------------------------- /** Matches the whole region, opening marker through ``. */ @@ -32,9 +34,17 @@ const REGION_PATTERN = /[\S\s]*?/; /** Matches just the opening marker, capturing `root` and the `pr=` list. */ const OPEN_MARKER_PATTERN = //; -/** Parse the opening marker's `root` and ordered `pr=` membership list, if present. */ +/** + * Parse the opening marker's `root` and ordered `pr=` membership list — but only + * inside a COMPLETE region (both markers present), so a stray or pasted opening + * marker without its closing tag is not mistaken for a managed region. + */ function parseStackComment(body) { - const match = OPEN_MARKER_PATTERN.exec(body); + const region = getRegion(body); + if (region === undefined) { + return undefined; + } + const match = OPEN_MARKER_PATTERN.exec(region); if (!match) { return undefined; } diff --git a/.github/scripts/stack-breadcrumb.test.cjs b/.github/scripts/stack-breadcrumb.test.cjs index 6c0df7f5..a4bd93d7 100644 --- a/.github/scripts/stack-breadcrumb.test.cjs +++ b/.github/scripts/stack-breadcrumb.test.cjs @@ -51,6 +51,14 @@ test("parseStackComment: undefined when no marker", () => { assert.equal(parseStackComment("just a normal PR body"), undefined); }); +test("parseStackComment: undefined for a bare opening marker without a closing tag", () => { + // A pasted/stray opening marker must not be treated as a managed region. + assert.equal( + parseStackComment("intro\n\nno closing tag"), + undefined + ); +}); + test("getRegion: extracts the full region", () => { assert.equal(getRegion(`before\n${REGION}\nafter`), REGION); }); From 48de74e73d92668712863337d2bf520683199904 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 2 Jul 2026 23:21:31 -0700 Subject: [PATCH 6/7] ci: fail the reconcile job when any PR body write fails reconcile() still continues past an individual failed write (safe failure), but the job now collects the failed PRs and calls setFailed so an inconsistent breadcrumb surfaces in CI instead of passing silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/stack-breadcrumb.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/stack-breadcrumb.yml b/.github/workflows/stack-breadcrumb.yml index 956c4a4f..f5e02304 100644 --- a/.github/workflows/stack-breadcrumb.yml +++ b/.github/workflows/stack-breadcrumb.yml @@ -202,7 +202,15 @@ jobs: } } + // reconcile() continues past an individual failed write (safe + // failure) and records it; surface any such failures as a job + // failure so an inconsistent breadcrumb doesn't pass silently. + const failures = []; for (const root of roots) { const result = await stack.reconcile(root, { pullRequests, writeBody }); core.info(`reconciled root #${root}: ${JSON.stringify(result)}`); + failures.push(...result.failed); + } + if (failures.length > 0) { + core.setFailed(`Failed to update ${failures.length} PR body/bodies: ${failures.map((n) => `#${n}`).join(", ")}. See logs above.`); } From 81e5616abf7e6d09d8662008f61c28bd82e27f55 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 2 Jul 2026 23:34:08 -0700 Subject: [PATCH 7/7] fix(stack-breadcrumb): localize blank-line cleanup on region removal Removing a stack region no longer globally collapses 3+ newline runs across the whole PR body (which could alter unrelated spacing); it now trims only the blank lines hugging the removed region and rejoins the surrounding text with a single blank line. Also reword the eslint-ignore comment (scripts are covered by node:test, not linted by it). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/stack-breadcrumb.cjs | 17 +++++++++++++---- .github/scripts/stack-breadcrumb.test.cjs | 10 ++++++++++ eslint.config.js | 4 ++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs index 95f3bd02..d7e7e67a 100644 --- a/.github/scripts/stack-breadcrumb.cjs +++ b/.github/scripts/stack-breadcrumb.cjs @@ -76,10 +76,19 @@ function spliceRegion(body, region) { if (!existing) { return body; } - return body - .replace(REGION_PATTERN, "") - .replace(/\n{3,}/g, "\n\n") - .trimEnd(); + // Remove the region and only the blank lines hugging it, rejoining the + // surrounding text with a single blank line. Spacing elsewhere in the body + // is left exactly as-is. + const start = body.indexOf(existing); + const before = body.slice(0, start).replace(/\n+$/, ""); + const after = body.slice(start + existing.length).replace(/^\n+/, ""); + if (before.length === 0) { + return after.trimEnd(); + } + if (after.length === 0) { + return before.trimEnd(); + } + return `${before}\n\n${after}`; } if (existing) { diff --git a/.github/scripts/stack-breadcrumb.test.cjs b/.github/scripts/stack-breadcrumb.test.cjs index a4bd93d7..15c42292 100644 --- a/.github/scripts/stack-breadcrumb.test.cjs +++ b/.github/scripts/stack-breadcrumb.test.cjs @@ -89,6 +89,16 @@ test("spliceRegion: no-op removing when no region exists", () => { assert.equal(spliceRegion("nothing to remove", ""), "nothing to remove"); }); +test("spliceRegion: removing preserves unrelated blank runs elsewhere", () => { + // A 3+ newline run unrelated to the region must survive removal. + const body = `line1\n\n\n\nline2\n\n${REGION}`; + assert.equal(spliceRegion(body, ""), "line1\n\n\n\nline2"); +}); + +test("spliceRegion: removing a middle region rejoins with one blank line", () => { + assert.equal(spliceRegion(`top\n\n${REGION}\n\nbottom`, ""), "top\n\nbottom"); +}); + test("spliceRegion: skip-when-equal is a no-op", () => { const body = `intro\n\n${REGION}\n\noutro`; assert.equal(spliceRegion(body, REGION), body); diff --git a/eslint.config.js b/eslint.config.js index 95c8bfcf..112c58c7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,8 +17,8 @@ export default tseslint.config( "openspec/", "**/test/fixtures/", "tmp/", - // Zero-dependency CommonJS workflow scripts (linted by their own node:test - // suite); the app's TS/ESM-oriented rules don't apply. + // Zero-dependency CommonJS workflow scripts (covered by their own + // node:test suite); the app's TS/ESM-oriented rules don't apply. ".github/scripts/", ], },