Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/core/src/loop/boringstack/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 `<camel>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 ` +
`<id>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);

Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/loop/boringstack/plan-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<camel>/…`), the `<camel>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));
}
61 changes: 61 additions & 0 deletions packages/core/tests/boringstack-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <camel>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-"));

Expand Down
46 changes: 46 additions & 0 deletions packages/core/tests/boringstack-plan-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <camel>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([]);
});
});