From 83c372be72e6cd749fb5cfdc23f16f99fd89e65f Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 25 Jul 2026 14:33:08 +0200 Subject: [PATCH 1/2] fix(build): fail fast on a non-PascalCase entity id instead of breaking generation downstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approved-plan build path fed slice.entity.id straight into toCamelCase/wireResource/ reachability/i18n keys with NO validation: the planner is only PROMPTED for PascalCase and the plan validator (isEntitySpec) checks merely non-empty, so a malformed id like "Purchase Order" passed and broke generation opaquely deep downstream (worse in headless, where no human reviews the plan). The resource-planner path already enforces this exact contract via isResourceId — the approved-plan path just missed it. Add invalidEntityIds (reuses isResourceId) + a fail-fast throw in runBoringstackBuild before any generation, surfaced cleanly by headless-build's top-level catch. Pure helper unit-tested; integration test asserts the throw. No gate/status-contract change; single script caller. --- packages/core/src/loop/boringstack/build.ts | 18 +++++++- .../src/loop/boringstack/plan-resources.ts | 14 ++++++ packages/core/tests/boringstack-build.test.ts | 45 ++++++++++++++++++ .../tests/boringstack-plan-resources.test.ts | 46 +++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index e6401771..29cfd392 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -18,7 +18,7 @@ import { refinePrompt } from "./refine-prompt"; import { runGreenfield } from "../greenfield/run"; import { composeBoringstackGate } from "./gate-stages"; import type { Reporter, IHandoff, EscalationRung } from "../loop.types"; -import { slicesToFeatures } from "./plan-resources"; +import { slicesToFeatures, invalidEntityIds } from "./plan-resources"; import { toCamelCase } from "./case"; import { loadApprovedPlan } from "../planning/plan-store"; import type { ISlice, IProductPlan } from "../planning/plan-types"; @@ -691,6 +691,22 @@ export async function runBoringstackBuild(opts: { return { status: "needs-plan", features: [] }; } + // Fail fast on a malformed entity id BEFORE any generation: each id becomes file + // paths, the `Routes` mount identifier, i18n keys, and test ids, so a + // non-identifier id (e.g. "Purchase Order") otherwise breaks generation deep + // downstream with an opaque error. The planner is only prompted for PascalCase and + // the plan validator checks merely non-empty, so enforce the identifier contract here. + const badIds = invalidEntityIds(approved.slices); + + if (badIds.length > 0) { + throw new Error( + `Plan has invalid entity id(s): ${badIds.map((id) => JSON.stringify(id)).join(", ")}. ` + + `Each entity id must be a PascalCase identifier — letter-first, alphanumeric only ` + + `(e.g. "Invoice", "PurchaseOrder") — because it becomes generated file paths, the ` + + `Routes mount identifier, i18n keys, and test ids. Fix the plan's entity id(s) and retry.` + ); + } + // Derive features from the plan's slices const features = slicesToFeatures(approved.slices); diff --git a/packages/core/src/loop/boringstack/plan-resources.ts b/packages/core/src/loop/boringstack/plan-resources.ts index a41b7819..b005e74d 100644 --- a/packages/core/src/loop/boringstack/plan-resources.ts +++ b/packages/core/src/loop/boringstack/plan-resources.ts @@ -166,3 +166,17 @@ export function slicesToFeatures(slices: readonly ISlice[]): IFeature[] { attempts: 0, })); } + +/** + * Entity ids in the plan that are NOT valid PascalCase identifiers. The build turns + * each id directly into generated file paths (`features//…`), the `Routes` + * mount identifier, i18n keys, and test ids — all of which require an identifier-safe + * token. The planner is only PROMPTED for PascalCase and the plan validator checks + * merely non-empty (`plan-store.ts isEntitySpec`), so a malformed id like `"Purchase Order"` + * slips through and breaks generation opaquely downstream. Returned (not thrown) so the + * caller can fail fast with one clear message. Reuses the same `isResourceId` contract the + * resource-planner path already enforces. Pure — unit-tested. + */ +export function invalidEntityIds(slices: readonly ISlice[]): string[] { + return slices.map((s) => s.entity.id).filter((id) => !isResourceId(id)); +} diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index 8f47955a..dfe8aea1 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -477,6 +477,51 @@ describe("runBoringstackBuild", () => { } }); + test("throws a clear error when an entity id is not a PascalCase identifier", async () => { + const dir = await mkdtemp(join(tmpdir(), "bs-")); + + try { + const plan: IProductPlan = { + product: "A simple app", + slices: [ + { + entity: { + id: "Purchase Order", // space → not an identifier-safe id + desc: "x", + fields: [], + relationships: [], + rules: [], + }, + ui: { screens: ["list"], action: "x", shows: [], nav: "x" }, + verification: { + mustRemainTrue: [], + mustNotHappen: ["x"], + acceptanceCheck: "x", + }, + }, + ], + }; + + await writePlan(dir, plan, "approved"); + + // Fail fast with an actionable message BEFORE any generation, rather than + // breaking opaquely downstream on the malformed Routes/path/i18n id. + await expect( + runBoringstackBuild({ + cwd: dir, + goal: "x", + host: createHost(), + evaluator: createEvaluator(), + exec: createExec(), + generate: async () => undefined, + generateUi: async () => undefined, + }) + ).rejects.toThrow(/invalid entity id.*Purchase Order.*PascalCase/su); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + test("derives features from plan slices and passes slice to refinePrompt", async () => { const dir = await mkdtemp(join(tmpdir(), "bs-")); diff --git a/packages/core/tests/boringstack-plan-resources.test.ts b/packages/core/tests/boringstack-plan-resources.test.ts index e17c7a0a..7173f6e3 100644 --- a/packages/core/tests/boringstack-plan-resources.test.ts +++ b/packages/core/tests/boringstack-plan-resources.test.ts @@ -3,8 +3,20 @@ import { planResources, parseResources, dedupeLayerVariants, + invalidEntityIds, } from "../src/loop/boringstack/plan-resources"; import type { IFeature } from "../src/loop/greenfield/greenfield.types"; +import type { ISlice } from "../src/loop/planning/plan-types"; + +const slice = (id: string): ISlice => ({ + entity: { id, desc: `${id} desc`, fields: [], relationships: [], rules: [] }, + ui: { screens: ["list"], action: "view", shows: [], nav: id }, + verification: { + mustRemainTrue: [], + mustNotHappen: ["x"], + acceptanceCheck: "x", + }, +}); const feat = (id: string): IFeature => ({ id, @@ -96,3 +108,37 @@ describe("dedupeLayerVariants", () => { expect(parsed?.map((f) => f.id)).toEqual(["Order"]); }); }); + +describe("invalidEntityIds", () => { + test("returns [] when every entity id is a PascalCase identifier", () => { + expect( + invalidEntityIds([slice("Invoice"), slice("PurchaseOrder"), slice("A1")]) + ).toEqual([]); + }); + + test("flags ids that break the identifier contract, preserving order", () => { + // Each of these becomes file paths / the Routes identifier / i18n keys, so a + // non-PascalCase-identifier id (space, hyphen, leading-lowercase, leading digit, + // symbol) must be rejected up front rather than breaking generation downstream. + const bad = invalidEntityIds([ + slice("Purchase Order"), + slice("Good"), + slice("purchase-order"), + slice("invoice"), + slice("2Fast"), + slice("Wei$rd"), + ]); + + expect(bad).toEqual([ + "Purchase Order", + "purchase-order", + "invoice", + "2Fast", + "Wei$rd", + ]); + }); + + test("returns [] for an empty slice list", () => { + expect(invalidEntityIds([])).toEqual([]); + }); +}); From bc685bfd25860b4d0460e86c621730c1d5589f83 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 25 Jul 2026 14:38:40 +0200 Subject: [PATCH 2/2] test(build): assert the entity-id check fails fast BEFORE any side effect Panel advisory: the throw test would still pass if generate/generateUi/baseline ran before validation, so it didn't protect the fail-fast guarantee. Spy the generators + assert zero calls, zero baseline captures, and no model send on the invalid-id path. --- packages/core/tests/boringstack-build.test.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index dfe8aea1..f3b3fe82 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -506,17 +506,33 @@ describe("runBoringstackBuild", () => { // Fail fast with an actionable message BEFORE any generation, rather than // breaking opaquely downstream on the malformed Routes/path/i18n id. + const host = createHost(); + let generateCalls = 0; + let generateUiCalls = 0; + await expect( runBoringstackBuild({ cwd: dir, goal: "x", - host: createHost(), + host, evaluator: createEvaluator(), exec: createExec(), - generate: async () => undefined, - generateUi: async () => undefined, + generate: async () => { + generateCalls += 1; + }, + generateUi: async () => { + generateUiCalls += 1; + }, }) ).rejects.toThrow(/invalid entity id.*Purchase Order.*PascalCase/su); + + // The guarantee is fail-fast BEFORE any side effect: no code generated, no + // baseline captured, no model turn dispatched. A regression that moved the + // check after generation/baseline would still throw but break these. + expect(generateCalls).toBe(0); + expect(generateUiCalls).toBe(0); + expect(host.metaBaselineCaptures.count).toBe(0); + expect(host.sent).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); }