linkedin-studio/scripts/check-replace-safety.mjs
Kjell Tore Guttormsen 776d728d7d 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
2026-07-16 20:03:05 +02:00

290 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// `$`-safety structural guard for hooks/scripts/state-updater.mjs (remediation S13).
//
// WHY: the S9→S12 "close the class, not the line" lesson recurred. S12 converted the
// 5 section-append `String.replace` sites to replacement *functions* but left
// `replaceField` — the scalar writer — on a replacement *string*, so an untrusted
// `last_post_topic` carrying `$&`/`$1`/`` $` `` silently corrupted state, and the S12
// test asserted only the section entry (never the scalar), shipping the bug GREEN.
// The class is "untrusted user content reaching ANY `String.replace` replacement
// STRING": in a replacement string `$&` expands to the whole match, `` $` ``/`$'` to the
// pre/suffix, `$$`→`$`, `$n`→group n — so a `$`-bearing value rewrites the field. A
// replacement FUNCTION returns its string verbatim (no `$`-substitution), closing the
// class by construction.
//
// This guard proves the PROPERTY behaviorally rather than grepping a syntactic proxy
// (which cannot tell a replacement-position template literal from a RegExp-pattern one
// across multi-line calls): it drives every exported state mutator with an adversarial
// payload built from EVERY special replacement token, in every free-text AND date
// field that reaches a `.replace()`, and asserts the payload survives VERBATIM. Two
// structural backstops make it a CLASS guard, not a per-line test:
// (1) COVERAGE-COMPLETENESS — every exported mutator (minus the documented I/O
// wrapper) must appear in the battery. A NEW export added without `$`-coverage
// fails here, so future code cannot reintroduce the class unguarded.
// (2) NON-VACUITY SELF-TEST — a naive string-replace fed the same payload MUST
// corrupt, and a function replacement MUST preserve it. A guard that cannot
// observe the corruption it forbids certifies nothing, so it fails the suite
// instead (mirrors Section 8/10/11 self-tests; the S7→S10 "proof run once by
// hand, never committed" lesson applied to the `$`-axis).
//
// Zero dependencies (node:assert + the module under test). Invoked from
// scripts/test-runner.sh Section 12; exit code mapped to pass/fail.
import assert from "node:assert/strict";
import * as mod from "../hooks/scripts/state-updater.mjs";
// Adversarial payload: every JS replacement-string special token. If ANY of these is
// interpreted (string replacement) instead of inserted literally (function
// replacement), the payload will not survive verbatim.
const TOKENS = ["$&", "$`", "$'", "$$", "$1", "$0"];
const PAYLOAD = `lead ${TOKENS.join(" ")} tail`;
// Minimal fixtures (mirror hooks/scripts/__tests__/state-updater.test.mjs). SAMPLE
// omits last_firsthour_date/last_outreach_date (additive-insert path); WITH_FH carries
// last_firsthour_date (the replaceField scalar path); TEMPLATE ships the two append
// sections pre-created (the production section-append path).
const SAMPLE = `---
last_post_date: "2026-04-05"
first_post_date: "2026-01-15"
last_post_topic: "AI strategy"
posts_this_week: 2
weekly_goal: 3
current_streak: 5
longest_streak: 12
current_week: "2026-W14"
follower_count: 850
follower_target: 10000
target_date: "2026-12-31"
---
# LinkedIn Session State
## Recent Posts
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
## Milestone Log
`;
const WITH_FH = SAMPLE.replace(
'last_post_date: "2026-04-05"',
() => 'last_post_date: "2026-04-05"\nlast_firsthour_date: null'
);
const TEMPLATE = `---
last_post_date: "2026-04-05"
last_post_topic: "AI strategy"
posts_this_week: 2
follower_count: 850
follower_target: 10000
target_date: "2026-12-31"
---
# LinkedIn Session State
## Recent Posts
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
## First-Hour Plans
<!-- First-hour / reply-loop plans, newest first. Written by /linkedin:firsthour. -->
## Outreach Pipeline
<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->
`;
// Coverage battery: one or more cases per exported mutator. `fields` lists the
// `.replace()` paths exercised; `expect` are substrings that MUST appear verbatim in
// the output (each would differ if a `$`-token expanded).
const BATTERY = [
{
fn: "updatePostTracking",
cases: [
{
path: "scalar replaceField (:58) + Recent Posts append (:122)",
run: () => mod.updatePostTracking(SAMPLE, {
postDate: "2026-04-09", postTopic: PAYLOAD, hookText: PAYLOAD, charCount: 1200, format: "post",
}),
expect: [`last_post_topic: "${PAYLOAD}"`, `"${PAYLOAD}" (1200) - ${PAYLOAD}`],
once: [/^## Recent Posts$/gm],
},
],
},
{
fn: "pruneContentHistory",
cases: [
{
path: "section rewrite of KEPT entries (:171)",
run: () => {
// 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);
// 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[\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, 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 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],
},
],
},
{
fn: "updateFollowerCount",
cases: [
{
// No free-text field: count is an integer, month is a `YYYY-MM` date used in
// date math. There is NO untrusted-string replacement surface here. Covered
// for completeness with a structural-integrity assertion on benign input.
path: "no untrusted-string replacement surface (count/month are int/date)",
run: () => mod.updateFollowerCount(SAMPLE, { count: 920, month: "2026-04" }),
expect: ["follower_count: 920", "[2026-04] 920 (+70)"],
once: [/^## Milestone Log$/gm],
},
],
},
{
fn: "recordFirstHourPlan",
cases: [
{
path: "section append, free-text topic/targets/comments (:271)",
run: () => mod.recordFirstHourPlan(TEMPLATE, {
planDate: "2026-05-30 09:00", postTopic: PAYLOAD, targets: [PAYLOAD], draftComments: [PAYLOAD], plan: ["09:00 — live"],
}),
expect: [`### [2026-05-30 09:00] ${PAYLOAD}`, `- ${PAYLOAD}`],
once: [/^## First-Hour Plans$/gm],
},
{
path: "date additive-insert (:246) — planDate fuzzed",
run: () => mod.recordFirstHourPlan(SAMPLE, { planDate: PAYLOAD, postTopic: "t" }),
expect: [`last_firsthour_date: "${PAYLOAD}"`],
once: [/^last_firsthour_date:/gm],
},
{
path: "date scalar replaceField (:237) — planDate fuzzed",
run: () => mod.recordFirstHourPlan(WITH_FH, { planDate: PAYLOAD, postTopic: "t" }),
expect: [`last_firsthour_date: "${PAYLOAD}"`],
once: [/^last_firsthour_date:/gm],
},
],
},
{
fn: "recordOutreachContact",
cases: [
{
path: "section append, free-text partner/stage/nextAction (:331)",
run: () => mod.recordOutreachContact(TEMPLATE, {
contactDate: "2026-05-30 14:00", track: "collab", partner: PAYLOAD, stage: PAYLOAD, nextAction: PAYLOAD, dueDate: "2026-06-06",
}),
expect: [`${PAYLOAD}`, `**Stage:** ${PAYLOAD}`, `**Next action:** ${PAYLOAD}`],
once: [/^## Outreach Pipeline$/gm],
},
{
path: "date additive-insert via last_post_date anchor (:308) — contactDate fuzzed",
run: () => mod.recordOutreachContact(SAMPLE, { contactDate: PAYLOAD, partner: "p" }),
expect: [`last_outreach_date: "${PAYLOAD}"`],
once: [/^last_outreach_date:/gm],
},
],
},
];
// --- Backstop 1: coverage completeness -------------------------------------------
// Every exported mutator must be in the battery, minus the documented I/O wrapper.
// A new export added without `$`-coverage fails here.
const IO_WRAPPER_EXEMPT = new Set(["writeState"]);
const exportedFns = Object.keys(mod).filter((k) => typeof mod[k] === "function");
const covered = new Set(BATTERY.map((b) => b.fn));
const uncovered = exportedFns.filter((k) => !covered.has(k) && !IO_WRAPPER_EXEMPT.has(k));
let failed = 0;
const failures = [];
if (uncovered.length) {
failed++;
failures.push(`coverage gap: exported mutator(s) without $-safety coverage → ${uncovered.join(", ")} (add a battery case or document an exemption)`);
}
// Guard the exemption too: if writeState is renamed/removed, surface it rather than
// silently exempting a stale name.
for (const name of IO_WRAPPER_EXEMPT) {
if (!exportedFns.includes(name)) {
failed++;
failures.push(`exemption stale: '${name}' is exempted but no longer exported — re-check the exemption list`);
}
}
// --- Backstop 2: non-vacuity self-test -------------------------------------------
// Prove the payload is genuinely dangerous (string replace corrupts) and that the
// fix shape (function replace) preserves it. A guard that cannot see the corruption
// it forbids enforces nothing.
{
const subject = 'last_post_topic: "OLD"';
const naive = subject.replace(/^last_post_topic: .*/m, `last_post_topic: "${PAYLOAD}"`);
const safe = subject.replace(/^last_post_topic: .*/m, () => `last_post_topic: "${PAYLOAD}"`);
if (naive.includes(PAYLOAD)) {
failed++;
failures.push("self-test vacuous: a STRING replacement did NOT corrupt the payload — the payload no longer exercises `$`-expansion, so a PASS is meaningless");
}
if (!safe.includes(PAYLOAD)) {
failed++;
failures.push("self-test broken: a FUNCTION replacement did NOT preserve the payload — the verbatim-survival assertion is unsound");
}
}
// --- Run the battery -------------------------------------------------------------
let cases = 0;
for (const { fn, cases: list } of BATTERY) {
for (const c of list) {
cases++;
try {
const out = c.run();
if (c.assert) {
c.assert(out);
} else {
const content = out && out.content;
assert.ok(content, `${fn} [${c.path}]: no content returned`);
for (const sub of c.expect || []) {
assert.ok(content.includes(sub), `${fn} [${c.path}]: missing verbatim → ${JSON.stringify(sub)}`);
}
for (const re of c.once || []) {
const hits = (content.match(re) || []).length;
assert.equal(hits, 1, `${fn} [${c.path}]: structural anchor ${re} appeared ${hits}× (expected 1 — a $&/$1 expansion duplicated it)`);
}
}
} catch (e) {
failed++;
failures.push(`${fn} [${c.path}]: ${e.message}`);
}
}
}
if (failed === 0) {
console.log(`$-safety: ${cases} adversarial case(s) across ${BATTERY.length} mutator(s) preserved the payload verbatim; coverage complete; self-test non-vacuous`);
process.exit(0);
} else {
console.error(`$-safety guard FAILED (${failed}):`);
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}