From 51686197e0b4ef9bd47b3ed2a690fa988871b42f Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:04:47 +0100 Subject: [PATCH 01/16] fix(log): add safe markdown section replacement --- scripts/lib/markdown.mjs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 scripts/lib/markdown.mjs diff --git a/scripts/lib/markdown.mjs b/scripts/lib/markdown.mjs new file mode 100644 index 0000000..862eb5d --- /dev/null +++ b/scripts/lib/markdown.mjs @@ -0,0 +1,29 @@ +export function replaceMarkdownSection(markdown, heading, value) { + if (typeof markdown !== "string") throw new TypeError("markdown must be a string."); + if (typeof heading !== "string" || !heading.trim() || /[\\r\\n]/.test(heading)) { + throw new TypeError("heading must be a non-empty single line."); + } + + const escaped = heading.replace(/[.*+?^{}()|[\\]\\\\]/g, "\\\\$&"); + const headingPattern = new RegExp(`^(#{1,6})[ \\t]+${escaped}[ \\t]*\\r?$`, "gm"); + const match = headingPattern.exec(markdown); + + if (!match) { + return `${markdown.trimEnd()}\\n\\n## ${heading}\\n${value.trim()}\\n`; + } + + const level = match[1].length; + let contentStart = match.index + match[0].length; + if (markdown[contentStart] === "\\n") contentStart += 1; + else if (markdown.slice(contentStart, contentStart + 2) === "\\r\\n") contentStart += 2; + + const boundaryPattern = new RegExp(`^#{1,${level}}[ \\t]+|^---[ \\t]*$|^$|^$`, "gm"); + boundaryPattern.lastIndex = contentStart; + const boundary = boundaryPattern.exec(markdown); + const end = boundary ? boundary.index : markdown.length; + const prefix = markdown.slice(0, contentStart); + const suffix = markdown.slice(end); + const replacement = `${value.trim()}\\n\\n`; + + return `${prefix}${replacement}${suffix.replace(/^\\n+/, "")}`; +} From 95aa2ec31551bbf8f2e90ecf90648569e055468c Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:04:49 +0100 Subject: [PATCH 02/16] test(log): cover markdown section replacement --- scripts/test/markdown.test.mjs | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 scripts/test/markdown.test.mjs diff --git a/scripts/test/markdown.test.mjs b/scripts/test/markdown.test.mjs new file mode 100644 index 0000000..a9c10d7 --- /dev/null +++ b/scripts/test/markdown.test.mjs @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { replaceMarkdownSection } from "../lib/markdown.mjs"; + +test("updates an existing template section without duplicating its heading", () => { + const source = "# Daily Log\n\n## Shipped\n- \n\n## Learned\n- \n"; + const result = replaceMarkdownSection(source, "Shipped", "- Delivered the capture fix."); + + assert.equal((result.match(/^## Shipped$/gm) ?? []).length, 1); + assert.match(result, /## Shipped\n- Delivered the capture fix\.\n\n## Learned/); +}); + +test("keeps later sections and activity markers outside the replaced section", () => { + const source = [ + "# Daily Log", + "", + "## Tomorrow", + "- ", + "", + "", + "## Shipped Today (GitHub activity)", + "old activity", + "", + "", + "---", + "", + ].join("\n"); + const result = replaceMarkdownSection(source, "Tomorrow", "- Review the PR."); + + assert.match(result, /## Tomorrow\n- Review the PR\./); + assert.match(result, /[\s\S]*old activity/); + assert.match(result, /\n---\n/); +}); + +test("appends a missing section using a level-two heading", () => { + const result = replaceMarkdownSection("# Daily Log\n", "Learned", "- A lesson."); + + assert.match(result, /## Learned\n- A lesson\.$/); +}); + +test("matches headings literally rather than treating punctuation as regex syntax", () => { + const source = "# Notes\n\n## What (I) built\n- old\n\n## Next\n- soon\n"; + const result = replaceMarkdownSection(source, "What (I) built", "- new"); + + assert.match(result, /## What \(I\) built\n- new\n\n## Next/); + assert.doesNotMatch(result, /- old/); +}); From c2623fffc0c3e3a30feb6ce63e2475bd8f843ae1 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:05:09 +0100 Subject: [PATCH 03/16] fix(log): correct markdown section parsing --- scripts/lib/markdown.mjs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/lib/markdown.mjs b/scripts/lib/markdown.mjs index 862eb5d..4d08521 100644 --- a/scripts/lib/markdown.mjs +++ b/scripts/lib/markdown.mjs @@ -1,29 +1,32 @@ export function replaceMarkdownSection(markdown, heading, value) { if (typeof markdown !== "string") throw new TypeError("markdown must be a string."); - if (typeof heading !== "string" || !heading.trim() || /[\\r\\n]/.test(heading)) { + if (typeof heading !== "string" || !heading.trim() || /[\r\n]/.test(heading)) { throw new TypeError("heading must be a non-empty single line."); } + if (typeof value !== "string") throw new TypeError("value must be a string."); - const escaped = heading.replace(/[.*+?^{}()|[\\]\\\\]/g, "\\\\$&"); - const headingPattern = new RegExp(`^(#{1,6})[ \\t]+${escaped}[ \\t]*\\r?$`, "gm"); + const escaped = heading.replace(/[.*+?^{}()|[\]\\]/g, "\\$&"); + const headingPattern = new RegExp("^(#{1,6})[ \\t]+" + escaped + "[ \\t]*\\r?$", "gm"); const match = headingPattern.exec(markdown); if (!match) { - return `${markdown.trimEnd()}\\n\\n## ${heading}\\n${value.trim()}\\n`; + return markdown.trimEnd() + "\n\n## " + heading + "\n" + value.trim() + "\n"; } const level = match[1].length; let contentStart = match.index + match[0].length; - if (markdown[contentStart] === "\\n") contentStart += 1; - else if (markdown.slice(contentStart, contentStart + 2) === "\\r\\n") contentStart += 2; + if (markdown.slice(contentStart, contentStart + 2) === "\r\n") contentStart += 2; + else if (markdown[contentStart] === "\n") contentStart += 1; - const boundaryPattern = new RegExp(`^#{1,${level}}[ \\t]+|^---[ \\t]*$|^$|^$`, "gm"); + const boundaryPattern = new RegExp( + "^#{1," + level + "}[ \\t]+|^---[ \\t]*$|^$|^$", + "gm", + ); boundaryPattern.lastIndex = contentStart; const boundary = boundaryPattern.exec(markdown); const end = boundary ? boundary.index : markdown.length; const prefix = markdown.slice(0, contentStart); const suffix = markdown.slice(end); - const replacement = `${value.trim()}\\n\\n`; - return `${prefix}${replacement}${suffix.replace(/^\\n+/, "")}`; + return prefix + value.trim() + "\n\n" + suffix.replace(/^\n+/, ""); } From 9c3af7e6303b87014e708ad88af270cfad0112d3 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:05:11 +0100 Subject: [PATCH 04/16] test(log): correct markdown parser regression cases From 2867eb8546f59640e17fc8286a53902632793258 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:05:56 +0100 Subject: [PATCH 05/16] fix(log): write issue answers into template sections --- scripts/issue-to-log.mjs | 45 +++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/scripts/issue-to-log.mjs b/scripts/issue-to-log.mjs index 919ef6c..e84ecd2 100644 --- a/scripts/issue-to-log.mjs +++ b/scripts/issue-to-log.mjs @@ -1,39 +1,36 @@ import { getFile, getIssue, putFile } from "./lib/github.mjs"; +import { replaceMarkdownSection } from "./lib/markdown.mjs"; const number = Number(process.env.ISSUE_NUMBER); if (!Number.isInteger(number) || number < 1) throw new Error("ISSUE_NUMBER must be a positive integer."); + const issue = await getIssue(number); -const dateMatch = issue.body.match(/^### Log date \(UTC\)\s*\r?\n+([^\r\n]+)/m); +const body = typeof issue.body === "string" ? issue.body : ""; +const dateMatch = body.match(/^### Log date \(UTC\)\s*\r?\n+([^\r\n]+)/m); const requestedDate = dateMatch?.[1]?.trim(); const date = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate ?? "") ? requestedDate : new Date().toISOString().slice(0, 10); -const parsed = new Date(`${date}T00:00:00Z`); -if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== date) throw new Error(`Invalid log date: ${date}`); -const [year, month] = date.split("-"); -const path = `logs/${year}/${month}/${date}.md`; +const parsed = new Date(date + "T00:00:00Z"); +if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== date) { + throw new Error("Invalid log date: " + date); +} +const [year, month] = date.split("-"); +const path = "logs/" + year + "/" + month + "/" + date + ".md"; const fields = Object.fromEntries(["Shipped", "Learned", "Blockers", "Tomorrow"].map((label) => { - const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = issue.body.match(new RegExp(`^### ${escaped}\\s*\\r?\\n+([\\s\\S]*?)(?=^### |$(?![\\s\\S]))`, "m")); - const value = (match?.[1] ?? "").trim(); + const escaped = label.replace(/[.*+?^{}()|[\]\\]/g, "\\$&").replaceAll("$", "\\$"); + const pattern = new RegExp("^### " + escaped + "\\s*\\r?\\n+([\\s\\S]*?)(?=^### |$(?![\\s\\S]))", "m"); + const value = (body.match(pattern)?.[1] ?? "").trim(); return [label, value && value !== "_No response_" ? value : "_Not provided._"]; })); -let file = await getFile(path); -let content = file?.content ?? `# Daily Log — ${date}\n\n**Day:** Captured from issue [#${number}](${issue.html_url})\n`; +const file = await getFile(path); +let content = file?.content ?? "# Daily Log — " + date + "\n\n**Day:** Captured from issue [#" + number + "](" + issue.html_url + ")\n"; for (const [heading, value] of Object.entries(fields)) { - content = replaceSection(content, heading, value); + content = replaceMarkdownSection(content, heading, value); } -if (!content.includes(`[issue #${number}]`)) content += `\n_Source: [issue #${number}](${issue.html_url})_\n`; -await putFile(path, content, `docs(log): save issue #${number} as ${date}`, file?.sha); -console.log(`Saved issue #${number} to ${path}.`); - -function replaceSection(markdown, heading, value) { - const marker = `### ${heading}`; - const start = markdown.indexOf(marker); - if (start < 0) return `${markdown.trimEnd()}\n\n${marker}\n${value}\n`; - const contentStart = markdown.indexOf("\n", start) + 1; - if (contentStart === 0) throw new Error(`Malformed section heading: ${heading}`); - const nextHeading = markdown.indexOf("\n### ", contentStart); - const suffix = nextHeading < 0 ? "" : markdown.slice(nextHeading + 1); - return `${markdown.slice(0, contentStart)}${value}\n\n${suffix}`; +if (!content.includes("[issue #" + number + "]")) { + content = content.trimEnd() + "\n\n_Source: [issue #" + number + "](" + issue.html_url + ")_\n"; } + +await putFile(path, content, "docs(log): save issue #" + number + " as " + date, file?.sha); +console.log("Saved issue #" + number + " to " + path + "."); From 8882a259578769ff35f9f6e673f9812f119d3920 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:05:58 +0100 Subject: [PATCH 06/16] fix(activity): preserve logs when collection is incomplete --- scripts/collect-activity.mjs | 98 +++++++++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/scripts/collect-activity.mjs b/scripts/collect-activity.mjs index 4bf4e2c..a90639a 100644 --- a/scripts/collect-activity.mjs +++ b/scripts/collect-activity.mjs @@ -1,61 +1,111 @@ import { activityApi as api, getFile, putFile } from "./lib/github.mjs"; const repository = process.env.GITHUB_REPOSITORY; -const [owner, repo] = repository.split("/"); +if (!repository || !/^[^/]+\/[^/]+$/.test(repository)) { + throw new Error("GITHUB_REPOSITORY must be in owner/repository format."); +} const configured = (process.env.GH_ACTIVITY_REPOS ?? "").split(/[\s,]+/).filter(Boolean); const repositories = [...new Set([repository, ...configured])]; const until = new Date(); const since = new Date(until.getTime() - 24 * 60 * 60 * 1000); const date = until.toISOString().slice(0, 10); const [year, month] = date.split("-"); -const path = `logs/${year}/${month}/${date}.md`; +const path = "logs/" + year + "/" + month + "/" + date + ".md"; const entry = await getFile(path); -if (!entry) throw new Error(`Daily entry does not exist: ${path}`); +if (!entry) throw new Error("Daily entry does not exist: " + path); const sections = []; +const failures = []; +let completedRepositories = 0; + for (const fullName of repositories) { - const [repoOwner, repoName] = fullName.split("/"); - if (!repoOwner || !repoName) { - console.warn(`Skipping invalid repository setting: ${fullName}`); + if (!/^[^/]+\/[^/]+$/.test(fullName)) { + failures.push({ repository: fullName, reason: "invalid owner/repository setting" }); + console.warn("Skipping invalid repository setting: " + fullName); continue; } + + const [repoOwner, repoName] = fullName.split("/"); try { - const query = new URLSearchParams({ since: since.toISOString(), until: until.toISOString(), per_page: "100" }); + const query = new URLSearchParams({ + since: since.toISOString(), + until: until.toISOString(), + per_page: "100", + }); const [commits, pulls, releases] = await Promise.all([ - api(`/repos/${repoOwner}/${repoName}/commits?${query}`), - api(`/repos/${repoOwner}/${repoName}/pulls?state=closed&sort=updated&direction=desc&per_page=100`), - api(`/repos/${repoOwner}/${repoName}/releases?per_page=100`), + api("/repos/" + repoOwner + "/" + repoName + "/commits?" + query), + api("/repos/" + repoOwner + "/" + repoName + "/pulls?state=closed&sort=updated&direction=desc&per_page=100"), + api("/repos/" + repoOwner + "/" + repoName + "/releases?per_page=100"), ]); + + completedRepositories += 1; const recentCommits = commits.filter((item) => { - const authored = item.commit?.author?.date; + const authored = item.commit?.author?.date ?? item.commit?.committer?.date; return authored && authored >= since.toISOString() && authored <= until.toISOString(); }); - const recentPulls = pulls.filter((item) => item.merged_at && item.merged_at >= since.toISOString() && item.merged_at <= until.toISOString()); - const recentReleases = releases.filter((item) => item.published_at && item.published_at >= since.toISOString() && item.published_at <= until.toISOString()); + const recentPulls = pulls.filter((item) => + item.merged_at && item.merged_at >= since.toISOString() && item.merged_at <= until.toISOString() + ); + const recentReleases = releases.filter((item) => + item.published_at && item.published_at >= since.toISOString() && item.published_at <= until.toISOString() + ); + for (const item of recentCommits) { const message = item.commit.message.split("\n")[0].replaceAll("\r", ""); const isLogNote = /\[log\]/i.test(message); const category = isLogNote ? " log note" : ""; - sections.push(`- **${fullName}${category}** — [${escapeMarkdown(message)}](${item.html_url})`); + sections.push("- **" + fullName + category + "** — [" + escapeMarkdown(message) + "](" + item.html_url + ")"); + } + for (const item of recentPulls) { + sections.push("- **" + fullName + " PR** — [#" + item.number + " " + escapeMarkdown(item.title) + "](" + item.html_url + ")"); + } + for (const item of recentReleases) { + sections.push("- **" + fullName + " release** — [" + escapeMarkdown(item.name || item.tag_name) + "](" + item.html_url + ")"); } - for (const item of recentPulls) sections.push(`- **${fullName} PR** — [#${item.number} ${escapeMarkdown(item.title)}](${item.html_url})`); - for (const item of recentReleases) sections.push(`- **${fullName} release** — [${escapeMarkdown(item.name || item.tag_name)}](${item.html_url})`); } catch (error) { - console.warn(`Activity unavailable for ${fullName}: ${error.message}`); + const reason = String(error?.message ?? error).split("\n")[0].slice(0, 240); + failures.push({ repository: fullName, reason }); + console.warn("Activity unavailable for " + fullName + ": " + reason); } } -const activity = sections.length ? sections.join("\n") : "_No GitHub commits, merged pull requests, or releases found in the last 24 hours._"; +if (completedRepositories === 0) { + throw new Error( + "Activity collection failed for every configured repository; preserving the existing activity block. " + + failures.map((item) => item.repository + ": " + item.reason).join("; ") + ); +} + +let activity = sections.length + ? sections.join("\n") + : failures.length + ? "_No activity found in repositories checked successfully._" + : "_No GitHub commits, merged pull requests, or releases found in the last 24 hours._"; +if (failures.length) { + activity += "\n\n> ⚠️ Incomplete activity collection for: " + + failures.map((item) => escapeMarkdown(item.repository) + " (" + escapeMarkdown(item.reason) + ")").join("; "); +} + const start = ""; const end = ""; -const block = `${start}\n### Shipped Today (GitHub activity)\n${activity}\n${end}`; -let content = entry.content; +const block = start + "\n## Shipped Today (GitHub activity)\n" + activity + "\n" + end; const pattern = /[\s\S]*?/; +let content = entry.content; if (pattern.test(content)) content = content.replace(pattern, block); -else content = `${content.trimEnd()}\n\n${block}\n`; -await putFile(path, content, `chore(log): enrich ${date} with GitHub activity`, entry.sha); -console.log(`Recorded ${sections.length} activity item(s) from ${repositories.length} repository/repositories.`); +else content = content.trimEnd() + "\n\n" + block + "\n"; + +await putFile(path, content, "chore(log): enrich " + date + " with GitHub activity", entry.sha); +console.log( + "Recorded " + sections.length + " activity item(s) across " + completedRepositories + + " successfully checked repository/repositories; " + failures.length + " failure(s)." +); function escapeMarkdown(value) { - return String(value).replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]").replaceAll("(", "\\(").replaceAll(")", "\\)").replaceAll("|", "\\|"); + return String(value) + .replaceAll("\\", "\\\\") + .replaceAll("[", "\\[") + .replaceAll("]", "\\]") + .replaceAll("(", "\\(") + .replaceAll(")", "\\)") + .replaceAll("|", "\\|"); } From 8b4fc1e9263ce712fc9831ff26b8836e00c7f95c Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:06:17 +0100 Subject: [PATCH 07/16] test(ci): execute automation regression suite --- .github/workflows/validate.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a323102..37dc3e5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -30,3 +30,5 @@ jobs: while IFS= read -r file; do node --check "$file" done < <(find scripts -type f -name '*.mjs' -print) + - name: Run unit tests + run: node --test scripts/test/*.test.mjs From 01998fd70c47359b8d6b2b5937f74696570ef157 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:06:37 +0100 Subject: [PATCH 08/16] fix(log): escape literal dollar signs in headings From 4f5c8e3b48d018e96e08d23f0ae30fcb293e70fe Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:06:39 +0100 Subject: [PATCH 09/16] test(log): account for trailing newline in appended section From e30ab9c92cb6942385760ca5e3816b34aaa8a647 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:06:53 +0100 Subject: [PATCH 10/16] fix(log): escape all heading regex metacharacters --- scripts/lib/markdown.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lib/markdown.mjs b/scripts/lib/markdown.mjs index 4d08521..2c49e8c 100644 --- a/scripts/lib/markdown.mjs +++ b/scripts/lib/markdown.mjs @@ -5,7 +5,7 @@ export function replaceMarkdownSection(markdown, heading, value) { } if (typeof value !== "string") throw new TypeError("value must be a string."); - const escaped = heading.replace(/[.*+?^{}()|[\]\\]/g, "\\$&"); + const escaped = heading.replace(/[.*+?^{}()|[\]\\]/g, "\\$&").replaceAll("$", "\\$"); const headingPattern = new RegExp("^(#{1,6})[ \\t]+" + escaped + "[ \\t]*\\r?$", "gm"); const match = headingPattern.exec(markdown); From d41e7b86d2bfd346db29265b354eaa4db57d6eb8 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:06:59 +0100 Subject: [PATCH 11/16] test(log): correct appended section newline assertion --- scripts/test/markdown.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test/markdown.test.mjs b/scripts/test/markdown.test.mjs index a9c10d7..301cc8f 100644 --- a/scripts/test/markdown.test.mjs +++ b/scripts/test/markdown.test.mjs @@ -35,7 +35,7 @@ test("keeps later sections and activity markers outside the replaced section", ( test("appends a missing section using a level-two heading", () => { const result = replaceMarkdownSection("# Daily Log\n", "Learned", "- A lesson."); - assert.match(result, /## Learned\n- A lesson\.$/); + assert.match(result, /## Learned\n- A lesson\.\n$/); }); test("matches headings literally rather than treating punctuation as regex syntax", () => { From ae163372ac4492c10ce39917d1613b5ff0014ec0 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:08:11 +0100 Subject: [PATCH 12/16] fix(log): clean up duplicate legacy sections --- scripts/lib/markdown.mjs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/lib/markdown.mjs b/scripts/lib/markdown.mjs index 2c49e8c..fe0fbbd 100644 --- a/scripts/lib/markdown.mjs +++ b/scripts/lib/markdown.mjs @@ -7,8 +7,27 @@ export function replaceMarkdownSection(markdown, heading, value) { const escaped = heading.replace(/[.*+?^{}()|[\]\\]/g, "\\$&").replaceAll("$", "\\$"); const headingPattern = new RegExp("^(#{1,6})[ \\t]+" + escaped + "[ \\t]*\\r?$", "gm"); - const match = headingPattern.exec(markdown); + const matches = [...markdown.matchAll(headingPattern)]; + + for (const duplicate of matches.slice(1).reverse()) { + const level = duplicate[1].length; + let contentStart = duplicate.index + duplicate[0].length; + if (markdown.slice(contentStart, contentStart + 2) === "\r\n") contentStart += 2; + else if (markdown[contentStart] === "\n") contentStart += 1; + const boundaryPattern = new RegExp( + "^#{1," + level + "}[ \\t]+|^---[ \\t]*$|^$|^$", + "gm", + ); + boundaryPattern.lastIndex = contentStart; + const boundary = boundaryPattern.exec(markdown); + const end = boundary ? boundary.index : markdown.length; + const before = markdown.slice(0, duplicate.index).replace(/\n+$/, ""); + const after = markdown.slice(end).replace(/^\n+/, ""); + markdown = before + (before ? "\n\n" : "") + after; + } + + const match = headingPattern.exec(markdown); if (!match) { return markdown.trimEnd() + "\n\n## " + heading + "\n" + value.trim() + "\n"; } @@ -25,8 +44,6 @@ export function replaceMarkdownSection(markdown, heading, value) { boundaryPattern.lastIndex = contentStart; const boundary = boundaryPattern.exec(markdown); const end = boundary ? boundary.index : markdown.length; - const prefix = markdown.slice(0, contentStart); - const suffix = markdown.slice(end); - return prefix + value.trim() + "\n\n" + suffix.replace(/^\n+/, ""); + return markdown.slice(0, contentStart) + value.trim() + "\n\n" + markdown.slice(end).replace(/^\n+/, ""); } From 2716088716e1f441b16ec148ee399d6a475b8f89 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:08:13 +0100 Subject: [PATCH 13/16] test(log): cover cleanup of duplicate legacy sections --- scripts/test/markdown.test.mjs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/test/markdown.test.mjs b/scripts/test/markdown.test.mjs index 301cc8f..a4f0bd1 100644 --- a/scripts/test/markdown.test.mjs +++ b/scripts/test/markdown.test.mjs @@ -45,3 +45,29 @@ test("matches headings literally rather than treating punctuation as regex synta assert.match(result, /## What \(I\) built\n- new\n\n## Next/); assert.doesNotMatch(result, /- old/); }); + +test("removes duplicate sections left by older capture runs", () => { + const source = [ + "# Daily Log", + "", + "## Shipped", + "- old original", + "", + "## Learned", + "- learned", + "", + "### Shipped", + "- stale duplicate", + "", + "### Learned", + "- stale duplicate lesson", + "", + "---", + "", + ].join("\n"); + const result = replaceMarkdownSection(source, "Shipped", "- corrected"); + + assert.equal((result.match(/^#{2,6} Shipped$/gm) ?? []).length, 1); + assert.doesNotMatch(result, /stale duplicate/); + assert.match(result, /## Learned\n- learned/); +}); From 69142490abd7b2c8fce1c1d494d2ff9c5ddfaa28 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:08:29 +0100 Subject: [PATCH 14/16] test(log): validate cleanup across all legacy fields --- scripts/test/markdown.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/test/markdown.test.mjs b/scripts/test/markdown.test.mjs index a4f0bd1..dbe590a 100644 --- a/scripts/test/markdown.test.mjs +++ b/scripts/test/markdown.test.mjs @@ -65,7 +65,10 @@ test("removes duplicate sections left by older capture runs", () => { "---", "", ].join("\n"); - const result = replaceMarkdownSection(source, "Shipped", "- corrected"); + let result = source; + for (const heading of ["Shipped", "Learned", "Blockers", "Tomorrow"]) { + result = replaceMarkdownSection(result, heading, "- corrected " + heading.toLowerCase()); + } assert.equal((result.match(/^#{2,6} Shipped$/gm) ?? []).length, 1); assert.doesNotMatch(result, /stale duplicate/); From ad4eaee28c7ea83587ca05518753185ddcc36a65 Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:08:40 +0100 Subject: [PATCH 15/16] test(log): assert replacement of duplicate legacy fields From ec0d7ea8dda19e0576d8c0f4fb2cfbebf9b6cc5d Mon Sep 17 00:00:00 2001 From: Michael Kage Date: Wed, 11 Nov 2026 16:08:49 +0100 Subject: [PATCH 16/16] test(log): assert replacement of duplicate legacy fields --- scripts/test/markdown.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test/markdown.test.mjs b/scripts/test/markdown.test.mjs index dbe590a..49b7b17 100644 --- a/scripts/test/markdown.test.mjs +++ b/scripts/test/markdown.test.mjs @@ -72,5 +72,5 @@ test("removes duplicate sections left by older capture runs", () => { assert.equal((result.match(/^#{2,6} Shipped$/gm) ?? []).length, 1); assert.doesNotMatch(result, /stale duplicate/); - assert.match(result, /## Learned\n- learned/); + assert.match(result, /## Learned\n- corrected learned/); });