Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
47bf737
feat(loop): near-green rotating-error detector + completion-only stee…
agjs Jul 20, 2026
2ca7738
fix(#77): require a genuine per-cycle edit for near-green rotation (p…
agjs Jul 22, 2026
419fe81
fix(#63): scope the raised test timeout to agent-runner.test.ts only
agjs Jul 22, 2026
f2a3810
fix(#63): split pure config tests out so the raised timeout covers on…
agjs Jul 22, 2026
6f03433
fix(#63): make AgentRunner tests fast at the ROOT (skip whole-repo TS…
agjs Jul 22, 2026
3227dd3
fix(#63): stub tsService on the two structured-mode runs the replace_…
agjs Jul 22, 2026
f3a0a50
fix(build): forbid the model from running the browser E2E / dev serve…
agjs Jul 22, 2026
9877087
test(#63b): lock the load-bearing prompt content (command prohibition…
agjs Jul 22, 2026
5806660
style: prettier-format the strengthened refine-prompt test
agjs Jul 22, 2026
64a98b0
fix(build): deterministically DENY the model from running the browser…
agjs Jul 23, 2026
2ec0fcb
Merge branch 'fix/near-green-rotation-v2' into integration/full-build
agjs Jul 23, 2026
a2b0ff7
Merge branch 'fix/flaky-test-timeout' into integration/full-build
agjs Jul 23, 2026
ff09a5d
Merge branch 'fix/no-model-e2e' into integration/full-build
agjs Jul 23, 2026
4b5594d
fix(acceptance): force PLAYWRIGHT_REUSE_SERVER=true so the harness E2…
agjs Jul 23, 2026
c9b5bc2
fix(acceptance): unique negative-test titles so Playwright doesn't re…
agjs Jul 23, 2026
c178c7e
fix(acceptance): confirm-delete testid must be on the CONFIRM BUTTON,…
agjs Jul 23, 2026
91d24e9
fix(acceptance): scope the sidebar's co-located test so the model kee…
agjs Jul 23, 2026
a8d9055
feat(acceptance): create step surfaces the root cause when a row neve…
agjs Jul 23, 2026
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
1 change: 1 addition & 0 deletions packages/core/src/config/config.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export const ENV_FLAG = {
expertRescue: "TSFORGE_EXPERT_RESCUE",
noNearGreenCheckpoint: "TSFORGE_NO_NEAR_GREEN_CHECKPOINT",
noE2eAcceptance: "TSFORGE_NO_E2E_ACCEPTANCE",
noNearGreenRotation: "TSFORGE_NO_NEAR_GREEN_ROTATION",
} as const;
9 changes: 9 additions & 0 deletions packages/core/src/config/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,13 @@ export const flags = {
* without knowing a flag exists. Kill-switch TSFORGE_NO_NEAR_GREEN_CHECKPOINT=1 disables
* it (A/B control / escape hatch). Thresholds N=2, M=3 from Phase 0a. */
nearGreenCheckpoint: (): boolean => !isOn(ENV_FLAG.noNearGreenCheckpoint),
/** WS-B near-green ROTATION steer (#77): when the build sits at a near-green count but the
* SPECIFIC error set rotates for several cycles (the model fixes one error and the fix spawns
* another — e.g. extracts a component → its siblings/tests are now missing), inject a
* completion-only steer ("finish the files that already have errors; don't create new
* files/modules unless the same edit adds their siblings + tests"). This is the last-mile gap
* that made green non-deterministic (build17 parked on it; build16 crossed by luck). Count-only
* WS-B can't see it (the count never sprays). DEFAULT ON, deterministic (no network); kill with
* TSFORGE_NO_NEAR_GREEN_ROTATION=1. Generic — keyed only on rule/file, no stack knowledge. */
nearGreenRotation: (): boolean => !isOn(ENV_FLAG.noNearGreenRotation),
};
42 changes: 38 additions & 4 deletions packages/core/src/loop/boringstack/acceptance/e2e-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,14 @@ function generateNegativeBlocks(
parentSeedingCode: string
): string {
return entity.negatives
.map((neg) => {
// B2: Test title uses JSON.stringify to escape backticks/interpolation in neg.value
const testTitle = `negative: ${entity.id} rejects ${neg.field}=${neg.value}`;
.map((neg, index) => {
// B2: Test title uses JSON.stringify to escape backticks/interpolation in neg.value.
// The trailing [index] guarantees a UNIQUE title per negative: two negatives can share the
// same (field, value) — e.g. a required-field rule and a "must not be empty" mustNotHappen
// both yield name="" — and Playwright HARD-REJECTS a file with duplicate test titles (the
// whole spec fails to collect, ZERO tests run, and a green feature false-parks). parseStep
// matches on the "negative: " PREFIX, so the suffix does not affect result classification.
const testTitle = `negative: ${entity.id} rejects ${neg.field}=${neg.value} [${String(index)}]`;

// Build valid field assignments (required non-FK fields)
const validFieldAssignments = entity.fields
Expand Down Expand Up @@ -669,6 +674,28 @@ test.describe(${JSON.stringify(name)}, () => {
await authedPage.dashboard.goto();
await navigateTo${entity.id}(page);

// Capture WHY a create can silently fail: the api client throws on non-2xx and React Query
// swallows it into the mutation's error state, so the row just never appears with no clue.
// Record failed /api/ responses + console errors and append them to the assertion failure,
// turning "row not found" into the actual cause (e.g. "POST /api/v1/${entity.key} -> 422 ...").
const apiFailures${entity.id} = [];
page.on("response", (r) => {
const u = r.url();
const method = r.request().method();
// Record every mutation (so we can tell "POST fired -> 201 but row didn't render" from
// "no POST fired at all"), plus any non-2xx.
const isMutation = method === "POST" || method === "PATCH" || method === "DELETE";
if (u.includes("/api/") && (r.status() > 399 || isMutation)) {
void r
.text()
.then((b) => apiFailures${entity.id}.push(method + " " + new URL(u).pathname + " -> " + r.status() + " " + b.slice(0, 200)))
.catch(() => apiFailures${entity.id}.push(method + " " + new URL(u).pathname + " -> " + r.status()));
}
});
page.on("console", (m) => {
if (m.type() === "error") apiFailures${entity.id}.push("console.error: " + m.text().slice(0, 200));
});

// Unique value → this test asserts on ITS OWN row (the shared DB accumulates rows
// across tests/runs, so absolute row counts are unreliable).
// Identity field gets the unique marker (type-aware: may not be the first field)
Expand All @@ -694,7 +721,14 @@ ${fillFirstFieldCode} // Fill identity field with unique value

// THIS test's row (identified by its unique value) appears — retries the async refetch
const row = page.getByTestId("${ids.row}").filter({ hasText: unique });
await expect(row).toBeVisible({ timeout: 10000 });
try {
await expect(row).toBeVisible({ timeout: 10000 });
} catch (e) {
const cause = apiFailures${entity.id}.length > 0
? "\\n\\nAPI activity + console during create (root-cause clue — if the POST is 2xx but the row is missing, the LIST/row render is the bug; if the POST is 4xx or absent, the mutation/form is):\\n" + apiFailures${entity.id}.join("\\n")
: "\\n\\n(No API mutation fired at all — the create form's submit is not wired to the create mutation, or the row renders without the unique text / without data-testid='${ids.row}'.)";
throw new Error((e instanceof Error ? e.message : String(e)) + cause);
}

// Verify the shown cells render the created values (scoped to THIS row only)
${rowCellAssertions}
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/loop/boringstack/acceptance/e2e-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,13 @@ export function makeBoringstackAcceptanceRunner(exec: Exec): IAcceptanceRunner {
...process.env,
PLAYWRIGHT_PORT: uiPort,
VITE_API_BASE: ctx.apiBase,
// The dockerized ui-dev already serves the app on uiPort; Playwright MUST reuse it,
// never start its own host dev server. The scaffold's webServer.command is `bun run dev`,
// whose preflight-host-dev.sh HARD-EXITS 1 while the container is up — so if reuse is
// off (the config gates it on !CI, and a CI=1 build env flips it off) Playwright spawns
// that, exits 1 with ZERO tests run, and the harness reads a green feature as a failed
// acceptance → park. Forcing reuse ON makes Playwright attach to the running server.
PLAYWRIGHT_REUSE_SERVER: "true",
};

const result = await exec(
Expand Down Expand Up @@ -595,6 +602,13 @@ export function makeBoringstackAcceptanceRunner(exec: Exec): IAcceptanceRunner {
...process.env,
PLAYWRIGHT_PORT: uiPort,
VITE_API_BASE: ctx.apiBase,
// The dockerized ui-dev already serves the app on uiPort; Playwright MUST reuse it,
// never start its own host dev server. The scaffold's webServer.command is `bun run dev`,
// whose preflight-host-dev.sh HARD-EXITS 1 while the container is up — so if reuse is
// off (the config gates it on !CI, and a CI=1 build env flips it off) Playwright spawns
// that, exits 1 with ZERO tests run, and the harness reads a green feature as a failed
// acceptance → park. Forcing reuse ON makes Playwright attach to the running server.
PLAYWRIGHT_REUSE_SERVER: "true",
};

const result = await exec(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ ${entity.fields.map((f) => ` - \`data-testid="${ids.field(f.name)}"\` for the "
${entity.shows.map((s) => ` - \`data-testid="${ids.rowCell(s)}"\` for the "${s}" column`).join("\n")}
- **Row edit button**: \`data-testid="${ids.rowEdit}"\` — the edit action on each row
- **Row delete button**: \`data-testid="${ids.rowDelete}"\` — the delete action on each row
- **Delete confirmation dialog**: \`data-testid="${ids.confirmDelete}"\` — the confirmation prompt when deleting a record
- **Delete confirm button**: \`data-testid="${ids.confirmDelete}"\` — put this on the BUTTON that actually performs the deletion (the one whose click fires the delete mutation), NOT on the dialog container/overlay. End-to-end acceptance CLICKS this testid to confirm the delete; if it sits on a wrapper \`<div>\` the click hits the backdrop, the delete never fires, and the row stays (delete acceptance fails).
${
entity.parents.length > 0
? `- **Relationship selectors**: \`data-testid\` for each parent entity select:\n${entity.parents.map((p) => ` - \`data-testid="${ids.field(p.fkField)}"\` for selecting a ${p.key}`).join("\n")}`
Expand All @@ -101,7 +101,7 @@ ${
- Create button: add \`data-testid="${ids.create}"\` to the button that opens the form
- Form component: add \`data-testid="${ids.form}"\` to the \`<form>\`, \`data-testid="${ids.submit}"\` to the submit button, and \`data-testid="${ids.field("...")}\` to each input
- Table rows: add \`data-testid="${ids.row}"\` to each row container, \`data-testid="${ids.rowCell("...")}\` to data cells, \`data-testid="${ids.rowEdit}"\` and \`data-testid="${ids.rowDelete}"\` to action buttons
- Delete confirmation: add \`data-testid="${ids.confirmDelete}"\` to the confirmation dialog
- Delete confirmation: add \`data-testid="${ids.confirmDelete}"\` to the CONFIRM BUTTON inside the delete dialog — the button whose \`onClick\` fires the delete mutation. NOT the dialog wrapper/overlay \`<div>\`: acceptance CLICKS this testid to confirm, and clicking a wrapper hits the backdrop, so the delete never runs and the row is never removed.

**Complete contract** (${required.length} required IDs):
${required.map((id) => `- \`${id}\``).join("\n")}
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/loop/boringstack/build-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,25 @@
import type { ExecutionMode } from "../prompt";
import type { IPolicyRules } from "../../policy";

/**
* Commands the model must NEVER run during a BoringStack build. The browser end-to-end
* acceptance is HARNESS-owned (run automatically after the fast gate, with the right port +
* server reuse). If the model runs it itself — `playwright`, `bun run dev`, `vite`, `dev.sh` —
* it starts a second host dev server that the scaffold's `preflight-host-dev.sh` guard
* HARD-EXITS 1 (the dockerized dev server already holds the port). That is an infra guard, not
* a code error: the model can't fix it, loops on it, and PARKS a feature whose code is already
* green (observed across build22–24, all 4 slices). Guidance alone did not stop it, so this is a
* deterministic policy DENY on the model's shell tool. It does NOT affect the harness's own
* acceptance run, which uses a separate injected exec, not the policy-gated `run` tool.
*/
const NO_BROWSER_E2E_DENY: IPolicyRules = {
deny: [
{
kind: "shell",
commandPattern: "playwright|\\bvite\\b|\\bbun\\s+run\\s+dev\\b|dev\\.sh",
},
],
};

/**
* The BoringStack build's fixed Session flags — spread into `Session.create` by the
Expand All @@ -17,6 +38,7 @@ export const BORINGSTACK_BUILD_SESSION: {
readonly guidance: string;
readonly pullConventions: true;
readonly offerCheck: true;
readonly policyRules: IPolicyRules;
} = {
executionMode: "drive-to-green",
guidance:
Expand All @@ -27,4 +49,5 @@ export const BORINGSTACK_BUILD_SESSION: {
"is locked.",
pullConventions: true,
offerCheck: true,
policyRules: NO_BROWSER_E2E_DENY,
};
12 changes: 12 additions & 0 deletions packages/core/src/loop/boringstack/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ export const APP_SIDEBAR_FILE =
* its feature's route entry, never modify another feature's route. */
export const APP_ROUTES_FILE = "apps/ui/src/app/router/routes.tsx";

/** The sidebar's co-located test. It asserts the EXACT number of nav links, so the moment a
* feature adds its NavLink (required for reachability) the count changes and this test fails at
* the FINAL full-project validate (the fast per-feature gate doesn't run the vitest suite, so it
* only surfaces there → a fully-verified feature still leaves the build "stuck"). The scaffold is
* an external clone we can't edit, so the model must keep this test in sync: scope it in and
* instruct (refinePrompt) to bump the expected link count by its one added link — nothing else. */
export const APP_SIDEBAR_TEST_FILE =
"apps/ui/src/components/core/AppSidebar/AppSidebar.test.tsx";

export function scopeFor(name: string): string[] {
const camel = toCamelCase(name);

Expand All @@ -136,6 +145,9 @@ export function scopeFor(name: string): string[] {
// to the router. Add-only: the model may ADD its feature's entry, never modify others'.
APP_SIDEBAR_FILE,
APP_ROUTES_FILE,
// Co-located sidebar test asserts the exact nav-link count; adding a NavLink changes it, so
// the model must be able to bump the count (see APP_SIDEBAR_TEST_FILE).
APP_SIDEBAR_TEST_FILE,
];
}

Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/loop/boringstack/refine-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ then in the component body \`const editHandler = makeIdHandler(onEdit);\` (and \

**Every visible string via \`t("features.${camel}.<key>")\`** — no hardcoded JSX text (button labels like "Edit"/"Delete" included).

**Make the feature reachable.** Add a sidebar link to \`apps/ui/src/components/core/AppSidebar/AppSidebar.tsx\` (add your feature's entry to the \`APP_SIDEBAR_NAV_ITEMS\` array, following the existing format) and register its route in \`apps/ui/src/app/router/routes.tsx\` (add a route pointing at your feature's page component). Both files are in your editable scope — ADD ONLY your feature's entries, never modify another feature's link/route (same rule as the shared schema + locale files). A feature missing from the sidebar or router is unreachable and fails browser acceptance.
**Make the feature reachable.** Add a sidebar link to \`apps/ui/src/components/core/AppSidebar/AppSidebar.tsx\` (add your feature's entry to the \`APP_SIDEBAR_NAV_ITEMS\` array, following the existing format) and register its route in \`apps/ui/src/app/router/routes.tsx\` (add a route pointing at your feature's page component). Both files are in your editable scope — ADD ONLY your feature's entries, never modify another feature's link/route (same rule as the shared schema + locale files). A feature missing from the sidebar or router is unreachable and fails browser acceptance. **After adding your NavLink, update the sidebar's co-located test \`apps/ui/src/components/core/AppSidebar/AppSidebar.test.tsx\`: it asserts the EXACT number of nav links (e.g. \`toHaveLength(6)\`), so bump that count by exactly one for your added link — otherwise the final full-project validate fails even though your feature is correct.**

---

Expand Down Expand Up @@ -240,6 +240,16 @@ WRONG \`tsc\` that prints "This is not the tsc command you are looking for". NEV
\`npx\`/\`npm\`/\`yarn\` — this stack is bun-only. If you ever must run something else, use
the project's \`bun run <script>\`.

## Do NOT run the browser end-to-end acceptance yourself
The harness runs the full browser (Playwright) acceptance AUTOMATICALLY after the fast gate is
green — it is NOT your job. Do NOT run \`playwright\`/\`bunx playwright test\`, \`bun run dev\`,
\`vite\`, or \`dev.sh\`. The dockerized dev server is already serving the app; starting a second one
on the host is HARD-REFUSED by the \`preflight-host-dev.sh\` guard (exit 1) — that is an
infrastructure guard, NOT a code error, and trying to "fix" it will trap you in a dead loop that
burns the whole turn budget and parks a feature whose code is already correct. When the \`check\`
tool returns \`passed: true\` and your required \`data-testid\`s are present, you are DONE — STOP
there and let the harness verify the browser flow.

---

Begin implementation now.`;
Expand Down
Loading