Skip to content
Merged
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
21 changes: 21 additions & 0 deletions packages/core/scripts/headless-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,33 @@ async function driveBuild(
// flags (drive-to-green, guidance, pull_conventions, the WS-G `check` tool) and is
// unit-tested to advertise `check`, so this wiring can't silently regress. The i18n
// editGuard is stateful (one per build) so it's supplied here, not in the flags.
// A/B lever (near-green rotation): DeepSeek's thinking mode wants temp ~1.0 to avoid
// CoT collapse → repetitive loops; the build otherwise falls through to 0.2. Set
// TSFORGE_BUILD_TEMPERATURE to run the treatment arm. Unset/invalid → session default.
const rawBuildTemp = process.env.TSFORGE_BUILD_TEMPERATURE;
const parsedBuildTemp =
rawBuildTemp === undefined || rawBuildTemp === ""
? Number.NaN
: Number(rawBuildTemp);
const buildTemperature = Number.isFinite(parsedBuildTemp)
? parsedBuildTemp
: undefined;

if (buildTemperature !== undefined) {
report({
kind: "tool",
task: "boringstack",
message: `⚙ build temperature override: ${String(buildTemperature)} (TSFORGE_BUILD_TEMPERATURE)`,
});
}

const host = await createBoringstackHostSession({
provider,
cwd: dir,
contextWindow,
maxTurns: LOOP_LIMITS.webMaxTurns,
report,
temperature: buildTemperature,
// BoringStack overlays (composed, see makeBoringstackBuildGuard): (1) veto
// deletion of a feature translation key the model authored earlier this build;
// (2) veto creating a same-basename .test.ts + .test.tsx twin that orphans the
Expand Down
25 changes: 21 additions & 4 deletions packages/core/src/loop/acceptance/acceptance-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,34 @@ function negativesFor(
return out;
}

/** The implicit auth owner every boringstack entity gets via automatic `userId` scoping. A
* "belongs to a User" relationship documents OWNERSHIP — it is NOT a user-selectable parent FK,
* so it must never become a parent `<select>` or an e2e parent-seed (that seeds a nonexistent
* `POST /api/v1/user` → 404 → acceptance park). The planner's own worked example teaches
* "belongs to a User" for every entity (propose-plan.ts), so without this EVERY build parks. */
const OWNER_ENTITIES: ReadonlySet<string> = new Set(["user"]);

function parseParents(relationships: readonly string[]): IParentRef[] {
const out: IParentRef[] = [];

for (const r of relationships) {
const m = /^belongs\s*to\s+(\w+)/i.exec(r.trim());
// Skip an optional leading article ("belongs to a User" / "an Order" / "the Company"). Without
// this, `(\w+)` captures the ARTICLE ("a") as the entity → a phantom `aId` parent whose seed
// `POST /api/v1/a` 404s and parks the build (live: valbuild23).
const m = /^belongs\s*to\s+(?:(?:an?|the)\s+)?(\w+)/i.exec(r.trim());

if (m === null || typeof m[1] !== "string") {
continue;
}

if (m !== null && typeof m[1] === "string") {
const entity = m[1];
const entity = m[1];

out.push({ entity, key: camel(entity), fkField: `${camel(entity)}Id` });
// The auth owner is implicit (userId), not a parent FK — never emit a select/seed for it.
if (OWNER_ENTITIES.has(entity.toLowerCase())) {
continue;
}

out.push({ entity, key: camel(entity), fkField: `${camel(entity)}Id` });
}

return out;
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/loop/acceptance/acceptance-steer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export function acceptanceSteer(
}

const step = firstFailure.step;
const baseMessage = stepMessage(entity, step);
const baseMessage =
formCloseMessage(entity, firstFailure.detail) ?? stepMessage(entity, step);
let detailSuffix = "";

if (typeof outcome.detail === "string" && outcome.detail.length > 0) {
Expand All @@ -47,6 +48,36 @@ export function acceptanceSteer(
return baseMessage + detailSuffix;
}

/**
* The e2e create/update flow submits the form then waits for it to go HIDDEN
* (`getByTestId('<key>-form').waitFor({ state: "hidden" })`) as the signal the
* mutation + list refresh completed, BEFORE it asserts the new row. A form that
* submits correctly but never closes fails HERE — Playwright reports
* "waiting for … to be hidden … N × locator resolved to visible <form …>". The
* failing step is then classified "create", whose generic message ("ensure the
* create form OPENS") is the exact OPPOSITE of the real fix (it opened fine — it
* won't CLOSE), which misdirects the model into chasing a non-existent
* open/persist bug. Detect that signature from the raw detail and steer at the
* true cause: close the form on a SUCCESSFUL mutation. Returns null when the
* detail is not a form-didn't-close failure (caller falls back to stepMessage).
*/
function formCloseMessage(
entity: IEntityAcceptance,
detail: string
): string | null {
const isFormStillVisible =
/to be hidden/i.test(detail) && /resolved to visible/i.test(detail);

if (!isFormStillVisible) {
return null;
}

const { id, key } = entity;
const singularKey = key.toLowerCase();

return `The ${id} feature failed acceptance because the create/edit form did not close after a successful submit: the browser filled and submitted the form, but the form stayed visible (the e2e waits for it to disappear as the signal the mutation completed). The mutation itself is likely fine — the fix is to CLOSE the form on SUCCESS: hide it from the mutation's onSuccess, e.g. \`createMutation.mutate(input, { onSuccess: () => { closeForm(); } })\` where the page's view-state hook owns the \`showForm\` flag and \`closeForm\` sets it false. Do the same for the edit form. Do NOT re-work persistence — the new ${singularKey} will appear in the list once the form closes and the list refetches.`;
}

function stepMessage(entity: IEntityAcceptance, step: AcceptStep): string {
const { id, key } = entity;
const singularKey = key.toLowerCase();
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/loop/boringstack/build-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export interface IBoringstackHostDeps {
maxTurns: number;
report: Reporter;
editGuard: EditGuard;
/** Sampling temperature for the build session. Omitted → the session default
* (DEFAULT_TEMPERATURE, 0.2). DeepSeek's thinking mode wants ~1.0 to avoid CoT
* collapse / repetitive reasoning loops; exposed so the headless driver can set it
* (e.g. from TSFORGE_BUILD_TEMPERATURE) for the temperature A/B on the near-green
* rotation without touching the loop. */
temperature?: number;
}

/**
Expand All @@ -32,6 +38,9 @@ export function createBoringstackHostSession(
contextWindow: deps.contextWindow,
maxTurns: deps.maxTurns,
...BORINGSTACK_BUILD_SESSION,
...(deps.temperature === undefined
? {}
: { temperature: deps.temperature }),
editGuard: deps.editGuard,
report: deps.report,
});
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/loop/boringstack/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,9 +574,11 @@ export function boringstackDeps(opts: {
// The editable file the expert repairs if a stall's errors are all out of
// scope (locked consumers of this feature's types) — its service file.
host.setExpertRescueTarget((await rescueFileFor(cwd, feature)) ?? "");
await exec(["bun", "run", "db:push", "--", "--force"], {
cwd: join(cwd, "apps/api"),
});
// NOTE: no db:push here — `generate` above IS generateResource, which already
// runs the headless-safe `dbPushForce` (recover-or-throw) on every attempt, and
// the gate's command stage re-pushes (with the same recovery + short-circuit)
// each cycle. A second push here would be redundant and, if it ignored its
// result, could re-introduce the swallowed-failure false-green this fix removes.

// Inject THIS feature's composed gate (differential command + reachability +
// judge). Now settleGate runs it every cycle and the shared ladder escalates
Expand Down
123 changes: 123 additions & 0 deletions packages/core/src/loop/boringstack/db-push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { Exec, IExecResult } from "./exec";

/** A plain SQL identifier. The entity table name is the camelCased entity id
* (a validated PascalCase identifier upstream), so it is always alphanumerics —
* but guard anyway before it is ever interpolated into SQL. */
const SAFE_IDENT = /^[A-Za-z][A-Za-z0-9_]*$/;

/**
* True when `db:push` failed ONLY because drizzle-kit hit its INTERACTIVE column
* resolver. When a plan drops the scaffold's stub `name` column and adds the real
* domain columns (e.g. bookmarks: `title` + `url`, no `name`), drizzle-kit cannot
* tell "rename `name`→`title`?" from "drop `name`, add `title`/`url`" and PROMPTS.
* In the headless build (no TTY) that prompt throws "Interactive prompts require a
* TTY terminal" — and `--force` does NOT cover it (that flag only auto-approves
* data-LOSS statements). The DB then never migrates: every runtime query 500s
* (`column "title" does not exist`), create fails, and the feature is hollow at
* runtime while the gate false-greens.
*
* CRUCIALLY, `bun run db:push` exits 0 on this crash (the drizzle-kit rejection is
* async and never propagates to the exit code — the same swallow that made the gate
* false-green in the first place), so this MUST be detected by the output signature,
* NOT the exit code. Detect that specific failure so we recover ONLY from it and
* never mask a genuinely broken schema.
*/
function isRenamePromptFailure(r: IExecResult): boolean {
const text = `${r.stdout}\n${r.stderr}`;

return (
/Interactive prompts require a TTY/i.test(text) ||
/promptColumnsConflicts|columnsResolver/.test(text)
);
}

/**
* Drop the build's entity table over the SAME connection `db:push` uses —
* `DATABASE_URL` from the exec env, via the app's own `postgres` client (already a
* dependency) — so the follow-up push does a clean CREATE instead of the ambiguous
* rename ALTER. The build DB is disposable (holds no real data; the e2e reseeds), so
* this is safe. No-op when `DATABASE_URL` is unset.
*/
async function dropEntityTable(
apiCwd: string,
exec: Exec,
table: string
): Promise<void> {
const script =
`const p=(await import("postgres")).default;` +
`const url=process.env.DATABASE_URL;` +
`if(!url){process.exit(0);}` +
`const sql=p(url);` +
`try{await sql.unsafe('DROP TABLE IF EXISTS "app"."${table}" CASCADE');}` +
`finally{await sql.end();}`;

// A failed drop is NOT fatal here: the follow-up push will simply hit the same
// rename crash again, which {@link normalize} turns into a surfaced failure. So
// swallow a throwing exec and let the retry be the single source of truth.
try {
await exec(["bun", "-e", script], { cwd: apiCwd });
} catch {
// ignore — the retry push reports the real outcome
}
}

/**
* A `db:push` that "exits 0" but whose output still carries the interactive-rename
* crash did NOT migrate the DB — the async rejection was swallowed. Force such a
* result to a non-zero code so callers (and the gate) can NEVER read it as success
* and false-green on a stale DB. A clean result (no crash signature) is returned
* unchanged; a result already non-zero stays non-zero.
*/
function normalize(r: IExecResult): IExecResult {
if (isRenamePromptFailure(r) && r.code === 0) {
return { ...r, code: 1 };
}

return r;
}

/**
* Run BoringStack's `db:push -- --force`. If it fails ONLY because drizzle-kit hit
* its interactive rename prompt (see {@link isRenamePromptFailure}), drop the build's
* entity table and retry as a clean CREATE — the fix for the headless
* schema-migration wall where a name-less plan removes the scaffold's stub `name`
* column. Without `entityTable`, or for ANY other failure, this behaves exactly like
* a plain push, so the caller's downstream gate still surfaces a genuinely broken
* schema (the model's own compile error) instead of it being masked.
*
* The returned result's `code` is ALWAYS honest: a swallowed rename crash (code 0 +
* signature) — whether on the first push with no recovery available, or on a retry
* that STILL crashed (e.g. the drop was a no-op because DATABASE_URL was unset) — is
* {@link normalize}d to a non-zero code, so a failed migration can never pass as
* success.
*/
export async function dbPushForce(
apiCwd: string,
exec: Exec,
entityTable?: string
): Promise<IExecResult> {
const first = await exec(["bun", "run", "db:push", "--", "--force"], {
cwd: apiCwd,
});

// Detect the crash by its OUTPUT, not the exit code: `bun run db:push` exits 0
// even when drizzle-kit died at the interactive prompt (async rejection swallowed).
// Only recover from THIS signature — any other outcome (real success, or a genuine
// schema error the model must fix) is returned untouched (but still normalized so a
// non-recoverable swallowed crash surfaces).
if (
entityTable === undefined ||
!SAFE_IDENT.test(entityTable) ||
!isRenamePromptFailure(first)
) {
return normalize(first);
}

await dropEntityTable(apiCwd, exec, entityTable);

const retry = await exec(["bun", "run", "db:push", "--", "--force"], {
cwd: apiCwd,
});

return normalize(retry);
}
27 changes: 19 additions & 8 deletions packages/core/src/loop/boringstack/gate-stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { IValidateResult, IErrorItem } from "../../validate";
import type { IProvider } from "../../inference";
import type { IFeature } from "../greenfield/greenfield.types";
import type { Exec, IExecResult } from "./exec";
import { dbPushForce } from "./db-push";
import { toCamelCase } from "./case";
import { runBoringstackGate } from "./gate";
import { extractFailures, ESLINT_PROGRAM_UNPARSABLE } from "./extract-failures";
import { verifyFeatureReachable } from "./reachability";
Expand Down Expand Up @@ -462,18 +464,22 @@ function dbPushError(result: IExecResult): IErrorItem {
export function boringstackCommandStage(
cwd: string,
exec: Exec,
rewritableGlobs: readonly string[] = []
rewritableGlobs: readonly string[] = [],
entityTable?: string
): IStage {
return {
async run(): Promise<IValidateResult> {
await autofixApps(cwd, exec);

// db:push syncs the Drizzle schema to Postgres. Its exit code is NOT discarded: a failed
// push means the DB is out of sync, so running the gate against it would produce confusing
// downstream errors instead of the real cause. Surface it and stop here.
const push = await exec(["bun", "run", "db:push", "--", "--force"], {
cwd: join(cwd, "apps/api"),
});
// db:push with headless recovery: a name-less plan drops the scaffold's stub `name`
// column, so drizzle-kit's interactive rename prompt crashes in the no-TTY build
// (`--force` doesn't cover it) and — because `bun run db:push` exits 0 on that crash —
// the DB silently never migrates and the gate false-greens. dbPushForce detects that
// by OUTPUT signature, drops the (disposable) entity table, retries a clean CREATE,
// and returns an HONEST code (a swallowed/persistent crash → non-zero). The exit-code
// check below then surfaces ANY unrecovered db:push failure via dbPushError (#60), so
// the gate never runs against a stale schema. See db-push.ts.
const push = await dbPushForce(join(cwd, "apps/api"), exec, entityTable);

if (push.code !== 0) {
return {
Expand Down Expand Up @@ -767,7 +773,12 @@ export function composeBoringstackGate(opts: {

return composeGate([
differentialStage(
boringstackCommandStage(cwd, exec, rewritableGlobs),
boringstackCommandStage(
cwd,
exec,
rewritableGlobs,
toCamelCase(feature.id)
),
baseline
),
reachabilityStage(cwd, feature.id),
Expand Down
16 changes: 11 additions & 5 deletions packages/core/src/loop/boringstack/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync } from "node:fs";

import { wireResource, wireUiFeature } from "./wire-resource";
import { toCamelCase } from "./case";
import { dbPushForce } from "./db-push";
import type { Exec } from "./exec";

function makeErrorWithStderr(stderr: string): Error {
Expand Down Expand Up @@ -51,11 +52,16 @@ export async function generateResource(
// just-formatted output would then FAIL the gate's format check.
await execOrThrow(exec, ["bun", "run", "format"], apiCwd);

// `--force` auto-approves drizzle-kit's data-loss statements so `db:push` never
// blocks on its interactive confirmation prompt (which hangs forever in the
// harness's non-TTY exec). Safe here: the build DB holds no real data, and when
// the model iterates on a schema the drop/recreate MUST proceed unattended.
await execOrThrow(exec, ["bun", "run", "db:push", "--", "--force"], apiCwd);
// `--force` auto-approves drizzle-kit's data-LOSS statements — but NOT its
// interactive column-RENAME resolver, which a name-less plan triggers by dropping
// the scaffold's stub `name` column and adding real ones. That prompt crashes in
// the non-TTY build, so `dbPushForce` drops the (disposable) entity table and
// retries as a clean CREATE on exactly that failure. See db-push.ts.
const push = await dbPushForce(apiCwd, exec, camel);

if (push.code !== 0) {
throw makeErrorWithStderr(push.stderr);
}
}

/** Poll the running API's OpenAPI spec until it responds. After `new:resource` +
Expand Down
Loading