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..f3b3fe82 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -477,6 +477,67 @@ 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. + const host = createHost(); + let generateCalls = 0; + let generateUiCalls = 0; + + await expect( + runBoringstackBuild({ + cwd: dir, + goal: "x", + host, + evaluator: createEvaluator(), + exec: createExec(), + 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 }); + } + }); + 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([]); + }); +});