From 96dec534e113f0865b3c6d396c4a824b3f0fea8b Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 23 May 2026 08:31:15 +0900 Subject: [PATCH] fix(planner): sync entity names when --rename overrides a target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The planner names each entity after its rename target (it emits both `Shopkeeper -> Monitor` and an entity `Monitor` that replaces Shopkeeper). A --rename override updated renamePlan but not domain.entities[].name, so the validation report contradicted itself — rename plan showed `Shopkeeper -> Resident` while the entity card still read "Monitor (replaces Shopkeeper)". Add syncEntityNames(): for each "changed" override outcome, rename any entity whose `replaces` matches the override's source AND whose name still equals the planner's old target (an entity deliberately named differently is left alone). Wired into dispatch right after applyRenameOverrides. Report-metadata coherence only — code generation keys off renamePlan, not entities, so generated code was already correct (verified on a real sentova run: 108 files use the override target, zero use the old one). - src/rename-overrides.ts: syncEntityNames (pure) - src/dispatch.ts: call it alongside the renamePlan override - tests: 2 new covering rename, divergent-name, no-op, and no-match Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dispatch.ts | 9 +++++++-- src/rename-overrides.ts | 23 ++++++++++++++++++++++- tests/smoke.test.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/dispatch.ts b/src/dispatch.ts index 85ed1ee..a59136b 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -17,7 +17,7 @@ import { runLayer1 } from "./validation/layer1.js"; import { runLayer2, type Layer2Mode } from "./validation/layer2.js"; import type { RepairAttempt, RunReport } from "./report/model.js"; import type { JudgeResult, Platform, PlatformDetail, RenamePair, WorkerResult } from "./agents/types.js"; -import { applyRenameOverrides, type OverrideOutcome } from "./rename-overrides.js"; +import { applyRenameOverrides, syncEntityNames, type OverrideOutcome } from "./rename-overrides.js"; import { isValidSlug, slugToPascal } from "./slug.js"; export type DispatchReportOptions = { @@ -77,7 +77,12 @@ export async function dispatch(spec: string, options: DispatchOptions = {}): Pro else if (o.kind === "noop") trace("dispatch", `rename override: ${o.from}=${o.to} already the planned target — no change`); else trace("dispatch", `rename override ignored: no planned rename for "${o.from}" (got ${o.from}=${o.to})`); } - domain = { ...domain, renamePlan: merged.plan }; + // Carry the override into entity names too, so the report's entity cards + // agree with its rename plan (the planner names entities after their + // targets). Code generation keys off renamePlan, not entities — this is + // metadata coherence, not a code-affecting change. + const entities = syncEntityNames(domain.entities, merged.outcomes); + domain = { ...domain, renamePlan: merged.plan, entities }; } // Mirror the substrate's NATIVEAPPTEMPLATE_API_* config to the diff --git a/src/rename-overrides.ts b/src/rename-overrides.ts index 313d862..5d5413f 100644 --- a/src/rename-overrides.ts +++ b/src/rename-overrides.ts @@ -1,4 +1,4 @@ -import type { RenamePair } from "./agents/types.js"; +import type { Entity, RenamePair } from "./agents/types.js"; // Manual rename overrides (ROADMAP §"Optional explicit naming overrides"). // The planner is the sole source of the rename plan; these let a human veto a @@ -54,3 +54,24 @@ export function applyRenameOverrides( } return { plan: merged, outcomes }; } + +// Keep entity names in step with rename-plan overrides. The planner names each +// entity after its rename target (e.g. it emits both `Shopkeeper -> Monitor` +// and an entity `Monitor` that `replaces` Shopkeeper), so a `--rename` that +// changes the target must also rename the entity or the report contradicts its +// own rename plan. Only entities whose name still matches the planner's old +// target are touched — an entity deliberately named differently is left alone. +export function syncEntityNames( + entities: readonly Entity[], + outcomes: readonly OverrideOutcome[], +): readonly Entity[] { + const changes = new Map(); + for (const o of outcomes) { + if (o.kind === "changed") changes.set(o.from, { was: o.was, to: o.to }); + } + if (changes.size === 0) return entities; + return entities.map((e) => { + const change = changes.get(e.replaces); + return change && e.name === change.was ? { ...e, name: change.to } : e; + }); +} diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 678031b..f486868 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1390,6 +1390,40 @@ test("applyRenameOverrides reports unmatched + noop overrides distinctly", async ]); }); +test("syncEntityNames renames an entity whose name still matches the overridden target", async () => { + const { syncEntityNames } = await import("../src/rename-overrides.js"); + const entities = [ + { name: "Household", replaces: "Shop", fields: [{ name: "address", type: "string" as const }] }, + { name: "Monitor", replaces: "Shopkeeper", fields: [], states: ["Active", "Resolved"] as const }, + ]; + const synced = syncEntityNames(entities, [ + { kind: "changed", from: "Shopkeeper", was: "Monitor", to: "Resident" }, + ]); + assert.equal(synced[0]?.name, "Household"); // untouched + assert.equal(synced[1]?.name, "Resident"); // renamed Monitor -> Resident + // `replaces` and other fields are preserved. + assert.equal(synced[1]?.replaces, "Shopkeeper"); + assert.deepEqual(synced[1]?.states, ["Active", "Resolved"]); +}); + +test("syncEntityNames leaves entities alone when name diverges, no changed outcomes, or no match", async () => { + const { syncEntityNames } = await import("../src/rename-overrides.js"); + const entities = [{ name: "Watcher", replaces: "Shopkeeper", fields: [] }]; + // Name diverges from the old target → conservative: leave it. + assert.equal( + syncEntityNames(entities, [{ kind: "changed", from: "Shopkeeper", was: "Monitor", to: "Resident" }])[0]?.name, + "Watcher", + ); + // No changed outcomes (noop/unmatched only) → returns input untouched. + const noop = syncEntityNames(entities, [{ kind: "noop", from: "Shop", to: "Clinic" }]); + assert.equal(noop, entities); + // Override targets a token no entity replaces → unchanged. + assert.equal( + syncEntityNames(entities, [{ kind: "changed", from: "Shop", was: "Store", to: "Household" }])[0]?.name, + "Watcher", + ); +}); + test("dispatch applies a rename override end-to-end and surfaces the outcome (stub pipeline)", async () => { const result = await dispatch("a walk-in clinic queue for small veterinary practices", { renameOverrides: [{ from: "Shopkeeper", to: "Provider" }],