@suluk/stubgen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@suluk/stubgen",
3
+ "version": "0.1.0",
4
+ "description": "Generate honestly-provisional backend STUBS from a NEEDS-CONTRACT gap (a tester pre-wrote a scenario the contract can't back). Emits the CONTRACT half generically (a @suluk/hono RouteContract literal with inferred Zod) and the HANDLER half through a HandlerTarget adapter seam (mirroring @suluk/deploy / the C034 runtime seam) — the first adapter is the Effect+run()+RouteError shape. Zero-dep, pure, source-text out. CANDIDATE tooling.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/stubgen"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "latest"
23
+ },
24
+ "scripts": {
25
+ "test": "bun test",
26
+ "typecheck": "tsc --noEmit -p ."
27
+ }
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @suluk/stubgen — turn a NEEDS-CONTRACT gap (a tester pre-wrote a scenario the contract can't back yet) into
3
+ * honestly-provisional backend STUBS the maintainer then writes pragmatically.
4
+ *
5
+ * Two halves, per C040-P3:
6
+ * • the CONTRACT half is GENERIC — a `@suluk/hono` RouteContract literal (method/path/name inferred from the intent;
7
+ * request Zod inferred from the gap's Examples columns; responses a placeholder), every inference tagged
8
+ * `// TODO: tighten` — the inferred Zod is LOSSY by construction and the maintainer owns the final schema (never
9
+ * laundered as authoritative).
10
+ * • the HANDLER half goes through a `HandlerTarget` ADAPTER SEAM (mirroring @suluk/deploy's DeployProvider / the C034
11
+ * runtime seam), because the handler idiom is app-specific. The first adapter is `honoEffectTarget` (the toolfactory
12
+ * Effect + run() + RouteError<name> shape); `honoTarget` is a framework-generic fallback.
13
+ *
14
+ * Zero-dependency + pure (source-text out): @suluk/core never imports this; this imports nothing.
15
+ */
16
+
17
+ export interface StubField {
18
+ name: string;
19
+ /** the inferred Zod expression, e.g. `z.string()`. */
20
+ zod: string;
21
+ /** the inferred TS type, e.g. `string`. */
22
+ tsType: string;
23
+ }
24
+
25
+ /** The input: a gap the contract cannot back, optionally with the Examples columns that hint the request shape. */
26
+ export interface StubGap {
27
+ /** the authored intent — the When step text, e.g. "I refund a charge". */
28
+ intent: string;
29
+ /** the Examples columns (request field names) + an optional sample cell for type inference. */
30
+ fields?: { name: string; sample?: string }[];
31
+ /** explicit overrides (else inferred from `intent`). */
32
+ name?: string;
33
+ method?: string;
34
+ path?: string;
35
+ }
36
+
37
+ /** The resolved, renderable stub. */
38
+ export interface StubSpec {
39
+ name: string;
40
+ method: string;
41
+ path: string;
42
+ intent: string;
43
+ fields: StubField[];
44
+ }
45
+
46
+ const STOP = new Set(["a", "an", "the", "my", "our", "to", "of", "for", "with", "from"]);
47
+ const VERB_METHOD: Record<string, string> = {
48
+ create: "post", add: "post", post: "post", submit: "post", new: "post", start: "post", send: "post", refund: "post",
49
+ get: "get", view: "get", list: "get", show: "get", fetch: "get", read: "get", see: "get",
50
+ update: "put", edit: "put", change: "put", set: "put",
51
+ delete: "delete", remove: "delete", cancel: "delete",
52
+ };
53
+
54
+ const words = (intent: string) => intent.replace(/^I\s+/i, "").trim().split(/\s+/).filter(Boolean);
55
+
56
+ function inferName(intent: string): string {
57
+ const w = words(intent).filter((x) => !STOP.has(x.toLowerCase())).map((x) => x.replace(/[^A-Za-z0-9]/g, ""));
58
+ const parts = w.filter(Boolean);
59
+ if (!parts.length) return "operation";
60
+ return parts.map((p, i) => (i === 0 ? p.toLowerCase() : p[0].toUpperCase() + p.slice(1).toLowerCase())).join("");
61
+ }
62
+
63
+ const inferMethod = (intent: string): string => VERB_METHOD[words(intent)[0]?.toLowerCase() ?? ""] ?? "post";
64
+
65
+ const kebab = (name: string) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
66
+
67
+ /** Infer a Zod expression + TS type from a sample cell (string default — honestly-provisional). */
68
+ function inferField(name: string, sample?: string): StubField {
69
+ const s = sample?.trim();
70
+ if (s) {
71
+ if (s !== "" && Number.isFinite(Number(s))) return { name, zod: "z.number()", tsType: "number" };
72
+ if (s === "true" || s === "false") return { name, zod: "z.boolean()", tsType: "boolean" };
73
+ }
74
+ return { name, zod: "z.string()", tsType: "string" };
75
+ }
76
+
77
+ /** Resolve a gap to a renderable spec (inferring name/method/path/fields where not given). */
78
+ export function stubSpec(gap: StubGap): StubSpec {
79
+ const name = gap.name ?? inferName(gap.intent);
80
+ return {
81
+ name,
82
+ method: (gap.method ?? inferMethod(gap.intent)).toLowerCase(),
83
+ path: gap.path ?? `/${kebab(name)}`,
84
+ intent: gap.intent,
85
+ fields: (gap.fields ?? []).map((f) => inferField(f.name, f.sample)),
86
+ };
87
+ }
88
+
89
+ const jsKey = (k: string) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : JSON.stringify(k));
90
+
91
+ /** Render the CONTRACT half — a `@suluk/hono` RouteContract literal to paste into `contractDoc([...])`. */
92
+ export function renderContract(spec: StubSpec): string {
93
+ const lines = [
94
+ `// STUB contract (generated from a NEEDS-CONTRACT gap: ${JSON.stringify(spec.intent)}). Tighten Zod + responses, then implement the handler.`,
95
+ `{`,
96
+ ` method: ${JSON.stringify(spec.method)},`,
97
+ ` path: ${JSON.stringify(spec.path)},`,
98
+ ` name: ${JSON.stringify(spec.name)},`,
99
+ ` summary: ${JSON.stringify(`TODO: describe ${spec.name}.`)},`,
100
+ ];
101
+ if (spec.fields.length) {
102
+ lines.push(` request: { json: z.object({ ${spec.fields.map((f) => `${jsKey(f.name)}: ${f.zod} /* TODO: tighten */`).join(", ")} }) },`);
103
+ }
104
+ lines.push(` responses: [{ status: 200, schema: z.object({}) /* TODO: response shape */ }],`, `},`);
105
+ return lines.join("\n");
106
+ }
107
+
108
+ /** The handler-emit adapter seam — a target renders the HANDLER half in its app's idiom. */
109
+ export interface HandlerTarget {
110
+ name: string;
111
+ emitHandler(spec: StubSpec): string;
112
+ }
113
+
114
+ /** The toolfactory idiom: an Effect program + the run() boundary + a contract-derived RouteError. The first adapter. */
115
+ export const honoEffectTarget: HandlerTarget = {
116
+ name: "hono-effect",
117
+ emitHandler(spec) {
118
+ return [
119
+ `// STUB handler for ${spec.name} — implement the Effect program; it is wired to the route below.`,
120
+ `const ${spec.name}Program = (c: Context<AppCtx>): Effect.Effect<Response, RouteError<${JSON.stringify(spec.name)}>, AppServices> =>`,
121
+ ` Effect.gen(function* () {`,
122
+ ` // TODO: implement ${spec.name} (read input, do the work, return the declared 200 shape)`,
123
+ ` return c.json({});`,
124
+ ` });`,
125
+ ``,
126
+ `app.${spec.method}(${JSON.stringify(spec.path)}, (c) => run(${spec.name}Program(c), c.env));`,
127
+ ].join("\n");
128
+ },
129
+ };
130
+
131
+ /** A framework-generic Hono fallback target. */
132
+ export const honoTarget: HandlerTarget = {
133
+ name: "hono",
134
+ emitHandler(spec) {
135
+ return [
136
+ `// STUB handler for ${spec.name}.`,
137
+ `app.${spec.method}(${JSON.stringify(spec.path)}, async (c) => {`,
138
+ ` // TODO: implement ${spec.name}`,
139
+ ` return c.json({});`,
140
+ `});`,
141
+ ].join("\n");
142
+ },
143
+ };
144
+
145
+ export interface GeneratedStub {
146
+ name: string;
147
+ spec: StubSpec;
148
+ contract: string;
149
+ handler: string;
150
+ }
151
+
152
+ /** Generate the contract + handler stub for one gap, lowered through a handler target. */
153
+ export function generateStub(gap: StubGap, target: HandlerTarget = honoEffectTarget): GeneratedStub {
154
+ const spec = stubSpec(gap);
155
+ return { name: spec.name, spec, contract: renderContract(spec), handler: target.emitHandler(spec) };
156
+ }
157
+
158
+ /** Generate stubs for many gaps. */
159
+ export function generateStubs(gaps: StubGap[], target: HandlerTarget = honoEffectTarget): GeneratedStub[] {
160
+ return gaps.map((g) => generateStub(g, target));
161
+ }
@@ -0,0 +1,72 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { stubSpec, renderContract, generateStub, honoEffectTarget, honoTarget, type StubGap } from "../src/index";
3
+
4
+ /**
5
+ * C040-P3 — @suluk/stubgen turns a NEEDS-CONTRACT gap into a generic RouteContract stub + a handler via an adapter seam.
6
+ * The inferred Zod is honestly-provisional (`// TODO: tighten`); the maintainer owns the final schema.
7
+ */
8
+
9
+ describe("stubSpec — inference from intent + Examples columns", () => {
10
+ const gap: StubGap = { intent: "I refund a charge", fields: [{ name: "chargeId", sample: "ch_123" }, { name: "amountCents", sample: "500" }, { name: "full", sample: "true" }] };
11
+ const spec = stubSpec(gap);
12
+
13
+ test("name drops the leading I + stop-words and camelCases", () => {
14
+ expect(spec.name).toBe("refundCharge");
15
+ });
16
+ test("method is inferred from the verb; path is the kebab of the name", () => {
17
+ expect(spec.method).toBe("post");
18
+ expect(spec.path).toBe("/refund-charge");
19
+ });
20
+ test("field Zod is inferred from the sample cell (number/boolean/string)", () => {
21
+ const byName = Object.fromEntries(spec.fields.map((f) => [f.name, f.zod]));
22
+ expect(byName.chargeId).toBe("z.string()");
23
+ expect(byName.amountCents).toBe("z.number()");
24
+ expect(byName.full).toBe("z.boolean()");
25
+ });
26
+
27
+ test("a get-verb intent infers method get", () => {
28
+ expect(stubSpec({ intent: "I view the receipt" }).method).toBe("get");
29
+ expect(stubSpec({ intent: "I delete a key" }).method).toBe("delete");
30
+ });
31
+
32
+ test("explicit overrides win over inference", () => {
33
+ const s = stubSpec({ intent: "I do a thing", name: "customName", method: "PUT", path: "/x/y" });
34
+ expect(s).toMatchObject({ name: "customName", method: "put", path: "/x/y" });
35
+ });
36
+ });
37
+
38
+ describe("renderContract — a paste-ready @suluk/hono RouteContract literal", () => {
39
+ const contract = renderContract(stubSpec({ intent: "I refund a charge", fields: [{ name: "chargeId", sample: "ch_1" }] }));
40
+ test("carries method/path/name + the inferred request Zod + a TODO", () => {
41
+ expect(contract).toContain('method: "post"');
42
+ expect(contract).toContain('name: "refundCharge"');
43
+ expect(contract).toContain("request: { json: z.object({ chargeId: z.string() /* TODO: tighten */ }) }");
44
+ expect(contract).toContain("/* TODO: response shape */");
45
+ });
46
+ test("a fieldless gap emits no request block", () => {
47
+ expect(renderContract(stubSpec({ intent: "I ping" }))).not.toContain("request:");
48
+ });
49
+ });
50
+
51
+ describe("HandlerTarget seam", () => {
52
+ const spec = stubSpec({ intent: "I refund a charge" });
53
+ test("honoEffectTarget emits the Effect + run() + RouteError idiom, wired to the route", () => {
54
+ const h = honoEffectTarget.emitHandler(spec);
55
+ expect(h).toContain("const refundChargeProgram = (c: Context<AppCtx>): Effect.Effect<Response, RouteError<\"refundCharge\">, AppServices>");
56
+ expect(h).toContain('app.post("/refund-charge", (c) => run(refundChargeProgram(c), c.env));');
57
+ });
58
+ test("honoTarget emits a framework-generic handler", () => {
59
+ const h = honoTarget.emitHandler(spec);
60
+ expect(h).toContain('app.post("/refund-charge", async (c) => {');
61
+ expect(h).not.toContain("Effect");
62
+ });
63
+ });
64
+
65
+ describe("generateStub — contract + handler, default target", () => {
66
+ const g = generateStub({ intent: "I create a subscription", fields: [{ name: "plan", sample: "pro" }] });
67
+ test("returns the inferred name + both halves", () => {
68
+ expect(g.name).toBe("createSubscription");
69
+ expect(g.contract).toContain('name: "createSubscription"');
70
+ expect(g.handler).toContain("createSubscriptionProgram");
71
+ });
72
+ });
@@ -0,0 +1,19 @@
1
+ import { test, expect } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * @suluk/stubgen is a zero-dependency leaf — it emits SOURCE TEXT, so it needs no runtime deps, and @suluk/core never
7
+ * imports it (the C027 module-boundary rule: the deterministic core stays free of the higher tooling layers). This pins
8
+ * both: src/index.ts imports nothing, and @suluk/core's barrel does not import @suluk/stubgen.
9
+ */
10
+ test("src/index.ts is self-contained: no imports at all (zero-dep leaf)", () => {
11
+ const body = readFileSync(join(import.meta.dir, "..", "src", "index.ts"), "utf8");
12
+ expect(/^\s*import\s/m.test(body)).toBe(false);
13
+ expect(/from\s+["']/.test(body)).toBe(false);
14
+ });
15
+
16
+ test("@suluk/core does not import @suluk/stubgen (the matcher stays free of the stub layer)", () => {
17
+ const coreIndex = readFileSync(join(import.meta.dir, "..", "..", "core", "src", "index.ts"), "utf8");
18
+ expect(coreIndex.includes("@suluk/stubgen")).toBe(false);
19
+ });
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }