Files
msd-core/tests/4-phase-complete-cjs-regression.test.cjs
Tom Boucher 6177e3a5f5 fix(4): retire cooperating-sibling for phase.*, introduce generator + I/O adapter, fix cmdPhaseComplete (#154)
* test(4): reproduce non-idempotent phase complete + unclamped percent in CJS CLI

RED regression tests for issue #4:
- T1: double invocation of cmdPhaseComplete double-increments **Completed Phases:**
  in STATE.md body (blind parseInt+1 instead of deriving from ROADMAP)
- T2: progress percent can exceed 100% when Completed Phases > Total Phases

The CJS path (bin/lib/phase.cjs:cmdPhaseComplete) has the bug; the SDK path
(phase-lifecycle.ts:phaseComplete, fixed in ~PR#3520) already derives
completed_phases from ROADMAP Complete-row count, making it idempotent.

References:
- Issue #4 (open-gsd/get-shit-done-redux)
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- /tmp/adr-3524-review-findings.md (architectural justification)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add sdk/scripts/gen-phase-lifecycle-policy.mjs generator + freshness check placeholder

Generates phase-lifecycle-policy.generated.cjs from sdk/src/query/phase-lifecycle-policy.ts.
All functions in phase-lifecycle-policy.ts are pure transforms (no I/O), directly
serializable via Function.prototype.toString(). The GSDError dependency is replaced
with a lightweight stub that throws plain Error objects — CJS callers that need
process.exit(1) behavior catch these and delegate to error().

This is the "I/O adapter pattern" from ADR-3524 Section 4 applied to pure helpers.

References:
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- /tmp/adr-3524-review-findings.md (architectural justification)
- Issue #4 (open-gsd/get-shit-done-redux)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add sdk/scripts/gen-phase.mjs generator

Generates phase.generated.cjs from sdk/src/query/phase.ts.
Only pure helpers (isCanonicalPlanFile, describeNonCanonicalPlans) are generated;
async query handlers (findPhase, phasePlanIndex) are I/O-bound and remain per-side
per ADR-3524 Section 4.

References:
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- /tmp/adr-3524-review-findings.md (architectural justification)
- Issue #4 (open-gsd/get-shit-done-redux)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add sdk/scripts/gen-phase-lifecycle.mjs + core idempotency fix logic

Generates phase-lifecycle.generated.cjs providing two pure functions that are the
root-cause fix for issue #4:

1. deriveProgressFromRoadmap(roadmapContent): counts Complete rows in ROADMAP
   progress table — makes completed_phases idempotent (derived from ground truth
   instead of blind +1). Direct transcription of the SDK's "Root cause 1 fix"
   block in phase-lifecycle.ts (~line 1644).

2. clampPercent(completed, total): percent capped at 100 — prevents >100% progress
   when Completed Phases exceeds Total Phases.

Design note: the full phase lifecycle mutations (add, insert, remove, complete) are
inherently async and I/O-bound. Per ADR-3524 Section 4 ("I/O stays per-side"), those
are NOT generated. Only the pure-computation kernel is extracted, following the
I/O adapter pattern: pure logic shared; each side (CJS sync, SDK async) supplies
its own I/O adapter.

The pure functions are defined in the generator as real JS functions and serialized
via Function.prototype.toString() — same technique as gen-project-root.mjs — rather
than embedded in template literals (which would require double-escaping all regex
backslashes).

References:
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- /tmp/adr-3524-review-findings.md (architectural justification)
- Issue #4 (open-gsd/get-shit-done-redux)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): emit phase.generated.cjs, phase-lifecycle.generated.cjs, phase-lifecycle-policy.generated.cjs

Three generated CJS artifacts from their respective generator scripts:

- phase.generated.cjs (1.7K): isCanonicalPlanFile + describeNonCanonicalPlans
  from sdk/src/query/phase.ts
- phase-lifecycle.generated.cjs (3.6K): deriveProgressFromRoadmap + clampPercent
  — the idempotency+clamp fix for issue #4
- phase-lifecycle-policy.generated.cjs (7.0K): 14 pure phase naming/directory
  helpers from sdk/src/query/phase-lifecycle-policy.ts

Run to regenerate:
  node sdk/scripts/gen-phase.mjs
  node sdk/scripts/gen-phase-lifecycle.mjs
  node sdk/scripts/gen-phase-lifecycle-policy.mjs

References:
- ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
- Issue #4 (open-gsd/get-shit-done-redux)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(4): migrate bin/lib/phase.cjs cmdPhaseComplete to use generated helpers

Replace the blind-increment + unclamped percent bug in cmdPhaseComplete with
the idempotent ROADMAP-derived approach: read freshly-updated ROADMAP, call
deriveProgressFromRoadmap() from phase-lifecycle.generated.cjs, fall back to
existing value when ROADMAP is unavailable. clampPercent() prevents >100%.

Root cause fix for issue #4: the original parseInt(completedRaw) + 1 on every
call made phase complete non-idempotent; the missing Math.min(100, ...) clamp
allowed Progress to exceed 100%.

I/O adapter pattern (ADR-3524 §4): pure computation in generated module;
CJS supplies sync readFileSync; SDK supplies async readFile. Same logic, two adapters.

Closes: Tests in 4-phase-complete-cjs-regression.test.cjs go GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add freshness checks + npm scripts for phase generated artifacts (D4/D6)

Add check-phase-fresh.mjs, check-phase-lifecycle-fresh.mjs, and
check-phase-lifecycle-policy-fresh.mjs (same pattern as check-project-root-fresh.mjs:
import buildXCjs() from the generator, regenerate in-memory, byte-compare to committed
file, exit 1 if stale).

Add gen:phase, check:phase-fresh, gen:phase-lifecycle, check:phase-lifecycle-fresh,
gen:phase-lifecycle-policy, check:phase-lifecycle-policy-fresh to sdk/package.json.
Note: gen:phase-lifecycle / check:phase-lifecycle-fresh do not require 'npm run build'
because the generator defines pure functions directly rather than importing dist.

Update shared-module-handsync-allowlist.json: reclassify phase.cjs justification to
reflect that it now consumes phase-lifecycle.generated.cjs for cmdPhaseComplete. The
*.generated.cjs files are excluded by the lint scanner (excludes *.generated.cjs) so
no new allowlist entries are required for the generated artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(4): add freshness-check CI steps for phase, phase-lifecycle, phase-lifecycle-policy

Add three drift-check steps to .github/workflows/test.yml following the same
pattern as the existing freshness checks (ubuntu-latest + node 24 only):
  - SDK generated phase artifact drift check
  - SDK generated phase-lifecycle artifact drift check
  - SDK generated phase-lifecycle-policy artifact drift check

These guard against editors modifying the generated *.cjs files directly.
They run check-phase-fresh.mjs, check-phase-lifecycle-fresh.mjs, and
check-phase-lifecycle-policy-fresh.mjs respectively (added in D4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(4): amend ADR-3524 — phase * I/O adapter pattern for issue #4 (D8)

Append a 2026-05-23 amendment to docs/adr/3524-cjs-sdk-hard-seam.md documenting
the Phase * cooperating-sibling retirement: three new generator scripts extract
pure-computation helpers from phase.ts / phase-lifecycle.ts / phase-lifecycle-policy.ts,
cmdPhaseComplete migrates to deriveProgressFromRoadmap + clampPercent for idempotency,
freshness checks + CI steps added.

Clarifies what is NOT generated (async I/O-bound mutation handlers stay per-side per
Section 4) and notes open drift bugs #6 and #26 for traceability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(4): add changeset fragment for cmdPhaseComplete fix

Refs #4

* fix(154): use canonical /gsd:plan-phase form in phase-lifecycle-policy.ts

Replaces the retired /gsd-plan-phase slash command reference with the
canonical colon-namespaced /gsd:plan-phase in the TS source template
string that feeds the generated CJS roadmap entry helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(154): regenerate phase-lifecycle-policy.generated.cjs after slash-namespace fix

Regenerated via node sdk/scripts/gen-phase-lifecycle-policy.mjs after
fixing /gsd-plan-phase → /gsd:plan-phase in the TS source. Generated
file now contains the canonical colon form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): cross-platform frontmatter regex anchor in 4-phase-complete-cjs-regression.test.cjs

Replaces /^---\n/ with /^---\r?\n/ so the frontmatter extraction helper
in the regression test tolerates Windows CRLF line endings (autocrlf=true
checkout leaves \r before \n, causing /^---\n/ to never match).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(154): add new generated CJS modules to INVENTORY.md and regenerate manifest

Adds three missing rows to the CLI Modules table:
  - phase-lifecycle-policy.generated.cjs
  - phase-lifecycle.generated.cjs
  - phase.generated.cjs

Bumps the headline count from 74 to 77 to match the filesystem.
Also regenerates docs/INVENTORY-MANIFEST.json via
node scripts/gen-inventory-manifest.cjs --write.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): regenerate phase-lifecycle-policy.generated.cjs with hyphen form

Root cause: commit 6cd701f4 regenerated the CJS artifact but at that point
sdk/dist/query/phase-lifecycle-policy.js already had the correct /gsd-plan-phase
(hyphen) form while sdk/src/query/phase-lifecycle-policy.ts still had /gsd:plan-phase
(colon). The generator uses Function.prototype.toString() on the compiled dist, so
the CJS picked up the wrong string from the stale TS source that was compiled into
dist at some earlier point.

Fix: correct the TS source to /gsd-plan-phase and re-run gen-phase-lifecycle-policy.mjs
so that buildPhaseRoadmapEntry in the CJS emits the hyphen form, satisfying the
bug-3584-runtime-slash-emitters.test.cjs assertion at line 179.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): restore /gsd:plan-phase canonical form in phase-lifecycle-policy

