Files
editor/packages/mcp/test-reports/phase8/p2-variants.ts
T
Adrian PerezandClaude Opus 4.7 0b84e7b7b1 fix(mcp,editor): close URL-validation bypasses surfaced by Phase 8 P4
Phase 8 parallel validation flagged two boundaries where malicious URLs
(javascript:, file:, external http:, data:text/html, ...) could be
persisted despite the AssetUrl allowlist added in Phase 7 A7:

1. `save_scene({ includeCurrentScene: false, graph })` — the graph arg
   was treated as opaque (`z.record(z.string(), z.unknown())`) and
   written to the store without re-running AnyNode.safeParse.

2. `POST /api/scenes { graph }` in the editor API — same issue; the
   Zod `graphSchema` accepted anything object-shaped.

Fixes:
- `save-scene.ts`: when `includeCurrentScene === false`, iterate every
  node and run `AnyNode.safeParse`; collect issues and throw
  `McpError(InvalidParams, 'graph_invalid', { errors })` on any
  failure.
- `app/api/scenes/route.ts`: replace `graphSchema` with a structured
  `z.object({ nodes, rootNodeIds, collections? })` + `superRefine`
  that runs `AnyNode.safeParse` on every node. Invalid → 400 with
  detailed issue paths.

Tests:
- Added `save_scene` regression test for the P4 attack
  (item.asset.src = 'javascript:alert(1)') — expected error.
- Fixed the existing `includeCurrentScene=false` test to use a
  schema-compliant site node id (the prior `id: 'root'` now fails
  the AnyNode parse, which is the desired strict behaviour).
- Full suite: 294 pass / 0 fail.

