From 776d728d7d0d3038fa1ae924f328273fb0f462aa Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 16 Jul 2026 20:03:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(linkedin-studio):=20N1=20=E2=80=94=20prune-?= =?UTF-8?q?regex=20no-op=20+=20dato-uavhengige=20kalender-fixtures=20(TDD)?= =?UTF-8?q?=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-F1 (prod-bug, prune de facto no-op): fjernet `m`-flagget fra Recent Posts- regexen i `state-updater.mjs`. Med `/m` matchet `$`-alternativet i lookahead slutten av hver linje, så den late capturen stoppet etter FØRSTE entry-linje; gamle entries under en fersk (nye prepender øverst) ble aldri skannet/prunet. Ny regresjonstest (old-under-fresh) rød->grønn beviser mekanismen. B-F2 (kalender-flake): `pruneContentHistory` fikk valgfri `today = new Date()`- param (deterministisk rotårsak-fix; CLI/hook-kall uendret via default). Prune- testene injiserer fast syntetisk today og asserter kun på Recent Posts-seksjonen (ikke helinnhold/frontmatter), så today-100d aldri kolliderer med SAMPLE_STATE- datoer. Samme grep i `scripts/check-replace-safety.mjs`. Suiter (alle grønne): test-runner 138/0 · hooks 140/0 (+1 regresjonstest) · trends 245/0 · brain 134/0 · tests 35/0 · render 20/0. Utført av Fable 5 (high) subagent, orkestrert + verifisert av Opus-hovedkontekst. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: a814039f-da8b-41b1-8d7c-60abe1e03c5a --- .../scripts/__tests__/state-updater.test.mjs | 107 +++++++++++------- hooks/scripts/state-updater.mjs | 10 +- scripts/check-replace-safety.mjs | 29 +++-- 3 files changed, 93 insertions(+), 53 deletions(-) diff --git a/hooks/scripts/__tests__/state-updater.test.mjs b/hooks/scripts/__tests__/state-updater.test.mjs index abf6f62..86fe956 100644 --- a/hooks/scripts/__tests__/state-updater.test.mjs +++ b/hooks/scripts/__tests__/state-updater.test.mjs @@ -284,41 +284,73 @@ describe('updatePostTracking', () => { }); }); +// Fixed synthetic "today" for prune tests: far from every hardcoded SAMPLE_STATE +// date, so fixture dates computed relative to it can never collide with them. +const FIXED_TODAY = new Date('2027-01-01T00:00:00Z'); + +// Days before FIXED_TODAY as YYYY-MM-DD. +function daysBeforeFixedToday(days) { + const d = new Date(FIXED_TODAY); + d.setDate(d.getDate() - days); + return d.toISOString().slice(0, 10); +} + +// Extract only the Recent Posts section body so assertions cannot accidentally +// match dates in the frontmatter or other sections. +function recentPostsSection(content) { + const match = content.match(/## Recent Posts\n([\s\S]*?)\n## /); + assert.ok(match, 'Recent Posts section present'); + return match[1]; +} + describe('pruneContentHistory', () => { - test('removes entries older than 90 days', () => { - const today = new Date(); - const old = new Date(today); - old.setDate(old.getDate() - 100); - const oldDate = old.toISOString().slice(0, 10); + test('prunes an old entry that sits BELOW a fresh entry (regression: /m flag truncated the capture to the first line)', () => { + // Production prepends new entries at the top, so old entries always sit + // BELOW fresh ones. With the /m flag, the $ alternative in the lookahead + // matched the end of the FIRST entry line, so only that line was captured + // and older entries below were never scanned -> never pruned. + const oldDate = daysBeforeFixedToday(400); + const freshDate = daysBeforeFixedToday(10); - const recent = new Date(today); - recent.setDate(recent.getDate() - 10); - const recentDate = recent.toISOString().slice(0, 10); - - const stateWithOld = SAMPLE_STATE.replace( - '## Recent Posts\n\n', - `## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n` + const state = SAMPLE_STATE.replace( + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + `## Recent Posts\n\n- [${freshDate}] "Fresh post..." (1200) - fresh topic\n- [${oldDate}] "Old post..." (1000) - old topic\n\n` ); - const result = pruneContentHistory(stateWithOld, 90); + const result = pruneContentHistory(state, 90, FIXED_TODAY); + assert.notEqual(result, null, 'an old entry below a fresh one must be pruned'); + assert.equal(result.pruned, 1); + const section = recentPostsSection(result.content); + assert.ok(!section.includes(oldDate), 'old entry pruned'); + assert.ok(section.includes(freshDate), 'fresh entry kept'); + }); + + test('removes entries older than 90 days', () => { + const oldDate = daysBeforeFixedToday(100); + const recentDate = daysBeforeFixedToday(10); + + const stateWithOld = SAMPLE_STATE.replace( + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + `## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n\n` + ); + + const result = pruneContentHistory(stateWithOld, 90, FIXED_TODAY); assert.notEqual(result, null); assert.equal(result.pruned, 1); - assert.ok(!result.content.includes(oldDate)); - assert.ok(result.content.includes(recentDate)); + const section = recentPostsSection(result.content); + assert.ok(!section.includes(oldDate)); + assert.ok(section.includes(recentDate)); }); test('preserves entries within 90 days', () => { - const today = new Date(); - const recent = new Date(today); - recent.setDate(recent.getDate() - 30); - const recentDate = recent.toISOString().slice(0, 10); + const recentDate = daysBeforeFixedToday(30); const stateWithRecent = SAMPLE_STATE.replace( - '## Recent Posts\n\n', - `## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n` + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + `## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n\n` ); - const result = pruneContentHistory(stateWithRecent, 90); + const result = pruneContentHistory(stateWithRecent, 90, FIXED_TODAY); assert.equal(result, null); // nothing to prune }); @@ -327,22 +359,19 @@ describe('pruneContentHistory', () => { /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, '## Recent Posts\n\n' ); - const result = pruneContentHistory(emptyRecent, 90); + const result = pruneContentHistory(emptyRecent, 90, FIXED_TODAY); assert.equal(result, null); }); test('handles custom maxAgeDays', () => { - const today = new Date(); - const old = new Date(today); - old.setDate(old.getDate() - 40); - const oldDate = old.toISOString().slice(0, 10); + const oldDate = daysBeforeFixedToday(40); const stateWithOld = SAMPLE_STATE.replace( - '## Recent Posts\n\n', - `## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n` + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + `## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n\n` ); - const result = pruneContentHistory(stateWithOld, 30); + const result = pruneContentHistory(stateWithOld, 30, FIXED_TODAY); assert.notEqual(result, null); assert.equal(result.pruned, 1); }); @@ -352,25 +381,23 @@ describe('pruneContentHistory', () => { // string search has no $1 group, but `$&` still expands to the whole matched // section and `$$` collapses to `$`, so a kept post like "$$ and $& budget" // corrupted state. The replacement function inserts newSection verbatim. - const today = new Date(); - const old = new Date(today); old.setDate(old.getDate() - 100); - const oldDate = old.toISOString().slice(0, 10); - const recent = new Date(today); recent.setDate(recent.getDate() - 10); - const recentDate = recent.toISOString().slice(0, 10); + const oldDate = daysBeforeFixedToday(100); + const recentDate = daysBeforeFixedToday(10); // Build the fixture with a replacement FUNCTION too — a string replacement here // would itself interpret the `$$`/`$&` we are trying to plant (the very bug under // test), corrupting the fixture before pruneContentHistory ever sees it. const stateWithMix = SAMPLE_STATE.replace( - '## Recent Posts\n\n', - () => `## Recent Posts\n\n- [${oldDate}] "Old..." (1000) - drop me\n- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget\n` + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + () => `## Recent Posts\n\n- [${oldDate}] "Old..." (1000) - drop me\n- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget\n\n` ); - const result = pruneContentHistory(stateWithMix, 90); + const result = pruneContentHistory(stateWithMix, 90, FIXED_TODAY); assert.notEqual(result, null); assert.equal(result.pruned, 1); - assert.ok(!result.content.includes(oldDate), 'old entry pruned'); - assert.ok(result.content.includes(`- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget`), 'kept $-bearing entry survives verbatim'); + const section = recentPostsSection(result.content); + assert.ok(!section.includes(oldDate), 'old entry pruned'); + assert.ok(section.includes(`- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget`), 'kept $-bearing entry survives verbatim'); const headings = result.content.match(/^## Recent Posts$/gm) || []; assert.equal(headings.length, 1, 'section must not be duplicated by a $& expansion'); }); diff --git a/hooks/scripts/state-updater.mjs b/hooks/scripts/state-updater.mjs index df1d9a5..38f58ad 100644 --- a/hooks/scripts/state-updater.mjs +++ b/hooks/scripts/state-updater.mjs @@ -133,17 +133,21 @@ export function updatePostTracking(stateContent, { postDate, postTopic, hookText * Remove Recent Posts entries older than maxAgeDays. * @param {string} stateContent - Full state file content * @param {number} [maxAgeDays=90] + * @param {Date} [today=new Date()] - Injectable clock (tests pass a fixed date) * @returns {{ content: string, pruned: number } | null} */ -export function pruneContentHistory(stateContent, maxAgeDays = 90) { - const today = new Date(); +export function pruneContentHistory(stateContent, maxAgeDays = 90, today = new Date()) { const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - maxAgeDays); const cutoffStr = cutoff.toISOString().slice(0, 10); // Find all Recent Posts entries const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm; - const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/m); + // No /m flag: with /m the $ alternative in the lookahead matched the end of + // EVERY line, so the lazy capture stopped after the FIRST entry line and + // older entries below it were never scanned (and never pruned). Without /m, + // $ matches only end-of-string and the capture spans the whole section. + const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/); if (!recentSection || !recentSection[1].trim()) return null; const sectionContent = recentSection[1]; diff --git a/scripts/check-replace-safety.mjs b/scripts/check-replace-safety.mjs index a65caba..69966d6 100644 --- a/scripts/check-replace-safety.mjs +++ b/scripts/check-replace-safety.mjs @@ -118,25 +118,34 @@ const BATTERY = [ { path: "section rewrite of KEPT entries (:171)", run: () => { - const today = new Date(); - const old = new Date(today); old.setDate(old.getDate() - 100); - const recent = new Date(today); recent.setDate(recent.getDate() - 10); + // Fixed injectable "today" (third param): with the real clock, on some + // calendar days today-100d collides with a hardcoded SAMPLE date (e.g. + // 2026-04-05), making the whole-content assertion flake. A fixed date far + // from every SAMPLE date removes the collision class. + const fixedToday = new Date("2027-01-01T00:00:00Z"); + const old = new Date(fixedToday); old.setDate(old.getDate() - 100); + const recent = new Date(fixedToday); recent.setDate(recent.getDate() - 10); const oldDate = old.toISOString().slice(0, 10); const recentDate = recent.toISOString().slice(0, 10); - // Plant the payload via a FUNCTION replace so the fixture itself is not - // pre-corrupted by the very expansion under test. + // Replace the WHOLE section (heading + hardcoded entry) so no SAMPLE entry + // falls under the cutoff, via a FUNCTION replace so the fixture itself is + // not pre-corrupted by the very expansion under test. const state = SAMPLE.replace( - "## Recent Posts\n\n", - () => `## Recent Posts\n\n- [${oldDate}] "drop" (1000) - drop me\n- [${recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}\n` + /## Recent Posts\n\n[\s\S]*?(?=## Milestone Log)/, + () => `## Recent Posts\n\n- [${oldDate}] "drop" (1000) - drop me\n- [${recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}\n\n` ); - const r = mod.pruneContentHistory(state, 90); + const r = mod.pruneContentHistory(state, 90, fixedToday); return { ...r, _recentDate: recentDate, _oldDate: oldDate }; }, assert: (r) => { assert.notEqual(r, null, "prune returned null"); assert.equal(r.pruned, 1, "exactly the old entry pruned"); - assert.ok(!r.content.includes(r._oldDate), "old entry pruned"); - assert.ok(r.content.includes(`- [${r._recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}`), "kept $-entry survives verbatim"); + // Assert on the Recent Posts section only, not the whole content, so + // frontmatter/other-section dates can never satisfy or break the check. + const section = (r.content.match(/## Recent Posts\n([\s\S]*?)\n## Milestone Log/) || [])[1]; + assert.ok(section, "Recent Posts section present"); + assert.ok(!section.includes(r._oldDate), "old entry pruned"); + assert.ok(section.includes(`- [${r._recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}`), "kept $-entry survives verbatim"); }, once: [/^## Recent Posts$/gm], },