fix(linkedin-studio): N1 — prune-regex no-op + dato-uavhengige kalender-fixtures (TDD) [skip-docs]

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) <noreply@anthropic.com>
Claude-Session: a814039f-da8b-41b1-8d7c-60abe1e03c5a
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:03:05 +02:00
commit 776d728d7d
3 changed files with 91 additions and 51 deletions

View file

@ -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', () => { describe('pruneContentHistory', () => {
test('removes entries older than 90 days', () => { test('prunes an old entry that sits BELOW a fresh entry (regression: /m flag truncated the capture to the first line)', () => {
const today = new Date(); // Production prepends new entries at the top, so old entries always sit
const old = new Date(today); // BELOW fresh ones. With the /m flag, the $ alternative in the lookahead
old.setDate(old.getDate() - 100); // matched the end of the FIRST entry line, so only that line was captured
const oldDate = old.toISOString().slice(0, 10); // and older entries below were never scanned -> never pruned.
const oldDate = daysBeforeFixedToday(400);
const freshDate = daysBeforeFixedToday(10);
const recent = new Date(today); const state = SAMPLE_STATE.replace(
recent.setDate(recent.getDate() - 10); /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
const recentDate = recent.toISOString().slice(0, 10); `## Recent Posts\n\n- [${freshDate}] "Fresh post..." (1200) - fresh topic\n- [${oldDate}] "Old post..." (1000) - old topic\n\n`
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 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.notEqual(result, null);
assert.equal(result.pruned, 1); assert.equal(result.pruned, 1);
assert.ok(!result.content.includes(oldDate)); const section = recentPostsSection(result.content);
assert.ok(result.content.includes(recentDate)); assert.ok(!section.includes(oldDate));
assert.ok(section.includes(recentDate));
}); });
test('preserves entries within 90 days', () => { test('preserves entries within 90 days', () => {
const today = new Date(); const recentDate = daysBeforeFixedToday(30);
const recent = new Date(today);
recent.setDate(recent.getDate() - 30);
const recentDate = recent.toISOString().slice(0, 10);
const stateWithRecent = SAMPLE_STATE.replace( const stateWithRecent = SAMPLE_STATE.replace(
'## Recent Posts\n\n', /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
`## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n` `## 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 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[\s\S]*?(?=## Session Notes)/,
'## Recent Posts\n\n' '## Recent Posts\n\n'
); );
const result = pruneContentHistory(emptyRecent, 90); const result = pruneContentHistory(emptyRecent, 90, FIXED_TODAY);
assert.equal(result, null); assert.equal(result, null);
}); });
test('handles custom maxAgeDays', () => { test('handles custom maxAgeDays', () => {
const today = new Date(); const oldDate = daysBeforeFixedToday(40);
const old = new Date(today);
old.setDate(old.getDate() - 40);
const oldDate = old.toISOString().slice(0, 10);
const stateWithOld = SAMPLE_STATE.replace( const stateWithOld = SAMPLE_STATE.replace(
'## Recent Posts\n\n', /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
`## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n` `## 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.notEqual(result, null);
assert.equal(result.pruned, 1); assert.equal(result.pruned, 1);
}); });
@ -352,25 +381,23 @@ describe('pruneContentHistory', () => {
// string search has no $1 group, but `$&` still expands to the whole matched // string search has no $1 group, but `$&` still expands to the whole matched
// section and `$$` collapses to `$`, so a kept post like "$$ and $& budget" // section and `$$` collapses to `$`, so a kept post like "$$ and $& budget"
// corrupted state. The replacement function inserts newSection verbatim. // corrupted state. The replacement function inserts newSection verbatim.
const today = new Date(); const oldDate = daysBeforeFixedToday(100);
const old = new Date(today); old.setDate(old.getDate() - 100); const recentDate = daysBeforeFixedToday(10);
const oldDate = old.toISOString().slice(0, 10);
const recent = new Date(today); recent.setDate(recent.getDate() - 10);
const recentDate = recent.toISOString().slice(0, 10);
// Build the fixture with a replacement FUNCTION too — a string replacement here // 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 // would itself interpret the `$$`/`$&` we are trying to plant (the very bug under
// test), corrupting the fixture before pruneContentHistory ever sees it. // test), corrupting the fixture before pruneContentHistory ever sees it.
const stateWithMix = SAMPLE_STATE.replace( const stateWithMix = SAMPLE_STATE.replace(
'## Recent Posts\n\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` () => `## 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.notEqual(result, null);
assert.equal(result.pruned, 1); assert.equal(result.pruned, 1);
assert.ok(!result.content.includes(oldDate), 'old entry pruned'); const section = recentPostsSection(result.content);
assert.ok(result.content.includes(`- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget`), 'kept $-bearing entry survives verbatim'); 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) || []; const headings = result.content.match(/^## Recent Posts$/gm) || [];
assert.equal(headings.length, 1, 'section must not be duplicated by a $& expansion'); assert.equal(headings.length, 1, 'section must not be duplicated by a $& expansion');
}); });

View file

@ -133,17 +133,21 @@ export function updatePostTracking(stateContent, { postDate, postTopic, hookText
* Remove Recent Posts entries older than maxAgeDays. * Remove Recent Posts entries older than maxAgeDays.
* @param {string} stateContent - Full state file content * @param {string} stateContent - Full state file content
* @param {number} [maxAgeDays=90] * @param {number} [maxAgeDays=90]
* @param {Date} [today=new Date()] - Injectable clock (tests pass a fixed date)
* @returns {{ content: string, pruned: number } | null} * @returns {{ content: string, pruned: number } | null}
*/ */
export function pruneContentHistory(stateContent, maxAgeDays = 90) { export function pruneContentHistory(stateContent, maxAgeDays = 90, today = new Date()) {
const today = new Date();
const cutoff = new Date(today); const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() - maxAgeDays); cutoff.setDate(cutoff.getDate() - maxAgeDays);
const cutoffStr = cutoff.toISOString().slice(0, 10); const cutoffStr = cutoff.toISOString().slice(0, 10);
// Find all Recent Posts entries // Find all Recent Posts entries
const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm; 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; if (!recentSection || !recentSection[1].trim()) return null;
const sectionContent = recentSection[1]; const sectionContent = recentSection[1];

View file

@ -118,25 +118,34 @@ const BATTERY = [
{ {
path: "section rewrite of KEPT entries (:171)", path: "section rewrite of KEPT entries (:171)",
run: () => { run: () => {
const today = new Date(); // Fixed injectable "today" (third param): with the real clock, on some
const old = new Date(today); old.setDate(old.getDate() - 100); // calendar days today-100d collides with a hardcoded SAMPLE date (e.g.
const recent = new Date(today); recent.setDate(recent.getDate() - 10); // 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 oldDate = old.toISOString().slice(0, 10);
const recentDate = recent.toISOString().slice(0, 10); const recentDate = recent.toISOString().slice(0, 10);
// Plant the payload via a FUNCTION replace so the fixture itself is not // Replace the WHOLE section (heading + hardcoded entry) so no SAMPLE entry
// pre-corrupted by the very expansion under test. // 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( const state = SAMPLE.replace(
"## Recent Posts\n\n", /## Recent Posts\n\n[\s\S]*?(?=## Milestone Log)/,
() => `## Recent Posts\n\n- [${oldDate}] "drop" (1000) - drop me\n- [${recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}\n` () => `## 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 }; return { ...r, _recentDate: recentDate, _oldDate: oldDate };
}, },
assert: (r) => { assert: (r) => {
assert.notEqual(r, null, "prune returned null"); assert.notEqual(r, null, "prune returned null");
assert.equal(r.pruned, 1, "exactly the old entry pruned"); assert.equal(r.pruned, 1, "exactly the old entry pruned");
assert.ok(!r.content.includes(r._oldDate), "old entry pruned"); // Assert on the Recent Posts section only, not the whole content, so
assert.ok(r.content.includes(`- [${r._recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}`), "kept $-entry survives verbatim"); // 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], once: [/^## Recent Posts$/gm],
}, },