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
137 changes: 137 additions & 0 deletions docs/superpowers/specs/2026-07-17-ui-completeness-scaffold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Hollow-UI Completeness Fix — Design Spec (task #50)

**Status:** step (1) — the refine-prompt CRUD contract (incl. API full-CRUD
requirement + UI test siblings) — is IMPLEMENTED and reviewer-gated. Steps (2)
mutations-first scaffold and (3) semantic reachability remain, pending an empirical
build to measure whether the contract alone suffices.

## Problem (verified)

The harness builds a BoringStack CRUD feature with a **complete API** (working
list/create routes + service, forced by a working scaffold + generated tests) but
a **hollow UI**: a list-only page with no create/edit/delete form, no confirmation,
no error/success toasts. The feature passes the gate anyway, and the model deletes
the translations it wrote (the i18n guard now blocks that, but the root remains).

### Root cause — API/UI scaffold asymmetry

| | API (`apps/api/scripts/codegen/new-resource.ts`) | UI (`apps/ui/scripts/codegen/new-feature.ts`) |
|---|---|---|
| Scaffold emits | WORKING list + create routes, service methods | BARE page (loading+empty only); `useCreate` STUB (returns input); empty query; no update/delete |
| Model's job | extend (add columns/logic) | **invent the entire UI from scratch** |
| Tests force it | generated route tests must pass | only a "renders heading" test |
| Reachability check | route mounted + schema table | UI route registered + `features.<x>.{title,empty}` present — **passes a hollow page** |
| Prompt | "service must implement listForUser + create" | "complete React feature slice" (vague) |

`reachability.ts:checkFeatureReachable` (tsforge) checks only: UI route registered,
API mounted, i18n title/empty present. `refine-prompt.ts:111-112` is vague. So a
page that renders "loading… / nothing here yet" satisfies everything.

## REVISED design (after 3-lens reviewer critique — 2026-07-17)

The full Option B (below) was reviewed and DESCOPED. Findings:
- **Gate-greenness:** a full List/Form/DeleteConfirm tree = ~24 files each needing
the 5-sibling folder structure + no-state-in-tsx + no-inline-JSX-fns + complexity
≤ 20 + no-dead-i18n-keys + test siblings. Very high risk of a RED baseline; Form
is the worst. "Mirror auth exactly" is insufficient.
- **Scope:** the real gap is the STUB mutations (`useCreate` returns input; no
update/delete). A rigid skeleton also fights diverse entities (relations, enums).
- **Robustness (adversarial):** every enforcement layer is gameable by a determined
drive-to-green model (keep placeholder field; delete scaffold files via `run`;
vacuous tests; import-without-call). Same class as the i18n `run`-bypass.

**Decision:** don't chase airtight enforcement (that was the i18n 6-round spiral).
The model is LAZY, not adversarial — make the correct path the easy path:

1. **refine-prompt contract** (tsforge, pure prompt — build FIRST, lowest risk):
replace vague "complete React feature slice" with an explicit per-field contract:
"For EACH field under Display, add a form field; wire create + edit + delete with
a confirm; surface errors via `t(features.<x>.<action>Error)`." Turns the passive
nudge (refine-prompt.ts:165) into a contract. Cheap, high-leverage for a lazy model.
2. **mutations-first scaffold** (boringstack PR): real `useCreate/useUpdate/useDelete`
(+ `.mutations.test.ts` sibling with `vi.mock` asserting the call). Shape-agnostic,
~1 file + test, low red-baseline risk. Removes the "invent CRUD wiring" gap.
3. **semantic reachability backstop** (tsforge, AST not substring): the feature page
must CALL a mutation + render a form — version-gated on a scaffold marker so it
can't false-fail builds on the old scaffold.
4. **DEFER** the full List/Form/DeleteConfirm component tree — too rigid + red-baseline
risk. The contract + working mutations anchor the model to build them.

Sequencing: (1) now; (2) boringstack PR, verify a fresh `new:feature` is gate-green;
(3) after (2) bakes, gated on scaffold version. Incremental, small rollback surface.

---

## Original Option B (superseded by the revised design above; kept for reference)

## Fix — mirror the API: pre-generate a WORKING CRUD UI skeleton (Option B)

Make `new:feature` emit a gate-green, working CRUD UI the model EXTENDS (adds
domain fields) rather than invents — exactly how the API scaffold works. Pair with
generated UI tests that assert the mutations fire, so completeness is forced (as the
API's tests force it), and a reachability tightening as a backstop.

### What the scaffold emits (all gate-green out of the box)

Grounded in the starter's real pattern (`auth/LoginCredentialsForm`,
`MfaChallengeForm` — react-hook-form + zod). Per feature `<Name>`:

1. **`<Name>.mutations.ts`** — real `useCreate<Name>`, `useUpdate<Name>`,
`useDelete<Name>` (currently only a `useCreate` stub). Each invalidates the list
query on success; `onError` surfaces the error i18n key.
2. **`<Name>Form.tsx`** — create/edit form: one generic field (`name`) the model
renames/extends, react-hook-form + zod, submit → `useCreate`/`useUpdate`, error
rendered via `t("features.<x>.<action>Error")`.
3. **`<Name>List.tsx`** — renders query data in a table/list: shows the generic
field + an Edit button (opens form) + a Delete button (opens confirm).
4. **`<Name>DeleteConfirm.tsx`** — confirm dialog → `useDelete`, `t(confirmDelete)`.
5. **`<Name>Page.tsx`** — composes List + Form (create/edit) + DeleteConfirm with
view state (list | creating | editing) — not just loading/empty.
6. **i18n keys** (seed in every locale + `wire-resource.ts:addFeatureI18nKeys`):
`title, empty, create, edit, delete, save, cancel, confirmDelete,
createError, updateError, deleteError, createSuccess, updateSuccess,
deleteSuccess` — all REFERENCED by the emitted components (so no unused-key
churn; the i18n guard then protects them).
7. **Tests** — `<Name>Form.test.tsx` (renders fields, submit calls the create
mutation), `<Name>List.test.tsx` (renders rows, delete opens confirm). These
FORCE the model to keep the wiring real (they fail if it strips the form).

### tsforge-side backstop (`reachability.ts`)

Tighten `checkFeatureReachable` to also require the feature slice references the
CRUD mutations (`useCreate<Name>` / `useUpdate<Name>` / `useDelete<Name>`) and a
form — so a stripped-back page fails reachability, not just the i18n rule.

### refine-prompt

Replace the vague "complete React feature slice" with the explicit contract: the
skeleton exists; extend the Form fields to the real domain fields, wire each to a
mutation, keep the List/Delete/error-handling. (Prose reinforces; scaffold+tests
enforce.)

## Key risks (for reviewers)

1. **The scaffold skeleton MUST be gate-green as the baseline** (strict lint: no
`as`, complexity ≤ 20, i18n every string, component-per-file, hooks rules). If
the generated skeleton is red, every build starts red. This is the hardest part
— mirror `auth` exactly.
2. **Generic field vs domain fields:** the skeleton ships one placeholder field
(`name`); the model must extend to real fields. Does one generic field remove
enough of the "invent from scratch" gap, or does the model still ship thin?
3. **Two repos:** scaffold change is a BoringStack PR (`new-feature.ts` +
`wire-resource` i18n seeds); the reachability tightening + prompt are tsforge.
Must land coordinated (scaffold first, else reachability tightening false-fails).
4. **Test-forces-completeness vs flakiness:** generated UI tests that assert
mutation calls must be deterministic (mock the client) and not brittle.

## Alternatives considered

- **(A) Reachability static-scan only** (no scaffold change): brittle regex on
JSX, and the model still invents from scratch → likely still thin. Weaker.
- **(C) Prompt-only:** proven insufficient (the model shortcuts under drive-to-green).

## Rollout

1. BoringStack PR: enhance `new-feature.ts` + i18n seeds; verify a fresh
`new:feature` is gate-green (typecheck/lint/test/build). 2. tsforge PR:
reachability tightening + refine-prompt. 3. Live build to measure UI realness.
36 changes: 28 additions & 8 deletions packages/core/src/loop/boringstack/refine-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ export function refinePrompt(feature: IFeature, slice?: ISlice): string {

const productContext = slice ? `\n\n${productContextSection(slice)}\n` : "";

// Where the model reads the resource's PERSISTED/EDITABLE domain fields from — the
// columns to store and the form inputs to render. This is the entity's **Fields**
// ONLY: **Display** is a rendering hint (may be computed values, relationship
// labels, or a read-only subset) and must NOT be turned into columns or inputs.
// With a slice the Product Context section is present; without one, fall back to
// the behavior so no instruction dangles a reference to a section that wasn't emitted.
const domainFields = slice
? "the entity's **Fields** in Product Context above (the **Display** list is for rendering only — do NOT treat it as columns or form inputs)"
: `the fields implied by the behavior "${feature.desc}"`;

return `You are implementing the **${feature.id}** resource.

**Behavior**: ${feature.desc}${priorFailure}${productContext}
Expand All @@ -98,27 +108,37 @@ export function refinePrompt(feature: IFeature, slice?: ISlice): string {
You MUST fill in these generated files for the **${feature.id}** resource:

### Persistence (apps/api/src/clients/postgres/schema/app.schema.ts)
- The \`${camel}\` Drizzle table is generated with only stub columns (\`id\`, \`userId\`, \`name\`, timestamps). **Add the real domain columns to it** — one column per field listed under Product Context above (choose the right Drizzle type: \`varchar\`/\`text\` for strings, \`boolean().notNull().default(false)\` for booleans, \`integer\`/\`numeric\` for numbers, \`timestamp\` for dates; make \`[optional]\` fields nullable). These columns are what actually persists — the service and types must read/write REAL columns, never in-memory-only fields.
- The \`${camel}\` Drizzle table is generated with only stub columns (\`id\`, \`userId\`, \`name\`, timestamps). **Add the real domain columns to it** — one column per ${domainFields} (choose the right Drizzle type: \`varchar\`/\`text\` for strings, \`boolean().notNull().default(false)\` for booleans, \`integer\`/\`numeric\` for numbers, \`timestamp\` for dates; make \`[optional]\` fields nullable). These columns are what actually persists — the service and types must read/write REAL columns, never in-memory-only fields.
- **Import every column builder you use.** If you add a \`boolean\`/\`text\`/\`integer\`/\`numeric\`/\`jsonb\` column, that identifier MUST be added to the existing \`import { ... } from "drizzle-orm/pg-core"\` line at the top of the file. A missing import is a \`ReferenceError\` that crashes the API on boot — the #1 cause of a failed build here.
- Edit ONLY the \`${camel}\` table. Do NOT touch any other table in this file.

### API Layer (apps/api/src/api/${camel}/)
- \`apps/api/src/api/${camel}/${camel}.schemas.ts\` — request/response validation schemas using **Elysia TypeBox** (\`import { t } from "elysia"\` → \`t.Object({ title: t.String(), … })\`). This is the API boundary — do NOT use Zod here; Zod is only for UI form/runtime validation.
The UI needs FULL CRUD, so the API must expose it. The scaffold ships only list+create — you MUST add get-one, update, and delete so the UI's edit/delete have real endpoints (missing routes surface as typed api-client failures after \`generate:api\`).
- \`apps/api/src/api/${camel}/${camel}.routes.ts\` — Elysia routes for the full set: **list (\`GET /\`), get-one (\`GET /:id\`), create (\`POST /\`), update (\`PATCH /:id\`), delete (\`DELETE /:id\`)**. Every route stays under \`requireAuth()\` and passes \`user.id\` to the service on EVERY call. A schema on EVERY route; \`ApiErrors.*\` (never \`throw new Error\`).
- \`apps/api/src/api/${camel}/${camel}.service.ts\` — a user-scoped method backing each route, EXTENDING the scaffold's existing \`listForUser(userId)\` + \`create({ …, userId })\` convention (do NOT rename them — that breaks the baseline): add \`getForUser(id, userId)\`, \`updateForUser(id, userId, data)\`, \`deleteForUser(id, userId)\`. Real Drizzle ops, not stubs.
- **SECURITY (critical): every get/update/delete MUST filter by BOTH the row id AND the authenticated \`userId\`** — \`where: and(eq(${camel}.id, id), eq(${camel}.userId, userId))\` (import \`and\` and \`eq\` from \`drizzle-orm\` — a missing import is the same boot-crashing ReferenceError as a missing column builder). A row is owned by one user; an id-only query lets one user read, modify, or delete another user's records (horizontal privilege escalation). Never look up or mutate a row by id alone.
- \`apps/api/src/api/${camel}/${camel}.schemas.ts\` — request/response validation schemas for each operation (create body, update body, item response) using **Elysia TypeBox** (\`import { t } from "elysia"\` → \`t.Object({ title: t.String(), … })\`). This is the API boundary — do NOT use Zod here; Zod is only for UI form/runtime validation.
- \`apps/api/src/api/${camel}/${camel}.types.ts\` — TypeScript types for domain entities and DTO objects
- \`apps/api/src/api/${camel}/${camel}.service.ts\` — Service layer business logic
- \`apps/api/src/api/${camel}/${camel}.routes.ts\` — Elysia routes: a schema on EVERY route; \`ApiErrors.*\` for errors (never \`throw new Error\`)

### UI Feature (apps/ui/src/features/${camel}/)
- The complete React feature slice for ${feature.id} (pages, components, hooks, state)
The generated feature is a HOLLOW starting point — a list-only page plus STUB hooks (\`use${feature.id}\` returns \`[]\`, \`useCreate${feature.id}\` just returns its input). A page that only lists records (or only shows an empty state) is an INCOMPLETE feature and must not be shipped. Build the REAL CRUD UI:
- **Mutations** (\`${feature.id}.mutations.ts\` — PascalCase file in \`apps/ui/src/features/${camel}/\`): implement real \`useCreate${feature.id}\`, \`useUpdate${feature.id}\`, and \`useDelete${feature.id}\` — each calls \`@/lib/api/client\` (\`apiClient.POST/PATCH/DELETE\`). **The typed api-client resolves HTTP 4xx/5xx as a result object \`{ data, error }\` — it does NOT throw.** So each mutationFn MUST \`const { data, error } = await apiClient.POST(…); if (error) throw error; return data;\` — otherwise React Query treats a failed request as a success and runs \`onSuccess\` on an error. Then invalidate the list query in \`onSuccess\`; surface the thrown failure via \`onError\`. Do NOT leave the stub that returns its input.
(Use the REAL domain fields of ${feature.id} — ${domainFields} — NOT a single placeholder \`name\`.)
- **List query** (\`${feature.id}.queries.ts\` — PascalCase file): implement \`use${feature.id}\` to actually fetch via \`apiClient.GET\` (the scaffold stub returns \`[]\`, so the list is permanently empty and create→appears-in-list is impossible until you do this). Same result-object rule as mutations: the queryFn MUST \`const { data, error } = await apiClient.GET(…); if (error) throw error; return data;\` — do NOT return \`data\` without the \`error\` check, or a failed fetch silently resolves to \`undefined\` and the query never enters its error state.
- **List**: render the fetched records (not just an empty state) — one row per record showing those domain fields, each row with **Edit** and **Delete** actions.
- **Create/Edit form**: one input per domain field. Validate with Zod; on submit call the create/update mutation; on error render \`t("features.${camel}.<action>Error")\`.
- **Delete**: a confirmation using \`t("features.${camel}.confirmDelete")\` that calls \`useDelete${feature.id}\`.
- The UI must carry out the feature's flow end to end (create → it appears in the list → edit → delete). Wiring the mutations into a real form + list is what makes the i18n error/confirm keys "used" — that is the intended way to clear \`i18n-locale-keys-used\`, never by deleting keys.

---

## Required Test Siblings

The linter enforces test coverage. You MUST write:
The linter enforces test coverage — **every logic file you add or change needs a mirrored test sibling**, API and UI alike:

- \`apps/api/tests/api/${camel}/${camel}.routes.test.ts\` — API endpoint tests
- \`apps/api/tests/api/${camel}/${camel}.service.test.ts\` — Service layer unit tests
- \`apps/api/tests/api/${camel}/${camel}.routes.test.ts\` — API endpoint tests covering create/update/delete, not just list.
- \`apps/api/tests/api/${camel}/${camel}.service.test.ts\` — Service layer unit tests. **These MUST include an ownership-isolation test that PROVES the userId scoping above:** create a row as user A, then assert user B's \`getForUser\`/\`updateForUser\`/\`deleteForUser\` on that id find/affect NOTHING (returns not-found / 0 rows — the same as a missing id), and that user A still can. Without this test the id-only privilege-escalation bug passes the gate — prose alone does not stop it.
- \`apps/ui/src/features/${camel}/…\` — **vitest**, a co-located mirrored test for every UI logic file you write (mutations/hooks/form). Beyond unit-testing the mutations (mock \`@/lib/api/client\`, assert \`useCreate${feature.id}\`/\`useUpdate${feature.id}\`/\`useDelete${feature.id}\` call it), include a test that RENDERS the feature page and drives the REAL flow through the list: fill the create form and submit (assert the create mutation fires), then trigger a rendered row's **Edit** (assert the update mutation fires) and **Delete** confirmation (assert the delete mutation fires). A test that only checks the hooks call the client — or that exercises create alone — can pass while update/delete stay disconnected from the page and the feature is still hollow; it MUST drive edit and delete from the rendered list.

Without these test files, the build will fail.

Expand Down
Loading