From 49480c6734ccb8d386bd7c2e68e4addf02be8484 Mon Sep 17 00:00:00 2001 From: Alexander Fry Date: Tue, 1 Sep 2026 14:14:37 -0400 Subject: [PATCH] fix(isa-utils): function replacer in appendDecisionRow so ISA dollar amounts don't backref-expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appendDecisionRow appended its auto-rewind row with a STRING replacement built from the existing Decisions body. That body carries literal dollar amounts, and String.prototype.replace interprets $1/$2/$& in a replacement string as regex backreferences, so an amount like $14,000 expanded to 4,000 — duplicating the ## Decisions header and shredding the section on any resume of a completed ISA. Switch to a function replacer, whose return value is inserted verbatim. Adds a regression test (synthetic amounts). --- LifeOS/install/hooks/lib/isa-utils.test.ts | 34 ++++++++++++++++++++++ LifeOS/install/hooks/lib/isa-utils.ts | 8 +++-- 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 LifeOS/install/hooks/lib/isa-utils.test.ts diff --git a/LifeOS/install/hooks/lib/isa-utils.test.ts b/LifeOS/install/hooks/lib/isa-utils.test.ts new file mode 100644 index 0000000000..ffcd704452 --- /dev/null +++ b/LifeOS/install/hooks/lib/isa-utils.test.ts @@ -0,0 +1,34 @@ +import { test, expect } from 'bun:test'; +import { appendDecisionRow } from './isa-utils'; + +// Regression (2026-09-01 incident): the auto-rewind appended its Decisions row +// with a STRING replacement built from the existing Decisions body. That body is +// full of literal dollar amounts, and String.replace treats `$1`/`$2`/`$&` in a +// replacement string as regex backreferences — so `$14,000` expanded to +// `4,000`, duplicating the `## Decisions` header and shredding +// the section. The fix uses a function replacer, whose return value is inserted +// verbatim with no `$` interpretation. +test('appendDecisionRow preserves dollar amounts and does not duplicate the section', () => { + // Synthetic amounts chosen so their leading digits are `$1`/`$2`/`$3`/`$4` — + // exactly the prefixes String.replace would misread as backreferences. + const content = + '---\nphase: complete\n---\n' + + '## Decisions\n' + + '- 2026-01-02: inflow +$12,000; net −$23,456.78; item $3,999; total $45,678.90\n' + + '## Learning\n- note\n'; + + const out = appendDecisionRow(content, '2026-09-01T00:00:00Z', 3); + + // Every dollar amount survives intact (the `$1`/`$2`/`$3` prefixes are the trap). + for (const amt of ['$12,000', '$23,456.78', '$3,999', '$45,678.90']) { + expect(out).toContain(amt); + } + // Exactly one Decisions heading — no backref-driven duplication. + expect(out.match(/## Decisions/g)?.length).toBe(1); + // The auto-rewind row was appended under Decisions, above Learning. + expect(out).toContain('- D-auto-2026-09-01T00:00:00Z:'); + expect(out.indexOf('D-auto-')).toBeLessThan(out.indexOf('## Learning')); + // No stray fragment line beginning with the tail of a split amount ($12,000 → 2,000). + expect(out).not.toMatch(/^2,000/m); + expect(out).not.toMatch(/^## Decisions[^\n]/m); +}); diff --git a/LifeOS/install/hooks/lib/isa-utils.ts b/LifeOS/install/hooks/lib/isa-utils.ts index b13f73039a..977de52e0f 100755 --- a/LifeOS/install/hooks/lib/isa-utils.ts +++ b/LifeOS/install/hooks/lib/isa-utils.ts @@ -41,12 +41,16 @@ export function hashBody(content: string): string { /** Append one Decisions row to the body. Inserts under `## Decisions` heading, * creating the section if missing. v6.9.0 invariant: every auto-rewind logs * one row so the principal can audit the rewind inline. */ -function appendDecisionRow(content: string, ts: string, newIteration: number): string { +export function appendDecisionRow(content: string, ts: string, newIteration: number): string { const row = `- D-auto-${ts}: Auto-resumed from complete to learn at ${ts} — iteration ${newIteration}`; const decisionsRe = /(\n## Decisions\n)([\s\S]*?)(\n## |\n---\n|$)/; const match = content.match(decisionsRe); if (match) { - return content.replace(decisionsRe, `${match[1]}${match[2].trimEnd()}\n${row}\n${match[3]}`); + // Function replacer, not a string: the existing Decisions body (match[2]) is + // full of literal dollar amounts, and a string replacement would interpret + // `$1`/`$2`/`$&` inside them as regex backreferences — a `$14,000` became + // `4,000`, duplicating and shredding the section (2026-09-01 incident). + return content.replace(decisionsRe, () => `${match[1]}${match[2].trimEnd()}\n${row}\n${match[3]}`); } // No Decisions section yet — append before the learning-trail section if present // (## Learning, or the legacy ## Changelog alias for pre-rename ISAs), else end.