Turn a NEEDS-CONTRACT gap — a scenario a tester pre-wrote that the contract can't back yet — into honestly-provisional backend stubs: a @suluk/hono contract literal plus a handler through an adapter seam.
Part of Suluk — one typed OpenAPI v4 contract projecting into every full-stack layer.
CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf.
bun add @suluk/stubgen
In the journeys arc a tester can pre-write a scenario the contract can't back yet — a
NEEDS-CONTRACT gap. @suluk/stubgen turns that gap into two provisional halves the maintainer
then writes pragmatically (C040-P3):
renderContract emits a @suluk/hono RouteContract
literal — method / path / name inferred from the intent text ("I refund a charge" → refundCharge,
POST, /refund-charge), the request Zod inferred from the gap's Examples columns, responses a
placeholder. Every inference is tagged // TODO: tighten: the inferred Zod is lossy by
construction and the maintainer owns the final schema — nothing is laundered as authoritative.HandlerTarget is the seam (mirroring @suluk/deploy's provider seam). The first adapter,
honoEffectTarget, emits the toolfactory idiom (an Effect program + the run() boundary + a
contract-derived RouteError<name>); honoTarget is a framework-generic fallback.Zero-dependency and pure — source text in, source text out. @suluk/core never imports it, and it
imports nothing.
import { generateStub, generateStubs, honoTarget } from "@suluk/stubgen";
// A gap: the authored intent + the Examples columns (with a sample cell for type inference).
const stub = generateStub({
intent: "I refund a charge",
fields: [{ name: "chargeId" }, { name: "amountCents", sample: "500" }],
});
stub.name; // "refundCharge" — inferred from the intent
stub.contract; // the @suluk/hono RouteContract literal (paste into contractDoc([...]))
stub.handler; // the Effect + run() + RouteError handler (honoEffectTarget by default)
stub.contract renders (Zod inferred, amountCents seen as a number, every inference flagged):
// STUB contract (generated from a NEEDS-CONTRACT gap: "I refund a charge"). Tighten Zod + responses, then implement the handler.
{
method: "post",
path: "/refund-charge",
name: "refundCharge",
summary: "TODO: describe refundCharge.",
request: { json: z.object({ chargeId: z.string() /* TODO: tighten */, amountCents: z.number() /* TODO: tighten */ }) },
responses: [{ status: 200, schema: z.object({}) /* TODO: response shape */ }],
},
Choose the framework-generic handler idiom instead of the Effect one, or batch many gaps:
const generic = generateStub({ intent: "I list invoices" }, honoTarget);
const all = generateStubs([{ intent: "I create a webhook" }, { intent: "I delete a key" }]);
| Export | What it does |
|---|---|
generateStub(gap, target?) |
The contract + handler for one gap → GeneratedStub (defaults to honoEffectTarget). |
generateStubs(gaps, target?) |
The same for many gaps. |
stubSpec(gap) |
Resolve a gap to a renderable StubSpec (infers name / method / path / field types). |
renderContract(spec) |
The generic @suluk/hono RouteContract literal half. |
honoEffectTarget |
The HandlerTarget for the toolfactory idiom (Effect + run() + RouteError). |
honoTarget |
A framework-generic Hono HandlerTarget fallback. |
Types StubGap, StubSpec, StubField, HandlerTarget, and GeneratedStub are exported
alongside.
@suluk/stubgen emits honestly-provisional source text and nothing more — every inference is
lossy and flagged // TODO: tighten, so the maintainer owns the final contract and never inherits a
laundered schema. It is zero-dependency and pure; the app-specific handler idiom is injected through
the HandlerTarget seam. It's the gap-filling step of the @suluk/journeys arc and
emits contracts for @suluk/hono.
Apache-2.0