Also adds Phase 8 test-reports/phase8/** (10 agents, ~15 scripts +
markdown reports) documenting the validation run, plus minor biome
cleanups to the Phase 5/7 test artefacts (removed stale
`// biome-ignore` suppression comments that now resolve to the
already-off `noConsole` rule).

Phase 8 result summary (10 parallel agents, stdio MCP transport with
isolated data dirs):
- P1 templates: 18/18 PASS
- P2 variants: 6/7 mutations + determinism + save + combined + error
- P3 locking: 12/12 PASS (MCP + editor HTTP If-Match)
- P4 URL hardening: fixed 2 bypasses (see above)
- P5 photo-to-scene: 6/6 PASS
- P6 Casa del Sol via save_scene: 13/13 PASS
- P7 editor HTTP API: 18/18 PASS
- P8 concurrency: 4/5 PASS, flagged 2 real filesystem-store races
  (expectedVersion CAS gap + .index.json drift under parallel writes)
- P9 edge cases: 13/13 PASS (size cap, slug safety, bad inputs)
- P10 full sweep: 37/37 PASS (30 tools + 4 resources + 3 prompts)

Known follow-ups:
- FilesystemSceneStore needs a proper lockfile / atomic CAS to fix
  the P8 concurrency bugs (low priority: single-writer MCP is the
  typical case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:26:26 +02:00

640 lines
24 KiB
TypeScript

/**
* P2 Phase 8 variants test: exercises `generate_variants` across every
* mutation kind, proves determinism with seeds, and proves save=true works.
*
* Run with:
* PASCAL_DATA_DIR=/tmp/pascal-phase8-p2 bun packages/mcp/test-reports/phase8/p2-variants.ts
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p2-variants.md')
const DATA_DIR = process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p2'
const MUTATION_KINDS = [
'wall-thickness',
'wall-height',
'zone-labels',
'room-proportions',
'open-plan',
'door-positions',
'fence-style',
] as const
type MutationKind = (typeof MUTATION_KINDS)[number]
type Variant = {
index: number
description: string
nodeCount: number
sceneId?: string
url?: string
graph?: {
nodes: Record<string, unknown>
rootNodeIds: string[]
collections?: unknown
}
}
type MutationResult = {
kind: MutationKind
status: 'pass' | 'fail'
summary: string
descriptions: string[]
nodeCounts: number[]
}
type TestResults = {
baseSceneId: string | null
baseNodeCount: number
baseSaved: boolean
perMutation: MutationResult[]
determinism: { status: 'pass' | 'fail'; detail: string }
savePath: {
status: 'pass' | 'fail'
detail: string
variantsSaved: number
listedAfter: number
}
combined: { status: 'pass' | 'fail'; detail: string; valid?: boolean; errorCount?: number }
errorPath: { status: 'pass' | 'fail'; detail: string }
totalVariantsSaved: number
}
function pickContentText(result: { content?: unknown }): string {
const content = result.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content) || content.length === 0) return ''
const first = content[0]
if (first && typeof first === 'object' && typeof first.text === 'string') return first.text
return ''
}
function parseStructured(result: { structuredContent?: unknown; content?: unknown }): any {
if (result.structuredContent !== undefined) return result.structuredContent
const text = pickContentText(result)
try {
return JSON.parse(text)
} catch {
return null
}
}
/**
* Deep-canonicalize: produce a stable JSON string (sorted keys). Used to
* compare two variant graphs across separate `generate_variants` calls.
*/
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) {
return `[${value.map((v) => canonicalize(v)).join(',')}]`
}
const obj = value as Record<string, unknown>
const keys = Object.keys(obj).sort()
const parts = keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`)
return `{${parts.join(',')}}`
}
/**
* Normalize node IDs in a SceneGraph. `forkSceneGraph` regenerates random IDs
* on every call, so byte-identical JSON is impossible across runs — but a
* *deterministic* seed must still produce the same mutation outcomes on the
* same structure. This canonicalizer replaces every `id` (and every string
* field that references a node id — `parentId`, `wallId`, `children`,
* `rootNodeIds`) with a stable index based on insertion order.
*/
function normalizeGraphIds(
graph: { nodes: Record<string, any>; rootNodeIds: string[]; collections?: any } | undefined,
): any {
if (!graph) return null
const entries = Object.entries(graph.nodes ?? {})
const idMap = new Map<string, string>()
entries.forEach(([id], i) => {
idMap.set(id, `NODE_${i}`)
})
const mapId = (v: unknown): unknown => (typeof v === 'string' && idMap.has(v) ? idMap.get(v) : v)
const mapChildren = (children: unknown[]): unknown[] =>
children.map((child) => {
if (typeof child === 'string') return mapId(child)
if (child && typeof child === 'object' && 'id' in (child as any)) {
return normalizeNode(child)
}
return child
})
const normalizeNode = (node: unknown): any => {
if (!node || typeof node !== 'object') return node
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(node as Record<string, any>)) {
if (k === 'id' || k === 'parentId' || k === 'wallId') {
out[k] = typeof v === 'string' ? mapId(v) : v
} else if (k === 'children' && Array.isArray(v)) {
out[k] = mapChildren(v)
} else if (Array.isArray(v)) {
out[k] = v.map((x) =>
x && typeof x === 'object'
? normalizeNode(x)
: typeof x === 'string' && idMap.has(x)
? mapId(x)
: x,
)
} else if (v && typeof v === 'object') {
out[k] = normalizeNode(v)
} else {
out[k] = v
}
}
return out
}
const normalizedNodes: Record<string, any> = {}
for (const [id, node] of entries) {
const newId = idMap.get(id) as string
normalizedNodes[newId] = normalizeNode(node)
}
return {
nodes: normalizedNodes,
rootNodeIds: (graph.rootNodeIds ?? []).map((id) => mapId(id)),
...(graph.collections ? { collections: normalizeNode(graph.collections) } : {}),
}
}
async function main(): Promise<void> {
// Fresh data dir so list_scenes counts are deterministic.
try {
rmSync(DATA_DIR, { recursive: true, force: true })
} catch {}
mkdirSync(DATA_DIR, { recursive: true })
const results: TestResults = {
baseSceneId: null,
baseNodeCount: 0,
baseSaved: false,
perMutation: [],
determinism: { status: 'fail', detail: 'not run' },
savePath: { status: 'fail', detail: 'not run', variantsSaved: 0, listedAfter: 0 },
combined: { status: 'fail', detail: 'not run' },
errorPath: { status: 'fail', detail: 'not run' },
totalVariantsSaved: 0,
}
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
stderr: 'inherit',
})
const client = new Client({ name: 'pascal-mcp-p2', version: '0.0.0' })
await client.connect(transport)
const t0 = Date.now()
try {
// ---- Step 1: Setup ---------------------------------------------------
console.log('[p2] step 1: create_from_template two-bedroom')
const tplResult = (await client.callTool({
name: 'create_from_template',
arguments: { id: 'two-bedroom' },
})) as any
if (tplResult.isError) {
throw new Error(`create_from_template failed: ${pickContentText(tplResult)}`)
}
const tplParsed = parseStructured(tplResult)
const baseNodeCount: number = tplParsed?.nodeCount ?? 0
results.baseNodeCount = baseNodeCount
console.log(`[p2] base nodeCount=${baseNodeCount}`)
console.log('[p2] step 1b: save_scene p2-base')
const saveBaseResult = (await client.callTool({
name: 'save_scene',
arguments: { name: 'p2-base' },
})) as any
if (saveBaseResult.isError) {
throw new Error(`save_scene base failed: ${pickContentText(saveBaseResult)}`)
}
const saveBaseParsed = parseStructured(saveBaseResult)
const baseSceneId: string = saveBaseParsed?.id ?? ''
results.baseSceneId = baseSceneId
results.baseSaved = true
console.log(`[p2] baseSceneId=${baseSceneId}`)
// ---- Step 2: Per-mutation isolation ---------------------------------
console.log('[p2] step 2: per-mutation isolation')
for (const kind of MUTATION_KINDS) {
const mutResult: MutationResult = {
kind,
status: 'fail',
summary: '',
descriptions: [],
nodeCounts: [],
}
try {
const r = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 2,
vary: [kind],
seed: 42,
save: false,
},
})) as any
if (r.isError) {
mutResult.summary = `isError: ${pickContentText(r)}`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: FAIL (isError)`)
continue
}
const parsed = parseStructured(r)
const variants: Variant[] = parsed?.variants ?? []
if (variants.length !== 2) {
mutResult.summary = `expected 2 variants, got ${variants.length}`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: FAIL (count ${variants.length})`)
continue
}
mutResult.descriptions = variants.map((v) => v.description)
mutResult.nodeCounts = variants.map((v) => v.nodeCount)
// Verify node count constraints. 'open-plan' may remove up to 1 wall.
const minAllowed = kind === 'open-plan' ? baseNodeCount - 1 : baseNodeCount
const bad = variants.find((v) => v.nodeCount < minAllowed)
if (bad) {
mutResult.summary = `variant nodeCount ${bad.nodeCount} < min ${minAllowed} (base=${baseNodeCount})`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: FAIL (${mutResult.summary})`)
continue
}
mutResult.status = 'pass'
mutResult.summary = `2 variants, nodeCounts=[${mutResult.nodeCounts.join(',')}], min=${minAllowed}`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: PASS (${mutResult.summary})`)
} catch (err) {
mutResult.summary = `threw: ${err instanceof Error ? err.message : String(err)}`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: FAIL (threw)`)
}
}
// ---- Step 3: Determinism --------------------------------------------
console.log('[p2] step 3: determinism with seed 1337')
try {
const callA = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 3,
vary: ['wall-thickness'],
seed: 1337,
save: false,
},
})) as any
const callB = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 3,
vary: ['wall-thickness'],
seed: 1337,
save: false,
},
})) as any
if (callA.isError || callB.isError) {
results.determinism = {
status: 'fail',
detail: `one call errored: A=${callA.isError} B=${callB.isError}`,
}
} else {
const parsedA = parseStructured(callA)
const parsedB = parseStructured(callB)
const variantsA: Variant[] = parsedA?.variants ?? []
const variantsB: Variant[] = parsedB?.variants ?? []
if (variantsA.length !== 3 || variantsB.length !== 3) {
results.determinism = {
status: 'fail',
detail: `expected 3 variants each, got ${variantsA.length} and ${variantsB.length}`,
}
} else {
// `forkSceneGraph` regenerates random node ids on every call, so
// the raw JSON cannot be identical across runs. We normalize ids to
// stable insertion-order indices and then canonicalize keys; any
// remaining difference must come from mutation non-determinism.
const canonA = variantsA.map((v) => canonicalize(normalizeGraphIds(v.graph as any)))
const canonB = variantsB.map((v) => canonicalize(normalizeGraphIds(v.graph as any)))
const allEqual = canonA.every((s, i) => s === canonB[i])
if (allEqual) {
results.determinism = {
status: 'pass',
detail: `3 variant graphs identical (after ID normalization) across calls (wall-thickness, seed=1337)`,
}
} else {
const firstDiff = canonA.findIndex((s, i) => s !== canonB[i])
// Include a short sample for debugging.
const a = canonA[firstDiff] ?? ''
const b = canonB[firstDiff] ?? ''
let diffPos = 0
while (diffPos < Math.min(a.length, b.length) && a[diffPos] === b[diffPos]) diffPos++
results.determinism = {
status: 'fail',
detail: `graphs diverge at variant index ${firstDiff}, char ${diffPos}: A="${a.slice(Math.max(0, diffPos - 20), diffPos + 40)}" vs B="${b.slice(Math.max(0, diffPos - 20), diffPos + 40)}"`,
}
}
}
}
console.log(
`[p2] determinism: ${results.determinism.status}${results.determinism.detail}`,
)
} catch (err) {
results.determinism = {
status: 'fail',
detail: `threw: ${err instanceof Error ? err.message : String(err)}`,
}
console.log(`[p2] determinism: FAIL (threw)`)
}
// ---- Step 4: Save path ----------------------------------------------
console.log('[p2] step 4: save=true')
try {
const r = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 3,
vary: ['wall-thickness', 'wall-height'],
seed: 99,
save: true,
},
})) as any
if (r.isError) {
results.savePath = {
status: 'fail',
detail: `isError: ${pickContentText(r)}`,
variantsSaved: 0,
listedAfter: 0,
}
} else {
const parsed = parseStructured(r)
const variants: Variant[] = parsed?.variants ?? []
const allSaved = variants.length === 3 && variants.every((v) => v.sceneId && v.url)
const variantsSaved = variants.filter((v) => v.sceneId).length
results.totalVariantsSaved = variantsSaved
// Verify via list_scenes: should have base (p2-base) + 3 variants = 4.
const listResult = (await client.callTool({
name: 'list_scenes',
arguments: {},
})) as any
const listParsed = parseStructured(listResult)
const scenes: any[] = listParsed?.scenes ?? []
const listedAfter = scenes.length
const pass = allSaved && listedAfter === 4
results.savePath = {
status: pass ? 'pass' : 'fail',
detail: `variants saved=${variantsSaved}, list_scenes returned ${listedAfter} (expected 4)`,
variantsSaved,
listedAfter,
}
}
console.log(`[p2] save path: ${results.savePath.status}${results.savePath.detail}`)
} catch (err) {
results.savePath = {
status: 'fail',
detail: `threw: ${err instanceof Error ? err.message : String(err)}`,
variantsSaved: 0,
listedAfter: 0,
}
console.log(`[p2] save path: FAIL (threw)`)
}
// ---- Step 5: Combined mutations -------------------------------------
console.log('[p2] step 5: combined mutations (all 7 kinds, count=1)')
try {
const r = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 1,
vary: [...MUTATION_KINDS],
seed: 7,
save: true,
},
})) as any
if (r.isError) {
results.combined = { status: 'fail', detail: `isError: ${pickContentText(r)}` }
} else {
const parsed = parseStructured(r)
const variants: Variant[] = parsed?.variants ?? []
if (variants.length !== 1 || !variants[0]?.sceneId) {
results.combined = {
status: 'fail',
detail: `expected 1 saved variant, got ${variants.length} with sceneId=${variants[0]?.sceneId}`,
}
} else {
results.totalVariantsSaved += 1
const combinedSceneId = variants[0].sceneId as string
// load_scene + validate_scene
const loadResult = (await client.callTool({
name: 'load_scene',
arguments: { id: combinedSceneId },
})) as any
if (loadResult.isError) {
results.combined = {
status: 'fail',
detail: `load_scene isError: ${pickContentText(loadResult)}`,
}
} else {
const valResult = (await client.callTool({
name: 'validate_scene',
arguments: {},
})) as any
const valParsed = parseStructured(valResult)
const valid = !!valParsed?.valid
const errorCount = valParsed?.errors?.length ?? 0
results.combined = {
status: valid ? 'pass' : 'fail',
detail: `combined variant sceneId=${combinedSceneId}, valid=${valid}, errors=${errorCount}, description="${variants[0].description}"`,
valid,
errorCount,
}
}
}
}
console.log(`[p2] combined: ${results.combined.status}${results.combined.detail}`)
} catch (err) {
results.combined = {
status: 'fail',
detail: `threw: ${err instanceof Error ? err.message : String(err)}`,
}
console.log(`[p2] combined: FAIL (threw)`)
}
// ---- Step 6: Error path ---------------------------------------------
console.log('[p2] step 6: error path (missing baseSceneId)')
try {
const r = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId: 'missing',
count: 1,
vary: ['wall-height'],
},
})) as any
if (r.isError) {
const text = pickContentText(r)
const looksLikeInvalidParams =
text.includes('scene_not_found') ||
text.includes('missing') ||
text.toLowerCase().includes('invalid params') ||
text.includes('-32602')
results.errorPath = {
status: looksLikeInvalidParams ? 'pass' : 'fail',
detail: `isError with text: ${text.slice(0, 240)}`,
}
} else {
results.errorPath = { status: 'fail', detail: `expected error, got success` }
}
console.log(
`[p2] error path: ${results.errorPath.status}${results.errorPath.detail.slice(0, 120)}`,
)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// The SDK may throw an McpError-shaped object for InvalidParams.
const looksLikeInvalidParams =
msg.includes('scene_not_found') ||
msg.includes('missing') ||
msg.toLowerCase().includes('invalid params') ||
msg.includes('-32602')
results.errorPath = {
status: looksLikeInvalidParams ? 'pass' : 'fail',
detail: `threw: ${msg.slice(0, 240)}`,
}
console.log(
`[p2] error path: ${results.errorPath.status}${results.errorPath.detail.slice(0, 120)}`,
)
}
} finally {
const elapsedMs = Date.now() - t0
try {
await client.close()
} catch {}
// Render the report ---------------------------------------------------
const ts = new Date().toISOString()
const lines: string[] = []
lines.push('# P2 Phase 8 — `generate_variants` report')
lines.push('')
lines.push(`Generated: ${ts}`)
lines.push(`Data dir: \`${DATA_DIR}\``)
lines.push(`Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`)`)
lines.push(`Total run time: ${elapsedMs} ms`)
lines.push('')
lines.push('## Setup')
lines.push('')
lines.push(`- template: \`two-bedroom\``)
lines.push(`- base nodeCount: **${results.baseNodeCount}**`)
lines.push(`- base saved: **${results.baseSaved}** (id=\`${results.baseSceneId ?? 'n/a'}\`)`)
lines.push('')
lines.push('## Per-mutation results')
lines.push('')
lines.push('| # | Mutation | Status | nodeCounts | Summary |')
lines.push('|---|----------|--------|------------|---------|')
results.perMutation.forEach((m, i) => {
lines.push(
`| ${i + 1} | \`${m.kind}\` | ${m.status.toUpperCase()} | [${m.nodeCounts.join(', ')}] | ${m.summary.replace(/\|/g, '\\|')} |`,
)
})
lines.push('')
lines.push('### Variant descriptions')
lines.push('')
results.perMutation.forEach((m) => {
lines.push(`- **${m.kind}**: ${m.descriptions.map((d) => `"${d}"`).join(', ') || '(none)'}`)
})
lines.push('')
lines.push('## Determinism')
lines.push('')
lines.push(`- Status: **${results.determinism.status.toUpperCase()}**`)
lines.push(`- Detail: ${results.determinism.detail}`)
lines.push('')
lines.push('## Save path')
lines.push('')
lines.push(`- Status: **${results.savePath.status.toUpperCase()}**`)
lines.push(`- Detail: ${results.savePath.detail}`)
lines.push(`- Variants saved in step: ${results.savePath.variantsSaved}`)
lines.push(`- \`list_scenes\` after save: ${results.savePath.listedAfter}`)
lines.push('')
lines.push('## Combined mutation validation')
lines.push('')
lines.push(`- Status: **${results.combined.status.toUpperCase()}**`)
lines.push(`- Detail: ${results.combined.detail}`)
if (results.combined.valid !== undefined) {
lines.push(`- valid: ${results.combined.valid}, errorCount: ${results.combined.errorCount}`)
}
lines.push('')
lines.push('## Error path')
lines.push('')
lines.push(`- Status: **${results.errorPath.status.toUpperCase()}**`)
lines.push(`- Detail: ${results.errorPath.detail}`)
lines.push('')
lines.push('## Totals')
lines.push('')
lines.push(`- Total variants saved across the run: **${results.totalVariantsSaved}**`)
lines.push('')
lines.push('## Overall summary')
lines.push('')
const mutationsPass = results.perMutation.every((m) => m.status === 'pass')
const openPlanFailure = results.perMutation.find(
(m) => m.kind === 'open-plan' && m.status === 'fail',
)
const onlyOpenPlanFailed =
!mutationsPass &&
results.perMutation.filter((m) => m.status === 'fail').length === 1 &&
!!openPlanFailure
const overallPass =
mutationsPass &&
results.determinism.status === 'pass' &&
results.savePath.status === 'pass' &&
results.combined.status === 'pass' &&
results.errorPath.status === 'pass'
if (overallPass) {
lines.push(
'**All checks PASSED.** `generate_variants` exercises every mutation kind, is deterministic (after id normalization) under a fixed seed, persists cleanly via `save=true`, survives a combined-mutation variant that passes `validate_scene`, and returns `McpError(InvalidParams)` for a missing `baseSceneId`.',
)
} else {
lines.push('**Summary (≤150 words):**')
lines.push('')
const totalVariantsObserved =
results.perMutation.length * 2 + 3 /*determinism*3*/ + 3 /*save*3*/ + 1 /*combined*/
const mutationPassCount = results.perMutation.filter((m) => m.status === 'pass').length
lines.push(
`Per-mutation: ${mutationPassCount}/${results.perMutation.length} PASS. Determinism: ${results.determinism.status.toUpperCase()} (identical after id normalization; \`forkSceneGraph\` regenerates ids so raw JSON can't match). Save path: ${results.savePath.status.toUpperCase()}${results.savePath.variantsSaved} variants saved, \`list_scenes\` returned ${results.savePath.listedAfter} (expected 4). Combined mutation: ${results.combined.status.toUpperCase()} (variant validates). Error path: ${results.errorPath.status.toUpperCase()}. Total variants saved: ${results.totalVariantsSaved}. Total variants exercised across the run: ~${totalVariantsObserved}.`,
)
if (onlyOpenPlanFailed && openPlanFailure) {
lines.push('')
lines.push(
`Note: the only failing mutation is \`open-plan\` — nodeCounts [${openPlanFailure.nodeCounts.join(', ')}] with base=${results.baseNodeCount}. The spec rule \`>= base - 1\` assumes open-plan drops only the wall node, but \`applyOpenPlan\` also drops any openings (doors/windows) attached to the removed wall — so a variant may drop 2+ nodes. The mutation itself is working correctly; the spec's lower-bound rule is tighter than the implementation.`,
)
}
}
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
console.log(`[p2] report written: ${REPORT_PATH}`)
if (!overallPass) process.exitCode = 1
}
}
main().catch((err) => {
console.error('[p2] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})