Commit 0c3a9c75 incorrectly reverted the slash-namespace fix by misreading
sdk/dist/ (a build artifact in hyphen form for non-Claude runtimes) as the
authoritative source. The canonical form for Claude-facing source is
/gsd:plan-phase (colon-namespaced).

Fix: revert TS source back to /gsd:plan-phase, rebuild dist, regenerate
phase-lifecycle-policy.generated.cjs.

Fixes bug-2543-gsd-slash-namespace test failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): update INVENTORY.md CLI Modules count to 79 after rebase onto main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(154): use hyphen form /gsd-plan-phase in persisted phase section template

- sdk/src/query/phase-lifecycle-policy.ts: use /gsd-plan-phase (routable
  hyphen form) in the phase scaffold template that gets persisted to
  ROADMAP.md; bug-3584 requires persisted artifacts use the hyphen form
- docs/INVENTORY.md: add missing runtime-name-policy.cjs row in CLI
  Modules table
- tests/4-phase-complete-cjs-regression.test.cjs: add maxRetries/retryDelay
  to rmSync calls to satisfy Windows parity ratchet (baseline was 95)
- Regenerate phase-lifecycle-policy.generated.cjs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:47:11 -04:00

282 lines
11 KiB
JavaScript

'use strict';
/**
* Regression test for issue #4 (open-gsd/get-shit-done-redux):
* bin/lib/phase.cjs cmdPhaseComplete — non-idempotent and unclamped.
*
* Root cause (pre-fix):
* cmdPhaseComplete blindly increments "Completed Phases" by 1 on every call
* (parseInt(completedRaw, 10) + 1) and recomputes Progress without a clamp.
* Double-calling yields Completed Phases +2 (not +1), and Progress can exceed 100%.
*
* Fix: derive completed_phases from ROADMAP Complete-row count (idempotent) and
* clamp percent to 100. This mirrors the SDK fix in phase-lifecycle.ts
* (commit deriving from ROADMAP, referenced as "PR #3520" in issue #4).
*
* Note on test structure: phase-command-router.cjs delegates to the SDK when
* the SDK dist is present, so the CJS path is exercised by calling
* cmdPhaseComplete directly (bypassing the router), which is the actual
* function containing the bug.
*
* References:
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) — architectural foundation
* - /tmp/adr-3524-review-findings.md — architectural justification
* - Issue #4 (open-gsd/get-shit-done-redux)
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
// ── Load cmdPhaseComplete directly from phase.cjs (bypass the SDK router) ────
// phase-command-router.cjs delegates to SDK when available; we must test the
// CJS implementation directly since that is where the bug lives.
const phaseModule = require('../get-shit-done/bin/lib/phase.cjs');
const { cmdPhaseComplete } = phaseModule;
// ── Fixture builder ──────────────────────────────────────────────────────────
/**
* Creates a minimal fixture project with:
* - ROADMAP.md with a 4-column progress table (Phase | Plans | Status | Completed)
* - STATE.md with Completed Phases: 0 and Total Phases: 2 (Progress 0%)
* - Phase 01 directory with one plan+summary (to satisfy phase complete guard)
* - Phase 02 directory (next phase)
*/
function createFixture(prefix = 'gsd-4-regression-') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
// ROADMAP.md: Phase 01 not yet complete, Phase 02 not started
// 4-column progress table: Phase | Plans Complete | Status | Completed
const roadmap = [
'# Roadmap',
'',
'- [ ] Phase 01: Foundation',
'- [ ] Phase 02: API',
'',
'### Phase 01: Foundation',
'**Goal:** Build the foundation',
'**Plans:** 1 plans',
'',
'### Phase 02: API',
'**Goal:** Build the API',
'',
'## Progress',
'',
'| Phase | Plans Complete | Status | Completed |',
'|-------|----------------|--------|-----------|',
'| 01. Foundation | 0/1 | Not started | - |',
'| 02. API | 0/1 | Not started | - |',
'',
].join('\n');
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
// STATE.md: Completed Phases: 0, Total Phases: 2, Progress: 0%
// Uses body-field format (bold **Field:** value) so the CJS handler's
// stateExtractField/stateReplaceField path is exercised.
const state = [
'# State',
'',
'**Current Phase:** 01',
'**Current Phase Name:** Foundation',
'**Status:** In progress',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working on phase 1',
'**Completed Phases:** 0',
'**Total Phases:** 2',
'**Progress:** 0%',
'',
].join('\n');
fs.writeFileSync(path.join(planningDir, 'STATE.md'), state);
// Phase 01 directory with a PLAN and SUMMARY so phase complete guard passes
const phase01Dir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase01Dir, { recursive: true });
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
// Phase 02 directory (needed for "next phase" detection)
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
return tmpDir;
}
function readStateMd(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
}
function extractField(stateContent, fieldName) {
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const boldMatch = stateContent.match(new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i'));
if (boldMatch) return boldMatch[1].trim();
const plainMatch = stateContent.match(new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im'));
return plainMatch ? plainMatch[1].trim() : null;
}
function extractFrontmatterField(stateContent, fieldName) {
// Extract from YAML frontmatter block
const fmMatch = stateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return null;
const fm = fmMatch[1];
// Handle both scalar and nested (progress.completed_phases)
const parts = fieldName.split('.');
if (parts.length === 1) {
const m = fm.match(new RegExp(`^${parts[0]}:\\s*(.+)`, 'm'));
return m ? m[1].trim() : null;
}
// Nested: e.g. "progress.completed_phases"
const sectionMatch = fm.match(new RegExp(`^${parts[0]}:\\s*\\n([\\s\\S]*?)(?=\\n[a-z]|$)`, 'm'));
if (!sectionMatch) return null;
const sectionContent = sectionMatch[1];
const fieldMatch = sectionContent.match(new RegExp(`^\\s+${parts[1]}:\\s*(.+)`, 'm'));
return fieldMatch ? fieldMatch[1].trim() : null;
}
// Capture stdout from cmdPhaseComplete (it calls output() which writes to stdout)
function capturePhaseComplete(cwd, phaseNum) {
const { execFileSync } = require('child_process');
const TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
// We invoke gsd-tools directly for the full CJS path, but with GSD_DISABLE_SDK_BRIDGE=1
// to force the CJS implementation. Since no env var disables bridge, we call cmdPhaseComplete
// directly and redirect output capture.
const chunks = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk) => { chunks.push(chunk); return true; };
try {
cmdPhaseComplete(cwd, phaseNum, false);
} finally {
process.stdout.write = origWrite;
}
return chunks.join('');
}
// ── T1: Double invocation must NOT double-increment Completed Phases ─────────
describe('issue #4 (CJS): cmdPhaseComplete — idempotency (blind-increment bug)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
test('T1: double invocation does NOT double-increment Completed Phases in STATE.md body', () => {
// First call — legitimate completion
capturePhaseComplete(tmpDir, '1');
const stateAfter1 = readStateMd(tmpDir);
const completedAfter1Body = extractField(stateAfter1, 'Completed Phases');
const completedAfter1Fm = extractFrontmatterField(stateAfter1, 'progress.completed_phases');
// After first call: Completed Phases in the body should be 1
// (derived from ROADMAP: 1 Complete row after marking phase 01 complete)
// OR the YAML frontmatter completed_phases should be 1
const completedAfter1 = completedAfter1Body || completedAfter1Fm;
assert.equal(
completedAfter1,
'1',
`After first call: completed_phases should be 1.\n` +
`Body field: ${completedAfter1Body}, FM field: ${completedAfter1Fm}\n\n` +
`STATE:\n${stateAfter1}`,
);
// Second call on the same phase — must be idempotent
capturePhaseComplete(tmpDir, '1');
const stateAfter2 = readStateMd(tmpDir);
const completedAfter2Body = extractField(stateAfter2, 'Completed Phases');
const completedAfter2Fm = extractFrontmatterField(stateAfter2, 'progress.completed_phases');
const completedAfter2 = completedAfter2Body || completedAfter2Fm;
// Pre-fix: completedAfter2 would be "2" (blind increment: 1+1=2)
// Post-fix: must remain "1" (derived from ROADMAP)
assert.equal(
completedAfter2,
'1',
`T1 FAILED: Completed Phases was double-incremented.\n` +
`After first call: ${completedAfter1}, after second call: ${completedAfter2}.\n` +
`This is the #4 non-idempotency bug — blind parseInt+1 instead of deriving from ROADMAP.\n\n` +
`STATE after second call (body: ${completedAfter2Body}, fm: ${completedAfter2Fm}):\n${stateAfter2}`,
);
});
});
// ── T2: Progress percent must never exceed 100% ──────────────────────────────
describe('issue #4 (CJS): cmdPhaseComplete — progress percent clamp', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
test('T2: Progress percent never exceeds 100 after double invocation', () => {
tmpDir = createFixture();
// Pre-load STATE.md with Completed Phases: 1, Total Phases: 1 (already 100%)
// so that a blind +1 on a second call would yield 200%
let stateContent = readStateMd(tmpDir);
stateContent = stateContent.replace('**Completed Phases:** 0', '**Completed Phases:** 1');
stateContent = stateContent.replace('**Total Phases:** 2', '**Total Phases:** 1');
stateContent = stateContent.replace('**Progress:** 0%', '**Progress:** 100%');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
// Also update ROADMAP to show just 1 phase total
const roadmap = [
'# Roadmap',
'',
'- [ ] Phase 01: Foundation',
'',
'### Phase 01: Foundation',
'**Goal:** Build the foundation',
'**Plans:** 1 plans',
'',
'## Progress',
'',
'| Phase | Plans Complete | Status | Completed |',
'|-------|----------------|--------|-----------|',
'| 01. Foundation | 0/1 | Not started | - |',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
// First call
capturePhaseComplete(tmpDir, '1');
// Second call — this is the problematic one
capturePhaseComplete(tmpDir, '1');
const stateAfterBoth = readStateMd(tmpDir);
// Check body Progress field
const progressStr = extractField(stateAfterBoth, 'Progress');
const fmPercent = extractFrontmatterField(stateAfterBoth, 'progress.percent');
// Try to extract numeric percent from either source
const bodyPercentMatch = progressStr && progressStr.match(/(\d+)%/);
const bodyPercent = bodyPercentMatch ? parseInt(bodyPercentMatch[1], 10) : null;
const fmPercentNum = fmPercent ? parseInt(fmPercent, 10) : null;
// At least one of body or frontmatter percent must exist and be ≤ 100
const anyPercent = bodyPercent ?? fmPercentNum;
assert.ok(
anyPercent !== null,
`T2: Could not find any percent value in STATE.md\n\nSTATE:\n${stateAfterBoth}`,
);
assert.ok(
anyPercent <= 100,
`T2 FAILED: Progress percent exceeds 100.\n` +
`Body Progress: "${progressStr}" (${bodyPercent}%), FM percent: ${fmPercentNum}%\n` +
`This is the #4 unclamped-percent bug — (N+1)/total can exceed 100.\n\n` +
`STATE:\n${stateAfterBoth}`,
);
});
});