diff --git a/packages/core/src/config/config.constants.ts b/packages/core/src/config/config.constants.ts index 119b554e..2e8dfd5a 100644 --- a/packages/core/src/config/config.constants.ts +++ b/packages/core/src/config/config.constants.ts @@ -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; diff --git a/packages/core/src/config/flags.ts b/packages/core/src/config/flags.ts index a768f6ec..eac07e6c 100644 --- a/packages/core/src/config/flags.ts +++ b/packages/core/src/config/flags.ts @@ -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), }; diff --git a/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts b/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts index 3e81c752..41999cad 100644 --- a/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts +++ b/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts @@ -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 @@ -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) @@ -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} diff --git a/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts b/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts index 09803d97..7ebcccb2 100644 --- a/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts +++ b/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts @@ -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( @@ -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( diff --git a/packages/core/src/loop/boringstack/acceptance/testid-contract.ts b/packages/core/src/loop/boringstack/acceptance/testid-contract.ts index 9ea96911..56c88899 100644 --- a/packages/core/src/loop/boringstack/acceptance/testid-contract.ts +++ b/packages/core/src/loop/boringstack/acceptance/testid-contract.ts @@ -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 \`
\` 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")}` @@ -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 \`
\`, \`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 \`
\`: 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")} diff --git a/packages/core/src/loop/boringstack/build-config.ts b/packages/core/src/loop/boringstack/build-config.ts index 3d0e7600..457078a3 100644 --- a/packages/core/src/loop/boringstack/build-config.ts +++ b/packages/core/src/loop/boringstack/build-config.ts @@ -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 @@ -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: @@ -27,4 +49,5 @@ export const BORINGSTACK_BUILD_SESSION: { "is locked.", pullConventions: true, offerCheck: true, + policyRules: NO_BROWSER_E2E_DENY, }; diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index 154a2126..8d9ac00c 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -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); @@ -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, ]; } diff --git a/packages/core/src/loop/boringstack/refine-prompt.ts b/packages/core/src/loop/boringstack/refine-prompt.ts index f5a1df2c..31fed259 100644 --- a/packages/core/src/loop/boringstack/refine-prompt.ts +++ b/packages/core/src/loop/boringstack/refine-prompt.ts @@ -195,7 +195,7 @@ then in the component body \`const editHandler = makeIdHandler(onEdit);\` (and \ **Every visible string via \`t("features.${camel}.")\`** — 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.** --- @@ -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