ts-procedures 8.5.0 → 8.6.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/build/codegen/emit-index.js +13 -0
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +25 -0
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.js +45 -8
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +86 -4
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/docs/client-error-handling.md +87 -0
- package/docs/handoffs/2026-06-08-dx-round2-declines.md +45 -0
- package/docs/http-integrations.md +25 -0
- package/docs/superpowers/plans/2026-06-08-codegen-dx-surfacing.md +428 -0
- package/docs/superpowers/specs/2026-06-08-dx-feedback-round-2-design.md +376 -0
- package/package.json +1 -1
- package/src/codegen/__fixtures__/users-envelope.json +9 -0
- package/src/codegen/emit-index.test.ts +34 -0
- package/src/codegen/emit-index.ts +19 -0
- package/src/codegen/emit-scope.test.ts +94 -4
- package/src/codegen/emit-scope.ts +53 -8
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +6 -0
- package/src/codegen/targets/swift/__fixtures__/users-golden.swift +6 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# Codegen DX Surfacing (Workstream A) Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make three already-present client capabilities discoverable in the generated TS output — input-less calls (`api.auth.Me()` instead of `Me({})`), the per-call options bag (`signal`/`timeout`), declared route errors — and emit per-scope client interfaces so DI seams don't force `as unknown as typeof api` casts.
|
|
6
|
+
|
|
7
|
+
**Architecture:** All changes live in the TS codegen emitters (`src/codegen/emit-scope.ts`, `src/codegen/emit-index.ts`). No runtime/client changes. #9 flips four `paramsTypeName` fallbacks from `'unknown'` to `'void'` (verified: a required `void` param is omittable at the call site, so `Me()` compiles while `Me({})` is a type error). #8/#10 replace the single-line callable JSDoc with a shared `buildCallableJsDoc` helper that names the options bag and (for error routes) the declared `Errors`. #15 derives per-scope client types from the factory's `ReturnType`, reusing the pattern already in `emit-index.ts`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Vitest, ajsc (JSON Schema → TS). Tests are string assertions over `emitScopeFile` / `emitIndexFile` output.
|
|
10
|
+
|
|
11
|
+
**Source spec:** `docs/superpowers/specs/2026-06-08-dx-feedback-round-2-design.md` (Workstream A: #9, #8, #10-breadcrumb, #15).
|
|
12
|
+
|
|
13
|
+
**Pre-verified facts (do not re-litigate):**
|
|
14
|
+
- `tsc --strict`: `declare const a: (params: void, options?: Opts) => Promise<number>` ⇒ `a()` compiles, `a({})` / `a({ nonsense: 1 })` are type errors. So `void` for `TParams` is sufficient — no signature restructuring.
|
|
15
|
+
- Current input-less output is `client.bindCallable<unknown, void>` (`emit-scope.test.ts:1255,1281`).
|
|
16
|
+
- `paramsTypeName` fallback sites (params position only): `emit-scope.ts:398` (RPC), `:536` (API), `:679` (http-stream), `:816` (stream). The response/yield `unknown` fallbacks at `:399`, `:680`, `:817` MUST stay `unknown`.
|
|
17
|
+
- Callable JSDoc single-line sites: `:412` (RPC), `:641` (API), `:777` (http-stream), `:822` (stream).
|
|
18
|
+
- Per-route `Errors` type is already emitted + reachable (`Users.GetUser.Errors` / `GetUserErrors`, `emit-scope.test.ts:740,750`); generated error classes are real, so `instanceof` already works on the throwing path. #10 here is a JSDoc breadcrumb only — the consumer-facing prose is Workstream D.
|
|
19
|
+
- `emit-index.ts` already emits `create${Service}Bindings` returning `{ <scope>: … }` and already uses `ReturnType<typeof factoryName>` (`:128,145`).
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## File Structure
|
|
24
|
+
|
|
25
|
+
- **Modify** `src/codegen/emit-scope.ts` — (a) four `paramsTypeName` fallbacks `'unknown'`→`'void'`; (b) add `buildCallableJsDoc` helper; (c) call it at the four callable sites.
|
|
26
|
+
- **Modify** `src/codegen/emit-scope.test.ts` — update the two `apiGroupNoBody` assertions; add input-less RPC + stream cases; add JSDoc assertions.
|
|
27
|
+
- **Modify** `src/codegen/emit-index.ts` — emit `export type ${Service}Client = ReturnType<typeof create${Service}Bindings>` + one `export type ${Pascal}Client = …['<scope>']` per scope.
|
|
28
|
+
- **Modify** `src/codegen/emit-index.test.ts` — assert the new per-scope client type exports.
|
|
29
|
+
|
|
30
|
+
Each task is independently testable and committable.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
### Task 1: #9 — input-less routes emit `void` params
|
|
35
|
+
|
|
36
|
+
**Files:**
|
|
37
|
+
- Modify: `src/codegen/emit-scope.ts:398,536,679,816`
|
|
38
|
+
- Test: `src/codegen/emit-scope.test.ts` (update `apiGroupNoBody`; add input-less RPC + stream fixtures)
|
|
39
|
+
|
|
40
|
+
- [ ] **Step 1: Update the existing input-less assertions to the target output (failing test)**
|
|
41
|
+
|
|
42
|
+
In `src/codegen/emit-scope.test.ts`, the `apiGroupNoBody` describe block currently asserts the old output. Change both occurrences:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// flat mode — was: client.bindCallable<unknown, void>
|
|
46
|
+
it('neither body nor headers: param type is void, return type is void', async () => {
|
|
47
|
+
const out = await emitScopeFile(apiGroupNoBody)
|
|
48
|
+
expect(out).toContain('client.bindCallable<void, void>')
|
|
49
|
+
expect(out).not.toContain('client.bindCallable<unknown,')
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// namespace mode — was: client.bindCallable<unknown, void>
|
|
55
|
+
it('neither body nor headers: param type is void, return type is void', async () => {
|
|
56
|
+
const out = await emitScopeFile(apiGroupNoBody, { namespaceTypes: true })
|
|
57
|
+
expect(out).toContain('client.bindCallable<void, void>')
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- [ ] **Step 2: Add input-less RPC and stream fixtures + assertions (failing test)**
|
|
62
|
+
|
|
63
|
+
Append to `src/codegen/emit-scope.test.ts`:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// Input-less RPC (no body) and stream (no params) — exercise the void-param fallback
|
|
67
|
+
// across the RPC and stream emission paths, not just API.
|
|
68
|
+
const rpcGroupNoInput: ScopeGroup = {
|
|
69
|
+
scopeKey: 'session',
|
|
70
|
+
camelCase: 'session',
|
|
71
|
+
routes: [
|
|
72
|
+
{
|
|
73
|
+
kind: 'rpc',
|
|
74
|
+
name: 'Logout',
|
|
75
|
+
path: '/session/logout',
|
|
76
|
+
method: 'post',
|
|
77
|
+
scope: 'session',
|
|
78
|
+
version: 1,
|
|
79
|
+
jsonSchema: {
|
|
80
|
+
response: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
|
|
81
|
+
},
|
|
82
|
+
} satisfies RPCHttpRouteDoc,
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const streamGroupNoParams: ScopeGroup = {
|
|
87
|
+
scopeKey: 'ticks',
|
|
88
|
+
camelCase: 'ticks',
|
|
89
|
+
routes: [
|
|
90
|
+
{
|
|
91
|
+
kind: 'stream',
|
|
92
|
+
name: 'WatchTicks',
|
|
93
|
+
path: '/ticks/stream',
|
|
94
|
+
methods: ['get'],
|
|
95
|
+
streamMode: 'sse',
|
|
96
|
+
scope: 'ticks',
|
|
97
|
+
version: 1,
|
|
98
|
+
jsonSchema: {
|
|
99
|
+
params: undefined,
|
|
100
|
+
yieldType: { type: 'object', properties: { n: { type: 'number' } }, required: ['n'] },
|
|
101
|
+
returnType: undefined,
|
|
102
|
+
},
|
|
103
|
+
} satisfies StreamHttpRouteDoc,
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('emitScopeFile input-less routes (void params)', () => {
|
|
108
|
+
it('RPC route with no body emits void as the params type arg', async () => {
|
|
109
|
+
const out = await emitScopeFile(rpcGroupNoInput)
|
|
110
|
+
expect(out).toContain('client.bindCallable<void,')
|
|
111
|
+
expect(out).not.toContain('client.bindCallable<unknown,')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('stream route with no params emits void as the params type', async () => {
|
|
115
|
+
const out = await emitScopeFile(streamGroupNoParams)
|
|
116
|
+
// Stream callables are direct methods: WatchTicks(params: void, options?)
|
|
117
|
+
expect(out).toContain('WatchTicks(params: void')
|
|
118
|
+
expect(out).not.toContain('WatchTicks(params: unknown')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- [ ] **Step 3: Run the tests to verify they fail**
|
|
124
|
+
|
|
125
|
+
Run: `npx vitest run src/codegen/emit-scope.test.ts -t "void"`
|
|
126
|
+
Expected: FAIL — the four assertions report `unknown` where `void` is expected.
|
|
127
|
+
|
|
128
|
+
- [ ] **Step 4: Flip the four params fallbacks to `void`**
|
|
129
|
+
|
|
130
|
+
In `src/codegen/emit-scope.ts`, change only the params-position fallbacks (leave response/yield fallbacks as `unknown`):
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// line 398 (emitRpcRoute)
|
|
134
|
+
const paramsTypeName = refs['Params'] ?? 'void'
|
|
135
|
+
```
|
|
136
|
+
```ts
|
|
137
|
+
// line 536 (emitApiRoute)
|
|
138
|
+
let paramsTypeName = 'void'
|
|
139
|
+
```
|
|
140
|
+
```ts
|
|
141
|
+
// line 679 (emitHttpStreamRoute)
|
|
142
|
+
let paramsTypeName = 'void'
|
|
143
|
+
```
|
|
144
|
+
```ts
|
|
145
|
+
// line 816 (emitStreamRoute)
|
|
146
|
+
const paramsTypeName = refs['Params'] ?? 'void'
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Do NOT touch `:399` (`responseTypeName`), `:680` (`yieldTypeName`), or `:817` (`yieldTypeName`) — those are return/yield positions and stay `unknown`.
|
|
150
|
+
|
|
151
|
+
- [ ] **Step 5: Run the new + existing scope tests to verify they pass**
|
|
152
|
+
|
|
153
|
+
Run: `npx vitest run src/codegen/emit-scope.test.ts`
|
|
154
|
+
Expected: PASS — all assertions green, including the unchanged input-ful routes (regression guard: routes with channels still emit `${Pascal}Req` / `Params`, never `void`).
|
|
155
|
+
|
|
156
|
+
- [ ] **Step 6: Extend the canonical envelope fixture with an input-less route (pipeline-level guard)**
|
|
157
|
+
|
|
158
|
+
Add an input-less route to `src/codegen/__fixtures__/users-envelope.json` so the full pipeline + any compile-level e2e exercises it (the existing five routes all have ≥1 req channel). Append to `routes`:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"kind": "api",
|
|
163
|
+
"name": "Heartbeat",
|
|
164
|
+
"scope": "users",
|
|
165
|
+
"method": "GET",
|
|
166
|
+
"fullPath": "/users/heartbeat",
|
|
167
|
+
"jsonSchema": { "req": {}, "res": {} },
|
|
168
|
+
"errors": []
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- [ ] **Step 7: Run the full codegen suite to confirm nothing else snapshots the fixture route count**
|
|
173
|
+
|
|
174
|
+
Run: `npx vitest run src/codegen/`
|
|
175
|
+
Expected: PASS. If a fixture-count assertion in `pipeline.test.ts` / `e2e.test.ts` fails, update that count from 5 to 6 (the added route is intentional). Verify the failure is only the count, not a behavior change.
|
|
176
|
+
|
|
177
|
+
- [ ] **Step 8: Commit**
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
git add src/codegen/emit-scope.ts src/codegen/emit-scope.test.ts src/codegen/__fixtures__/users-envelope.json
|
|
181
|
+
git commit -m "feat(codegen): emit void params for input-less routes so api.X() needs no ({})"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
### Task 2: #8 + #10 — callable JSDoc surfaces the options bag and declared errors
|
|
187
|
+
|
|
188
|
+
**Files:**
|
|
189
|
+
- Modify: `src/codegen/emit-scope.ts` (add `buildCallableJsDoc`; replace single-line JSDoc at `:412,641,777,822`)
|
|
190
|
+
- Test: `src/codegen/emit-scope.test.ts`
|
|
191
|
+
|
|
192
|
+
- [ ] **Step 1: Write failing tests for the JSDoc breadcrumbs**
|
|
193
|
+
|
|
194
|
+
Append to `src/codegen/emit-scope.test.ts`:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
describe('emitScopeFile callable JSDoc surfaces options + errors', () => {
|
|
198
|
+
it('mentions the per-call options bag on an RPC callable', async () => {
|
|
199
|
+
const out = await emitScopeFile(rpcGroup)
|
|
200
|
+
expect(out).toContain('POST') // existing method label preserved
|
|
201
|
+
expect(out).toContain('@param options')
|
|
202
|
+
expect(out).toContain('ProcedureCallOptions')
|
|
203
|
+
expect(out).toContain('signal')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('mentions declared errors + Scope.Route.Errors on a route with errors (namespace)', async () => {
|
|
207
|
+
const out = await emitScopeFile(rpcGroupWithErrors, {
|
|
208
|
+
namespaceTypes: true,
|
|
209
|
+
errorKeys: new Set(['NotFound']),
|
|
210
|
+
serviceName: 'Api',
|
|
211
|
+
})
|
|
212
|
+
expect(out).toContain('@throws')
|
|
213
|
+
expect(out).toContain('Users.GetUser.Errors')
|
|
214
|
+
expect(out).toContain('instanceof')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('omits the @throws line when a route declares no errors', async () => {
|
|
218
|
+
const out = await emitScopeFile(rpcGroupNoErrors, { serviceName: 'Api' })
|
|
219
|
+
expect(out).not.toContain('@throws')
|
|
220
|
+
expect(out).toContain('@param options') // options line still present
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('mentions options on a stream callable but no @throws', async () => {
|
|
224
|
+
const out = await emitScopeFile(streamGroup)
|
|
225
|
+
expect(out).toContain('@param options')
|
|
226
|
+
expect(out).not.toContain('@throws')
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- [ ] **Step 2: Run to verify failure**
|
|
232
|
+
|
|
233
|
+
Run: `npx vitest run src/codegen/emit-scope.test.ts -t "JSDoc surfaces"`
|
|
234
|
+
Expected: FAIL — current JSDoc is a single line (`/** POST /users/1 */`) with no `@param options` / `@throws`.
|
|
235
|
+
|
|
236
|
+
- [ ] **Step 3: Add the `buildCallableJsDoc` helper**
|
|
237
|
+
|
|
238
|
+
Add to `src/codegen/emit-scope.ts` (near the other route-emission helpers, above `emitRpcRoute`):
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
/**
|
|
242
|
+
* Builds the multi-line JSDoc comment for a route callable. Surfaces the
|
|
243
|
+
* second `options` argument (the per-call AbortSignal/timeout seam — DX #8) and,
|
|
244
|
+
* for routes that declare typed errors, points at the route's `Errors` type and
|
|
245
|
+
* how to narrow it on the throwing path (DX #10). Indented for placement inside
|
|
246
|
+
* the bind-object (4 spaces).
|
|
247
|
+
*/
|
|
248
|
+
function buildCallableJsDoc(opts: {
|
|
249
|
+
methodLabel: string
|
|
250
|
+
path: string
|
|
251
|
+
errorsRef: string | null
|
|
252
|
+
}): string {
|
|
253
|
+
const lines = [
|
|
254
|
+
` /**`,
|
|
255
|
+
` * ${opts.methodLabel} ${opts.path}`,
|
|
256
|
+
` *`,
|
|
257
|
+
` * @param options Optional per-call {@link ProcedureCallOptions} —`,
|
|
258
|
+
` * \`signal\` (cancel on dispose), \`timeout\`, \`headers\`, \`basePath\`.`,
|
|
259
|
+
]
|
|
260
|
+
if (opts.errorsRef) {
|
|
261
|
+
lines.push(
|
|
262
|
+
` * @throws Declared typed errors: {@link ${opts.errorsRef}}. Narrow with`,
|
|
263
|
+
` * \`instanceof\` on the throwing path, or call \`.safe()\` for a \`Result\`.`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
lines.push(` */`)
|
|
267
|
+
return lines.join('\n')
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
- [ ] **Step 4: Replace the four single-line JSDoc comments with the helper**
|
|
272
|
+
|
|
273
|
+
`emit-scope.ts:412` (RPC) — replace `` ` /** ${route.method.toUpperCase()} ${route.path} */`, `` with:
|
|
274
|
+
```ts
|
|
275
|
+
buildCallableJsDoc({
|
|
276
|
+
methodLabel: route.method.toUpperCase(),
|
|
277
|
+
path: route.path,
|
|
278
|
+
errorsRef: hasErrors ? errorsRef : null,
|
|
279
|
+
}),
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`emit-scope.ts:641` (API) — replace the single-line comment with:
|
|
283
|
+
```ts
|
|
284
|
+
buildCallableJsDoc({
|
|
285
|
+
methodLabel: route.method.toUpperCase(),
|
|
286
|
+
path: route.fullPath,
|
|
287
|
+
errorsRef: hasErrors ? errorsRef : null,
|
|
288
|
+
}),
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`emit-scope.ts:777` (http-stream) — streams carry no `.safe()` typed-error arm, so `errorsRef: null`:
|
|
292
|
+
```ts
|
|
293
|
+
buildCallableJsDoc({
|
|
294
|
+
methodLabel: route.method.toUpperCase(),
|
|
295
|
+
path: route.fullPath,
|
|
296
|
+
errorsRef: null,
|
|
297
|
+
}),
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`emit-scope.ts:822` (stream) — `errorsRef: null`:
|
|
301
|
+
```ts
|
|
302
|
+
buildCallableJsDoc({
|
|
303
|
+
methodLabel: route.methods.map((m) => m.toUpperCase()).join('|'),
|
|
304
|
+
path: route.path,
|
|
305
|
+
errorsRef: null,
|
|
306
|
+
}),
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
> Note: `hasErrors` and `errorsRef` are already in scope at the RPC (`:403,:404`) and API (`:627`-region) call sites. Confirm before editing; if a site computes them later, hoist the computation above the callable array.
|
|
310
|
+
|
|
311
|
+
- [ ] **Step 5: Run to verify the new tests pass and the existing method/path JSDoc assertions still hold**
|
|
312
|
+
|
|
313
|
+
Run: `npx vitest run src/codegen/emit-scope.test.ts`
|
|
314
|
+
Expected: PASS — including the existing `callable includes JSDoc with method and path` tests (`POST` / `/users/1` still present on the first comment line).
|
|
315
|
+
|
|
316
|
+
- [ ] **Step 6: Commit**
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
git add src/codegen/emit-scope.ts src/codegen/emit-scope.test.ts
|
|
320
|
+
git commit -m "feat(codegen): surface per-call options + declared errors in callable JSDoc"
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
### Task 3: #15 — per-scope client interfaces derived from the bindings factory
|
|
326
|
+
|
|
327
|
+
**Files:**
|
|
328
|
+
- Modify: `src/codegen/emit-index.ts` (after the factory function, before/after `create${Service}Client`)
|
|
329
|
+
- Test: `src/codegen/emit-index.test.ts`
|
|
330
|
+
|
|
331
|
+
- [ ] **Step 1: Write failing tests for the derived client types**
|
|
332
|
+
|
|
333
|
+
Add to `src/codegen/emit-index.test.ts` (use the existing multi-scope fixture in that file; if it has scopes `users` and `posts`, assert both):
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
describe('emitIndexFile per-scope client types (DX #15)', () => {
|
|
337
|
+
it('emits the aggregate client type from the factory return type', async () => {
|
|
338
|
+
const out = emitIndexFile(groups, { serviceName: 'Api' })
|
|
339
|
+
expect(out).toContain('export type ApiClient = ReturnType<typeof createApiBindings>')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('emits one per-scope client type as an indexed access into the aggregate', async () => {
|
|
343
|
+
const out = emitIndexFile(groups, { serviceName: 'Api' })
|
|
344
|
+
// groups here are camelCase 'users' / 'posts' (adjust to the fixture's scopes)
|
|
345
|
+
expect(out).toContain("export type UsersClient = ApiClient['users']")
|
|
346
|
+
expect(out).toContain("export type PostsClient = ApiClient['posts']")
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('honors a custom serviceName in the client type names', async () => {
|
|
350
|
+
const out = emitIndexFile(groups, { serviceName: 'Catalog' })
|
|
351
|
+
expect(out).toContain('export type CatalogClient = ReturnType<typeof createCatalogBindings>')
|
|
352
|
+
expect(out).toContain("export type UsersClient = CatalogClient['users']")
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
> Before writing, open `src/codegen/emit-index.test.ts` and reuse its existing `groups` fixture + `import { emitIndexFile }` line. Match the scope camelCase names it already defines rather than introducing new ones.
|
|
358
|
+
|
|
359
|
+
- [ ] **Step 2: Run to verify failure**
|
|
360
|
+
|
|
361
|
+
Run: `npx vitest run src/codegen/emit-index.test.ts -t "per-scope client types"`
|
|
362
|
+
Expected: FAIL — no `export type ApiClient` / `UsersClient` in current output.
|
|
363
|
+
|
|
364
|
+
- [ ] **Step 3: Emit the derived client types in `emit-index.ts`**
|
|
365
|
+
|
|
366
|
+
In `src/codegen/emit-index.ts`, after the `${factoryName}` function is pushed onto `pieces` (after the block ending at line 107, before the `create${Service}Client` block at line 109), insert:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
// Per-scope client interfaces, derived from the bindings factory's return type
|
|
370
|
+
// (DX #15). Reuses the `ReturnType<typeof factory>` pattern already used by the
|
|
371
|
+
// convenience client below. Lets a consumer type a dependency as the narrow scope
|
|
372
|
+
// port — `constructor(client: UsersClient = api.users)` — and a fake satisfies just
|
|
373
|
+
// that scope with no `as unknown as typeof api` cast. `${Service}Client` is the
|
|
374
|
+
// callable bundle; it does NOT collide with the `${Service}.<Scope>` type namespace.
|
|
375
|
+
const aggregateClientType = `${servicePascal}Client`
|
|
376
|
+
pieces.push(
|
|
377
|
+
`export type ${aggregateClientType} = ReturnType<typeof ${factoryName}>`,
|
|
378
|
+
...groups.map(
|
|
379
|
+
(g) => `export type ${toPascalCase(g.camelCase)}Client = ${aggregateClientType}['${g.camelCase}']`,
|
|
380
|
+
),
|
|
381
|
+
'',
|
|
382
|
+
)
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
- [ ] **Step 4: Run to verify the new tests pass**
|
|
386
|
+
|
|
387
|
+
Run: `npx vitest run src/codegen/emit-index.test.ts`
|
|
388
|
+
Expected: PASS.
|
|
389
|
+
|
|
390
|
+
- [ ] **Step 5: Confirm the generated index still type-checks end-to-end**
|
|
391
|
+
|
|
392
|
+
Run: `npx vitest run src/codegen/`
|
|
393
|
+
Expected: PASS. The added type aliases are pure derivations of an existing emitted value, so any compile-level e2e test that builds the generated index continues to compile. If `e2e.test.ts` snapshots the full index text, update the snapshot and eyeball the diff (only the new `export type …Client` lines added).
|
|
394
|
+
|
|
395
|
+
- [ ] **Step 6: Commit**
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
git add src/codegen/emit-index.ts src/codegen/emit-index.test.ts
|
|
399
|
+
git commit -m "feat(codegen): emit per-scope client types so DI seams need no aggregate-client cast"
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Self-Review
|
|
405
|
+
|
|
406
|
+
**Spec coverage (Workstream A items #9, #8, #10-breadcrumb, #15):**
|
|
407
|
+
- #9 → Task 1 (four `void` fallbacks + RPC/API/stream coverage + fixture). ✓
|
|
408
|
+
- #8 → Task 2 (`@param options` on all four callable kinds). ✓
|
|
409
|
+
- #10 (breadcrumb half) → Task 2 (`@throws` + `Scope.Route.Errors` + `instanceof`). The consumer prose half is Workstream D, out of this plan by design. ✓
|
|
410
|
+
- #15 → Task 3 (`${Service}Client` + per-scope indexed types). ✓
|
|
411
|
+
|
|
412
|
+
**Type consistency:** `buildCallableJsDoc({ methodLabel, path, errorsRef })` — same three-field shape at all four call sites. `errorsRef` is `string | null` everywhere (the RPC/API sites pass `hasErrors ? errorsRef : null`; stream sites pass `null`). `aggregateClientType` / `${Pascal}Client` naming consistent between emit code and tests.
|
|
413
|
+
|
|
414
|
+
**Placeholder scan:** no TBD/TODO; every code step shows the literal edit; every run step shows the command + expected result.
|
|
415
|
+
|
|
416
|
+
**Known follow-ups (NOT this plan):** the `isProcedureError` guard (deferred sugar — `instanceof` already works); the consumer-facing docs for #10/#8 (Workstream D); #6 no-content (Workstream B, server-side); correlation-id (Workstream E).
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Execution Handoff
|
|
421
|
+
|
|
422
|
+
**Plan complete and saved to `docs/superpowers/plans/2026-06-08-codegen-dx-surfacing.md`. Two execution options:**
|
|
423
|
+
|
|
424
|
+
**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration.
|
|
425
|
+
|
|
426
|
+
**2. Inline Execution** — execute tasks in this session via executing-plans, batch execution with checkpoints.
|
|
427
|
+
|
|
428
|
+
**Which approach?**
|