From 7cd1dd471484a1548b4d278ddf4f8bdf633c6999 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Mon, 20 Jul 2026 13:46:37 +0200 Subject: [PATCH] feat(conventions+gate): api-client path/call-shape guide + two-cause PathsWithMethod steer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build12 (i18n active) built the full slice + cleared the dead-key wall, then PARKED because every colocated test sprayed on generated api-client USAGE errors — panel 4/4: wrong path (`/api/supplier` vs real `/api/v1/supplier[/{id}]`), PATCH called with 3 args, Readable mistaken for the item. Model reran generate:api 5× (wrong lever — these are consumer bugs, the route was already in the spec). - data-fetching guide: verified path format (/api/v1/[/{id}], literal {id}) and call shapes. Options object is OPTIONAL (plain GET is 1 arg); params+body go in ONE options object — never a third positional arg (fixes the panel's 'ALWAYS two args' contradiction). Readable = route response-schema fix. - gate-stages PathsWithMethod steer: REWRITTEN to name BOTH causes call-site-FIRST (wrong /api/v1 string vs unregistered route) — the old steer said only 'route not in spec + generate:api', directly contradicting the guide and mis-steering build12's wrong-string case. Now consistent front-load + per-error steer. - tests: method<->shape pairings (not loose fragments); gate-stages test asserts the two-cause steer + wrong-lever note. --- .../core/src/loop/boringstack/gate-stages.ts | 14 ++++---- packages/core/src/loop/conventions.ts | 36 ++++++++++++++----- .../tests/boringstack-conventions.test.ts | 25 +++++++++++++ .../tests/boringstack-gate-stages.test.ts | 22 +++++++----- 4 files changed, 74 insertions(+), 23 deletions(-) diff --git a/packages/core/src/loop/boringstack/gate-stages.ts b/packages/core/src/loop/boringstack/gate-stages.ts index 48c18865..4c7d7b3d 100644 --- a/packages/core/src/loop/boringstack/gate-stages.ts +++ b/packages/core/src/loop/boringstack/gate-stages.ts @@ -126,12 +126,12 @@ export function signatureToError(sig: string): IErrorItem { ? " This `'>' expected` in a `.ts` is usually JSX in a non-JSX file — if it contains JSX, it must be a `.tsx` (a `.ts` parses `` as a generic and demands `>`)." : ""; - // The api-client's types are GENERATED from the OpenAPI spec. Calling - // `apiClient.("/api/v1/x")` for a path not yet in the spec fails typing with - // `PathsWithMethod` — the tell that the UI was written BEFORE the API route - // was registered + the client regenerated, NOT a UI-type bug to fight in the component. - // Steer to fix the ORDER, not the call site (observed: model ground on this + the - // coupled test-sibling for 20+ turns instead of touching the route). + // The api-client's types are GENERATED from the OpenAPI spec. A `PathsWithMethod` + // error means the path STRING doesn't match any generated key — which has TWO causes, and + // the steer must name both (observed live: build12's model used `/api/supplier` — a wrong + // CALL-SITE string, the route `/api/v1/supplier/{id}` was already in the spec — yet re-ran + // generate:api 5× because the old steer only ever said "route not in spec"). Cause 1 (check + // FIRST, cheaper): the path literal is wrong. Cause 2: the route genuinely isn't registered. const isPathsWithMethod = message.includes("PathsWithMethod"); return { @@ -143,7 +143,7 @@ export function signatureToError(sig: string): IErrorItem { message: isParse ? `${message}\n↳ SYNTAX/PARSE error — do NOT surgically patch it (a patch on a broken-parse file re-breaks its braces/generics/JSX). REWRITE THE WHOLE FILE \`${file}\` cleanly in one pass; once it parses, downstream errors clear.${parseSteer}` : isPathsWithMethod - ? `${message}\n↳ This path is NOT in the OpenAPI spec yet — the api-client types are GENERATED from it, so don't fight the UI types. Make sure the API route exists AND is registered/mounted (so it shows in /swagger/json), then let the gate run — it re-runs generate:api and the path type appears. A \`/api/v1/…\` literal rejected by \`PathsWithMethod\` means the route isn't in the spec; fix the route/registration, not the call site.` + ? `${message}\n↳ \`PathsWithMethod<…, "">\` means no generated key matches this path AND method. Three causes — check the call site FIRST (generate:api fixes NEITHER of the first two): (1) WRONG PATH string — the literal must be EXACTLY \`/api/v1/\` (every route is mounted under \`/api/v1/\` — \`/api/x\` or \`/x\` is wrong) with a literal \`{id}\` segment, value via \`{ params: { path: { id } } }\`, never interpolated; (2) WRONG VERB — the path exists but doesn't support this method (e.g. a GET-only path called with POST); call the method the route actually defines; (3) ONLY if path AND verb are already right is the route genuinely unregistered — ensure it exists AND is mounted (shows in /swagger/json), then the gate re-runs generate:api and the type appears. Do NOT re-run generate:api for a call-site (path/verb) bug.` : message, }; } diff --git a/packages/core/src/loop/conventions.ts b/packages/core/src/loop/conventions.ts index 956dae66..29cbd0fb 100644 --- a/packages/core/src/loop/conventions.ts +++ b/packages/core/src/loop/conventions.ts @@ -156,14 +156,34 @@ const GUIDES: Readonly> = { "the component rendering the field state the hook returns.", "data-fetching": "DATA-FETCHING (boringstack). ALL HTTP goes through the generated client " + - "`@/lib/api/client` — never `fetch`/`axios` (lint-banned). Call it as " + - '`const { data } = await apiClient.GET("/path")` (or `.POST`/`.PATCH`/`.DELETE` ' + - "with `{ body }`); `data` is typed from the OpenAPI spec. Errors THROW " + - "automatically via the client's `throwOnError` middleware (TanStack Query catches " + - "them) — so NEVER check `response.error`: it is typed `undefined`, so the guard is " + - "a dead `no-unnecessary-condition` gate error. Just read `data`. Put queries in " + - "`.queries.ts` and mutations in `.mutations.ts`. If a path isn't " + - "in the spec yet, add the API route first, then `bun run generate:api`.", + "`@/lib/api/client` — never `fetch`/`axios` (lint-banned). PATH STRINGS are the #1 thing " + + "the model gets wrong: the path is a LITERAL that must exactly match a key in the generated " + + "`paths` type, and every route is mounted under `/api/v1/` — so it is " + + '`apiClient.GET("/api/v1/")`, NOT `"/"` or `"/api/"` (a wrong ' + + "string is a `PathsWithMethod`/'not assignable' error that `generate:api` will NEVER fix — it " + + "is a usage bug, not a stale-spec bug). For a by-id path, use the LITERAL `{id}` segment and " + + "pass the value via params — never string-interpolate the id into the path. CALL SHAPE: the " + + "options object is OPTIONAL — a plain list GET is one arg (`GET(path)`); when you need params " + + "and/or a body they ALL go together in ONE options object as the SECOND arg — there is NEVER a " + + "third positional argument:\n" + + '• list: `const { data } = await apiClient.GET("/api/v1/supplier")`\n' + + '• by id: `const { data } = await apiClient.GET("/api/v1/supplier/{id}", { params: { path: { id } } })`\n' + + '• query: `await apiClient.GET("/api/v1/supplier", { params: { query: { status: "active" } } })`\n' + + '• create: `const { data } = await apiClient.POST("/api/v1/supplier", { body: input })`\n' + + '• update: `const { data } = await apiClient.PATCH("/api/v1/supplier/{id}", { params: { path: { id } }, body: input })`\n' + + '• delete: `await apiClient.DELETE("/api/v1/supplier/{id}", { params: { path: { id } } })`\n' + + "PATCH/PUT take path AND body in the SAME options object (one object as the second arg — " + + "passing body as a THIRD argument is an arity error: 'Expected 2 arguments, but got 3'). Errors THROW automatically via the " + + "client's `throwOnError` middleware (TanStack Query catches them) — so NEVER check " + + "`response.error`: it is typed `undefined`, so the guard is a dead `no-unnecessary-condition` " + + "gate error. Just read `data`; let TS infer its type (do NOT annotate a return type or `as`-cast " + + "it — index into it with a null-narrow, e.g. `data?.items ?? []`, to satisfy strict undefined " + + "checks). If `data` types as `Readable>` instead of the JSON body, the API " + + "ROUTE is missing a single-content-type `response:` schema — fix the route (see api-service), " + + "not the consumer. Put queries in `.queries.ts` and mutations in " + + "`.mutations.ts`. If a path genuinely isn't in the spec yet, add the API route first, " + + "then `bun run generate:api` ONCE — if the error persists after one regen, the path STRING or " + + "call shape is wrong, not the spec.", "lint-gotchas": "STRICT-LINT GOTCHAS (boringstack). The gate is eslint with EVERY rule an error; a fresh " + "feature trips these most, so write them right up front:\n" + diff --git a/packages/core/tests/boringstack-conventions.test.ts b/packages/core/tests/boringstack-conventions.test.ts index 460fac0c..d12be416 100644 --- a/packages/core/tests/boringstack-conventions.test.ts +++ b/packages/core/tests/boringstack-conventions.test.ts @@ -126,6 +126,31 @@ describe("convention registry", () => { expect(g).toContain("throwOnError"); }); + test("data-fetching guide pins the exact api-client path format + call shapes (build12 park cluster)", () => { + const g = conventionGuide("data-fetching"); + + // The path bug that sprayed build12: model used "/api/supplier" — must be "/api/v1/…". + expect(g).toContain("/api/v1/"); + expect(g).toContain("PathsWithMethod"); + // Full method↔shape pairings (not loose fragments) — a fragment on the wrong method must fail. + expect(g).toContain('apiClient.POST("/api/v1/supplier", { body: input })'); + expect(g).toContain( + 'apiClient.PATCH("/api/v1/supplier/{id}", { params: { path: { id } }, body: input })' + ); + expect(g).toContain( + 'apiClient.DELETE("/api/v1/supplier/{id}", { params: { path: { id } } })' + ); + // Options are OPTIONAL and there is never a THIRD positional arg (the corrected contradiction). + expect(g).toContain("options object is OPTIONAL"); + expect(g).toContain("NEVER a " + "third positional argument"); + expect(g).toContain("arity error"); + // generate:api is the WRONG lever for a consumer/usage error (model reran it 5×). + expect(g).toContain("generate:api"); + expect(g).toContain("usage bug, not a stale-spec bug"); + // The Readable wrapper is a ROUTE response-schema problem, not a consumer fix. + expect(g).toContain("Readable { const g = conventionGuide("testing"); diff --git a/packages/core/tests/boringstack-gate-stages.test.ts b/packages/core/tests/boringstack-gate-stages.test.ts index f7c89218..20959504 100644 --- a/packages/core/tests/boringstack-gate-stages.test.ts +++ b/packages/core/tests/boringstack-gate-stages.test.ts @@ -141,24 +141,30 @@ describe("signatureToError", () => { expect(err.message).toContain("REWRITE THE WHOLE FILE"); }); - test("a PathsWithMethod type error steers to REGISTER the route (not fight the UI types)", () => { - // Observed live (build6): model called apiClient.POST('/api/v1/supplier') before the - // route was in the OpenAPI spec → fought the UI types + coupled test-sibling for 20+ turns. + test("a PathsWithMethod type error steers to BOTH causes, call-site FIRST (build12: wrong /api/… string, route WAS in spec)", () => { + // build12: model used a wrong call-site string and re-ran generate:api 5× because the old + // steer only ever said "route not in spec". The steer must name the call-site cause first. const msg = - "Argument of type '\"/api/v1/supplier\"' is not assignable to parameter of type 'PathsWithMethod'."; + "Argument of type '\"/api/supplier\"' is not assignable to parameter of type 'PathsWithMethod'."; const err = signatureToError( `failure:apps%2Fui%2Fsrc%2Ffeatures%2Fsupplier%2FSupplier.mutations.ts:12:no-unsafe:${encodeURIComponent(msg)}` ); - expect(err.message).toContain("NOT in the OpenAPI spec"); - expect(err.message).toContain("register"); - expect(err.message).toContain("generate:api"); + // Call-site cause named first, with the exact /api/v1/ format. + expect(err.message).toContain("call site"); + expect(err.message).toContain("/api/v1/"); + // All three causes present: wrong path, wrong VERB (method-specific), unregistered route. + expect(err.message).toContain("WRONG PATH"); + expect(err.message).toContain("WRONG VERB"); + expect(err.message).toContain("unregistered"); + // …and the wrong-lever warning: don't re-run generate:api for a call-site string bug. + expect(err.message).toContain("Do NOT re-run generate:api"); // A plain type error (no PathsWithMethod) is left untouched. const plain = signatureToError( "failure:apps%2Fui%2Fsrc%2Fx.ts:3:no-unsafe:Type%20'string'%20is%20not%20assignable%20to%20'number'." ); - expect(plain.message).not.toContain("NOT in the OpenAPI spec"); + expect(plain.message).not.toContain("call site"); }); test("a `'>' expected` in a .ts flags the JSX-must-be-.tsx cause; other .ts parse errors do NOT", () => {