linkedin-studio/scripts/check-replace-safety.mjs
Kjell Tore Guttormsen b45fdad911 feat(linkedin-studio): N15 — do-next-kontrakt (recordDoNext + skrivere/lesere + forrige-utgave-kalibrering) [skip-docs]
Måling som ikke endrer neste utgave er teater: report/analyze/ab-test/48h-monitor
endte alle i chat (grep do.next = 0 i hele repoet). N15 gjør kanalen til en kontrakt.

- recordDoNext i hooks/scripts/state-updater.mjs (TDD, 17 nye tester): seksjonen
  Do-Next Directives, rader "[dato] (kilde) direktiv — evidence: peker".
  Levetid = erstatt-per-kilde + 60-dagers aldersgulv, maks 3 per skriv, additiv
  scalar last_donext_date, $-sikre replacement-funksjoner. CLI: --record-do-next.
- Skrivere: report Step 7b, analyze Step 6b, ab-test 2c.6 (kun Adopt),
  post-feedback-monitor (kun 48h-sjekken); analytics-interpreter emitterer nå
  anbefalinger på do-next-form (imperativ + evidens).
- Lesere: post/quick/batch/create Step 0 + newsletter Step 1, som i tillegg
  kalibrerer på forrige utgaves MÅLTE tall via queue-id-match (queue.json →
  analytics/posts) med eksplisitt honest-miss — aldri estimert.
- Guards: test-runner Section 16v (15 ubetingede sjekker, floor 198 → 213),
  $-safety-batteriet dekker recordDoNext (coverage-gate fanget den nye eksporten).

Suiter: test-runner 232/0, hooks 191/0, trends 300/0, brain 134/0, editions 72/0,
specifics-bank 45/0, contract-gate 33/0, tests 35/0, render 60/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
2026-07-25 15:23:35 +02:00

329 lines
14 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],
},
],
},
{
fn: "recordDoNext",
cases: [
{
path: "section CREATE, free-text directive/evidence",
run: () => mod.recordDoNext(SAMPLE, {
recordDate: "2026-05-30", source: "report",
directives: [{ directive: PAYLOAD, evidence: PAYLOAD }],
}),
expect: [`(report) ${PAYLOAD} — evidence: ${PAYLOAD}`],
once: [/^## Do-Next Directives$/gm],
},
{
path: "section REWRITE (supersede/prune path) — the kept + new rows are re-emitted",
run: () => {
// Two writes: the second rebuilds the section body, so a `$`-bearing row
// written by ANOTHER source has to survive being re-emitted verbatim.
const first = mod.recordDoNext(SAMPLE, {
recordDate: "2026-05-28", source: "ab-test",
directives: [{ directive: PAYLOAD, evidence: PAYLOAD }],
today: new Date("2026-06-10T00:00:00Z"),
});
return mod.recordDoNext(first.content, {
recordDate: "2026-05-30", source: "report",
directives: [{ directive: PAYLOAD, evidence: PAYLOAD }],
today: new Date("2026-06-10T00:00:00Z"),
});
},
expect: [`(ab-test) ${PAYLOAD} — evidence: ${PAYLOAD}`, `(report) ${PAYLOAD} — evidence: ${PAYLOAD}`],
once: [/^## Do-Next Directives$/gm],
},
{
path: "date additive-insert via last_post_date anchor — recordDate fuzzed",
run: () => mod.recordDoNext(SAMPLE, { recordDate: PAYLOAD, source: "report", directives: ["d"] }),
expect: [`last_donext_date: "${PAYLOAD}"`],
once: [/^last_donext_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);
}