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
290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
#!/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);
|
||
}
|