ts-procedures 8.3.0 → 8.5.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/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -8
- package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
- package/build/client/call.js +1 -1
- package/build/client/call.js.map +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +23 -1
- package/build/client/index.js.map +1 -1
- package/build/client/index.test.js +87 -0
- package/build/client/index.test.js.map +1 -1
- package/build/client/resolve-options.d.ts +5 -4
- package/build/client/resolve-options.js +18 -7
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +53 -24
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/stream.js +1 -1
- package/build/client/stream.js.map +1 -1
- package/build/client/types.d.ts +31 -3
- package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
- package/build/codegen/__fixtures__/make-envelope.js +38 -0
- package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
- package/build/codegen/bin/cli.d.ts +15 -0
- package/build/codegen/bin/cli.js +46 -21
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +54 -1
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/bin/flag-specs.d.ts +10 -0
- package/build/codegen/bin/flag-specs.js +62 -0
- package/build/codegen/bin/flag-specs.js.map +1 -0
- package/build/codegen/bin/flag-specs.test.d.ts +1 -0
- package/build/codegen/bin/flag-specs.test.js +35 -0
- package/build/codegen/bin/flag-specs.test.js.map +1 -0
- package/build/codegen/collect-models.d.ts +48 -0
- package/build/codegen/collect-models.js +84 -0
- package/build/codegen/collect-models.js.map +1 -0
- package/build/codegen/collect-models.test.d.ts +1 -0
- package/build/codegen/collect-models.test.js +59 -0
- package/build/codegen/collect-models.test.js.map +1 -0
- package/build/codegen/emit-client-runtime.js +1 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-models.d.ts +26 -0
- package/build/codegen/emit-models.js +53 -0
- package/build/codegen/emit-models.js.map +1 -0
- package/build/codegen/emit-models.test.d.ts +1 -0
- package/build/codegen/emit-models.test.js +42 -0
- package/build/codegen/emit-models.test.js.map +1 -0
- package/build/codegen/emit-scope.d.ts +10 -0
- package/build/codegen/emit-scope.js +119 -34
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-types.d.ts +26 -1
- package/build/codegen/emit-types.js +27 -5
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/index.d.ts +15 -0
- package/build/codegen/index.js +5 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/model-refs.d.ts +27 -0
- package/build/codegen/model-refs.js +49 -0
- package/build/codegen/model-refs.js.map +1 -0
- package/build/codegen/model-refs.test.d.ts +1 -0
- package/build/codegen/model-refs.test.js +33 -0
- package/build/codegen/model-refs.test.js.map +1 -0
- package/build/codegen/pipeline.d.ts +7 -0
- package/build/codegen/pipeline.js +6 -1
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/schema-walk.d.ts +13 -0
- package/build/codegen/schema-walk.js +26 -0
- package/build/codegen/schema-walk.js.map +1 -0
- package/build/codegen/schema-walk.test.d.ts +1 -0
- package/build/codegen/schema-walk.test.js +35 -0
- package/build/codegen/schema-walk.test.js.map +1 -0
- package/build/codegen/targets/_shared/target-run.d.ts +15 -0
- package/build/codegen/targets/ts/run.js +37 -1
- package/build/codegen/targets/ts/run.js.map +1 -1
- package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
- package/build/codegen/targets/ts/shared-models.test.js +354 -0
- package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
- package/build/doc-envelope.d.ts +13 -0
- package/build/doc-envelope.js +23 -0
- package/build/doc-envelope.js.map +1 -0
- package/build/doc-envelope.test.d.ts +1 -0
- package/build/doc-envelope.test.js +31 -0
- package/build/doc-envelope.test.js.map +1 -0
- package/build/exports.d.ts +2 -0
- package/build/exports.js +1 -0
- package/build/exports.js.map +1 -1
- package/docs/client-and-codegen.md +163 -0
- package/docs/handoffs/ajsc-named-type-collision.md +134 -0
- package/docs/handoffs/ajsc-named-type-support.md +181 -0
- package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
- package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
- package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
- package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
- package/package.json +2 -2
- package/src/client/call.ts +1 -1
- package/src/client/index.test.ts +98 -0
- package/src/client/index.ts +32 -1
- package/src/client/resolve-options.test.ts +73 -26
- package/src/client/resolve-options.ts +23 -9
- package/src/client/stream.ts +1 -1
- package/src/client/types.ts +34 -3
- package/src/codegen/__fixtures__/make-envelope.ts +89 -0
- package/src/codegen/bin/cli.test.ts +65 -1
- package/src/codegen/bin/cli.ts +51 -22
- package/src/codegen/bin/flag-specs.test.ts +38 -0
- package/src/codegen/bin/flag-specs.ts +71 -0
- package/src/codegen/collect-models.test.ts +68 -0
- package/src/codegen/collect-models.ts +125 -0
- package/src/codegen/emit-client-runtime.ts +1 -0
- package/src/codegen/emit-models.test.ts +48 -0
- package/src/codegen/emit-models.ts +63 -0
- package/src/codegen/emit-scope.ts +145 -33
- package/src/codegen/emit-types.ts +48 -7
- package/src/codegen/index.ts +20 -0
- package/src/codegen/model-refs.test.ts +37 -0
- package/src/codegen/model-refs.ts +57 -0
- package/src/codegen/pipeline.ts +13 -1
- package/src/codegen/schema-walk.test.ts +37 -0
- package/src/codegen/schema-walk.ts +23 -0
- package/src/codegen/targets/_shared/target-run.ts +15 -0
- package/src/codegen/targets/ts/run.ts +50 -0
- package/src/codegen/targets/ts/shared-models.test.ts +391 -0
- package/src/doc-envelope.test.ts +35 -0
- package/src/doc-envelope.ts +30 -0
- package/src/exports.ts +2 -0
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
# DX Feedback Round 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:** Resolve five downstream DX findings — codegen `--help`, an offline `writeDocEnvelope` helper, shared codegen types keyed on `$id`, function-valued client headers for dynamic auth, and scaffolder file-naming knobs.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Five mostly-independent changes. #1/#2/#4 are self-contained and fully TDD-able. #3 (shared types) is gated behind a verification spike (Task 8) because the emission mechanism depends on how ajsc v7.2.0 handles a `$ref`→named-type; the spike picks `$ref`-reference vs post-emit substitution before the emission tasks run. #5 edits the AI-skill markdown + templates (no runtime code).
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (ESM, `.js` import specifiers), Vitest, AJV/TypeBox, ajsc (dynamic import), Hono.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md`
|
|
12
|
+
|
|
13
|
+
**Conventions to follow:**
|
|
14
|
+
- Run a single test file: `npx vitest run <path>`. Full suite: `npm run test`. Lint: `npm run lint`. Build: `npm run build`.
|
|
15
|
+
- ESM imports use `.js` specifiers even for `.ts` sources.
|
|
16
|
+
- Commit after each green task. Branch is `dx-feedback` (already checked out).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## File Structure
|
|
21
|
+
|
|
22
|
+
| File | Responsibility | Tasks |
|
|
23
|
+
|------|----------------|-------|
|
|
24
|
+
| `src/codegen/bin/flag-specs.ts` (new) | Structured flag catalog + `formatHelp()` | 1, 2 |
|
|
25
|
+
| `src/codegen/bin/cli.ts` (modify) | Consume flag specs; `--help`/`-h`/bare → usage; exclude help from suggester | 1, 2, 3 |
|
|
26
|
+
| `src/codegen/bin/cli.test.ts` (modify/locate) | CLI behaviour tests | 1, 2, 3 |
|
|
27
|
+
| `src/doc-envelope.ts` (new) + package export | `writeDocEnvelope(source, path)` helper | 4, 5 |
|
|
28
|
+
| `src/codegen/collect-models.ts` (new) | Walk routes, collect `$id` subschemas, dedup, collision-detect | 9, 10 |
|
|
29
|
+
| `src/codegen/emit-models.ts` (new) | Emit `_models.ts` (generated + re-exported) | 11 |
|
|
30
|
+
| `src/codegen/emit-scope.ts` (modify) | Reference hoisted models instead of inlining | 12 |
|
|
31
|
+
| `src/codegen/targets/ts/run.ts` (modify) | Wire collect→emit-models→scope; emit `_models.ts` | 12 |
|
|
32
|
+
| `src/codegen/index.ts` + `pipeline.ts` + `targets/_shared/target-run.ts` (modify) | Thread `shareModels` + `sharedTypesImport` options | 12 |
|
|
33
|
+
| `src/codegen/bin/cli.ts` (modify) | `--share-models`/`--no-share-models` flag + `sharedTypesImport` config | 13 |
|
|
34
|
+
| `src/client/types.ts` (modify) | `HeadersInit` type on `ProcedureCallDefaults.headers` | 6 |
|
|
35
|
+
| `src/client/resolve-options.ts` (modify) | async `resolveHeaders`; `applyRequestOptions` async | 6 |
|
|
36
|
+
| `src/client/call.ts` + `stream.ts` (modify) | `await applyRequestOptions(...)` | 6 |
|
|
37
|
+
| `docs/client-and-codegen.md` (modify) | Auth seam section | 7 |
|
|
38
|
+
| `agent_config/.../skills/ts-procedures/SKILL.md` + `templates/*.md` (modify) | `fileNameStyle` + `groupBy` scaffold args | 14 |
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## #1 — `--help` for the codegen CLI
|
|
43
|
+
|
|
44
|
+
### Task 1: Structured flag catalog with `formatHelp()`
|
|
45
|
+
|
|
46
|
+
**Files:**
|
|
47
|
+
- Create: `src/codegen/bin/flag-specs.ts`
|
|
48
|
+
- Test: `src/codegen/bin/flag-specs.test.ts`
|
|
49
|
+
|
|
50
|
+
- [ ] **Step 1: Write the failing test**
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// src/codegen/bin/flag-specs.test.ts
|
|
54
|
+
import { describe, it, expect } from 'vitest'
|
|
55
|
+
import { FLAG_SPECS, KNOWN_FLAGS, formatHelp } from './flag-specs.js'
|
|
56
|
+
|
|
57
|
+
describe('flag-specs', () => {
|
|
58
|
+
it('derives KNOWN_FLAGS from the spec table', () => {
|
|
59
|
+
expect(KNOWN_FLAGS).toContain('--url')
|
|
60
|
+
expect(KNOWN_FLAGS).toContain('--service-name')
|
|
61
|
+
expect(KNOWN_FLAGS).toContain('--target')
|
|
62
|
+
// every spec name appears
|
|
63
|
+
for (const spec of FLAG_SPECS) expect(KNOWN_FLAGS).toContain(spec.name)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('formatHelp lists every flag with its description, grouped', () => {
|
|
67
|
+
const help = formatHelp()
|
|
68
|
+
expect(help).toMatch(/Usage: ts-procedures-codegen/)
|
|
69
|
+
for (const spec of FLAG_SPECS) {
|
|
70
|
+
expect(help).toContain(spec.name)
|
|
71
|
+
expect(help).toContain(spec.description)
|
|
72
|
+
}
|
|
73
|
+
// group headers present
|
|
74
|
+
expect(help).toMatch(/Source/)
|
|
75
|
+
expect(help).toMatch(/Targets/)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does not list --help/-h as a real codegen flag in KNOWN_FLAGS suggestion set', () => {
|
|
79
|
+
// help is handled separately; it must NOT be a did-you-mean candidate
|
|
80
|
+
expect(KNOWN_FLAGS).not.toContain('--help')
|
|
81
|
+
expect(KNOWN_FLAGS).not.toContain('-h')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
87
|
+
|
|
88
|
+
Run: `npx vitest run src/codegen/bin/flag-specs.test.ts`
|
|
89
|
+
Expected: FAIL — `flag-specs.js` not found / exports missing.
|
|
90
|
+
|
|
91
|
+
- [ ] **Step 3: Write `flag-specs.ts`**
|
|
92
|
+
|
|
93
|
+
Port every current flag (cli.ts:87-98, `KNOWN_FLAGS`) into a structured table. Include the value flags' `arg` placeholders and one-line descriptions.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// src/codegen/bin/flag-specs.ts
|
|
97
|
+
export interface FlagSpec {
|
|
98
|
+
name: string
|
|
99
|
+
arg?: string
|
|
100
|
+
description: string
|
|
101
|
+
group: 'Source' | 'Output' | 'Codegen' | 'Targets' | 'Misc'
|
|
102
|
+
default?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const FLAG_SPECS: readonly FlagSpec[] = [
|
|
106
|
+
// Source
|
|
107
|
+
{ name: '--url', arg: '<url>', description: 'Fetch the doc envelope from a running server', group: 'Source' },
|
|
108
|
+
{ name: '--file', arg: '<path>', description: 'Read the doc envelope from a JSON file (see writeDocEnvelope)', group: 'Source' },
|
|
109
|
+
{ name: '--config', arg: '<path>', description: 'Load options from a JSON config file', group: 'Source' },
|
|
110
|
+
// Output
|
|
111
|
+
{ name: '--out', arg: '<dir>', description: 'Output directory for generated files', group: 'Output' },
|
|
112
|
+
{ name: '--dry-run', description: 'Print what would be written without touching disk', group: 'Output' },
|
|
113
|
+
{ name: '--clean-out-dir', description: 'Prune orphaned generator-owned files before writing', group: 'Output', default: 'on' },
|
|
114
|
+
{ name: '--no-clean-out-dir', description: 'Disable orphan pruning', group: 'Output' },
|
|
115
|
+
{ name: '--watch', description: 'Regenerate on envelope change', group: 'Output' },
|
|
116
|
+
{ name: '--interval', arg: '<ms>', description: 'Poll interval for --watch (default 3000)', group: 'Output' },
|
|
117
|
+
// Codegen
|
|
118
|
+
{ name: '--service-name', arg: '<name>', description: 'Service identifier driving generated names', group: 'Codegen', default: 'Api' },
|
|
119
|
+
{ name: '--namespace-types', description: 'Wrap types in nested namespaces', group: 'Codegen', default: 'on' },
|
|
120
|
+
{ name: '--no-namespace-types', description: 'Flat type names', group: 'Codegen' },
|
|
121
|
+
{ name: '--self-contained', description: 'Bundle the client runtime into the output dir', group: 'Codegen', default: 'on' },
|
|
122
|
+
{ name: '--no-self-contained', description: 'Import the client runtime from ts-procedures/client', group: 'Codegen' },
|
|
123
|
+
{ name: '--client-import-path', arg: '<path>', description: 'Override the client runtime import path', group: 'Codegen' },
|
|
124
|
+
{ name: '--share-models', description: 'Hoist $id-bearing schemas into a shared _models.ts', group: 'Codegen', default: 'on' },
|
|
125
|
+
{ name: '--no-share-models', description: 'Inline every type per route (legacy behaviour)', group: 'Codegen' },
|
|
126
|
+
{ name: '--jsdoc', description: 'Emit JSDoc on generated types', group: 'Codegen', default: 'on' },
|
|
127
|
+
{ name: '--no-jsdoc', description: 'Suppress JSDoc', group: 'Codegen' },
|
|
128
|
+
{ name: '--enum-style', arg: '<union|enum>', description: 'How to emit enums (namespace mode)', group: 'Codegen' },
|
|
129
|
+
{ name: '--depluralize', description: 'Depluralize extracted array-item type names', group: 'Codegen' },
|
|
130
|
+
{ name: '--array-item-naming', arg: '<name|false>', description: 'Naming for extracted array-item types', group: 'Codegen' },
|
|
131
|
+
{ name: '--uncountable-words', arg: '<csv>', description: 'Words exempt from depluralization', group: 'Codegen' },
|
|
132
|
+
// Targets
|
|
133
|
+
{ name: '--target', arg: '<ts|kotlin|swift>', description: 'Output language', group: 'Targets', default: 'ts' },
|
|
134
|
+
{ name: '--kotlin-package', arg: '<pkg>', description: 'Package for Kotlin output (required for --target kotlin)', group: 'Targets' },
|
|
135
|
+
{ name: '--kotlin-serializer', arg: '<kotlinx|none>', description: 'Kotlin serialization annotations', group: 'Targets', default: 'kotlinx' },
|
|
136
|
+
{ name: '--swift-serializer', arg: '<codable|none>', description: 'Swift Codable conformance', group: 'Targets', default: 'codable' },
|
|
137
|
+
{ name: '--swift-access-level', arg: '<public|internal>', description: 'Swift access level', group: 'Targets', default: 'public' },
|
|
138
|
+
{ name: '--unsupported-unions', arg: '<throw|fallback>', description: 'Behaviour for untagged oneOf schemas', group: 'Targets', default: 'throw' },
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
export const KNOWN_FLAGS: readonly string[] = FLAG_SPECS.map((s) => s.name)
|
|
142
|
+
|
|
143
|
+
const GROUP_ORDER: FlagSpec['group'][] = ['Source', 'Output', 'Codegen', 'Targets', 'Misc']
|
|
144
|
+
|
|
145
|
+
export function formatHelp(): string {
|
|
146
|
+
const lines: string[] = []
|
|
147
|
+
lines.push('Usage: ts-procedures-codegen --out <dir> (--url <url> | --file <path>) [options]')
|
|
148
|
+
lines.push('')
|
|
149
|
+
lines.push('Generate a typed client from a ts-procedures doc envelope.')
|
|
150
|
+
lines.push('')
|
|
151
|
+
const col = Math.max(...FLAG_SPECS.map((s) => (s.name + (s.arg ? ' ' + s.arg : '')).length)) + 2
|
|
152
|
+
for (const group of GROUP_ORDER) {
|
|
153
|
+
const specs = FLAG_SPECS.filter((s) => s.group === group)
|
|
154
|
+
if (specs.length === 0) continue
|
|
155
|
+
lines.push(`${group}:`)
|
|
156
|
+
for (const s of specs) {
|
|
157
|
+
const left = s.name + (s.arg ? ' ' + s.arg : '')
|
|
158
|
+
const def = s.default ? ` (default: ${s.default})` : ''
|
|
159
|
+
lines.push(` ${left.padEnd(col)}${s.description}${def}`)
|
|
160
|
+
}
|
|
161
|
+
lines.push('')
|
|
162
|
+
}
|
|
163
|
+
lines.push(' -h, --help Show this help and exit')
|
|
164
|
+
return lines.join('\n')
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
169
|
+
|
|
170
|
+
Run: `npx vitest run src/codegen/bin/flag-specs.test.ts`
|
|
171
|
+
Expected: PASS.
|
|
172
|
+
|
|
173
|
+
- [ ] **Step 5: Commit**
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
git add src/codegen/bin/flag-specs.ts src/codegen/bin/flag-specs.test.ts
|
|
177
|
+
git commit -m "feat(codegen): structured flag catalog with formatHelp()"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Task 2: Replace `KNOWN_FLAGS` in cli.ts with the spec-derived one
|
|
181
|
+
|
|
182
|
+
**Files:**
|
|
183
|
+
- Modify: `src/codegen/bin/cli.ts` (remove the literal `KNOWN_FLAGS` at 87-98, import from `flag-specs.js`)
|
|
184
|
+
|
|
185
|
+
- [ ] **Step 1: Update cli.ts to import the catalog**
|
|
186
|
+
|
|
187
|
+
Delete the literal `const KNOWN_FLAGS = [...] as const` block (lines 87-98). Add at the top with the other imports:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { KNOWN_FLAGS, formatHelp } from './flag-specs.js'
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`closestKnownFlag` already iterates `KNOWN_FLAGS`; it now reads the imported array (a `readonly string[]`). No other change needed in `closestKnownFlag`.
|
|
194
|
+
|
|
195
|
+
- [ ] **Step 2: Run the existing CLI tests to verify no regression**
|
|
196
|
+
|
|
197
|
+
Run: `npx vitest run src/codegen/bin/cli.test.ts`
|
|
198
|
+
Expected: PASS (unknown-flag/did-you-mean tests still green — same flag set).
|
|
199
|
+
|
|
200
|
+
- [ ] **Step 3: Build to confirm no dangling references**
|
|
201
|
+
|
|
202
|
+
Run: `npm run build`
|
|
203
|
+
Expected: no TS errors referencing `KNOWN_FLAGS`.
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 4: Commit**
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
git add src/codegen/bin/cli.ts
|
|
209
|
+
git commit -m "refactor(codegen): derive KNOWN_FLAGS from the flag-specs catalog"
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Task 3: Handle `--help`/`-h`/bare invocation in the CLI entrypoint
|
|
213
|
+
|
|
214
|
+
**Files:**
|
|
215
|
+
- Modify: `src/codegen/bin/cli.ts` (the `main()` function — read lines 359-end to find argv handling and `process.exit`)
|
|
216
|
+
- Test: `src/codegen/bin/cli.test.ts`
|
|
217
|
+
|
|
218
|
+
- [ ] **Step 1: Write the failing test**
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// add to src/codegen/bin/cli.test.ts
|
|
222
|
+
import { shouldShowHelp } from './cli.js'
|
|
223
|
+
import { formatHelp } from './flag-specs.js'
|
|
224
|
+
|
|
225
|
+
describe('--help handling', () => {
|
|
226
|
+
it('shouldShowHelp true for --help, -h, and bare (no args)', () => {
|
|
227
|
+
expect(shouldShowHelp(['--help'])).toBe(true)
|
|
228
|
+
expect(shouldShowHelp(['-h'])).toBe(true)
|
|
229
|
+
expect(shouldShowHelp([])).toBe(true)
|
|
230
|
+
})
|
|
231
|
+
it('shouldShowHelp false when real flags are present', () => {
|
|
232
|
+
expect(shouldShowHelp(['--url', 'http://x', '--out', 'gen'])).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
it('help text covers the full flag surface', () => {
|
|
235
|
+
const help = formatHelp()
|
|
236
|
+
expect(help).toContain('--watch')
|
|
237
|
+
expect(help).toContain('--enum-style')
|
|
238
|
+
expect(help).toContain('--target')
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
244
|
+
|
|
245
|
+
Run: `npx vitest run src/codegen/bin/cli.test.ts`
|
|
246
|
+
Expected: FAIL — `shouldShowHelp` not exported.
|
|
247
|
+
|
|
248
|
+
- [ ] **Step 3: Add `shouldShowHelp` and wire it into `main()`**
|
|
249
|
+
|
|
250
|
+
Add the predicate near `parseArgs`:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
/**
|
|
254
|
+
* True when the CLI should print usage and exit 0: an explicit --help/-h, or a
|
|
255
|
+
* bare invocation with no args. --help is handled here (not in parseArgs) so it
|
|
256
|
+
* never reaches the unknown-flag branch and never appears in did-you-mean.
|
|
257
|
+
*/
|
|
258
|
+
export function shouldShowHelp(argv: string[]): boolean {
|
|
259
|
+
if (argv.length === 0) return true
|
|
260
|
+
return argv.includes('--help') || argv.includes('-h')
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
In `main()` (read the existing function; it currently does `extractConfigPath` → `loadConfigFile` → `parseArgs` → `generateClient`), add as the very first lines after reading `argv`:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
const argv = process.argv.slice(2)
|
|
268
|
+
if (shouldShowHelp(argv)) {
|
|
269
|
+
console.log(formatHelp())
|
|
270
|
+
process.exit(0)
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
275
|
+
|
|
276
|
+
Run: `npx vitest run src/codegen/bin/cli.test.ts`
|
|
277
|
+
Expected: PASS.
|
|
278
|
+
|
|
279
|
+
- [ ] **Step 5: Manual smoke check**
|
|
280
|
+
|
|
281
|
+
Run: `npm run build && node build/codegen/bin/cli.js --help`
|
|
282
|
+
Expected: usage text, exit 0. Then `node build/codegen/bin/cli.js --targt ts --out x --url y` still errors with the did-you-mean suggestion.
|
|
283
|
+
|
|
284
|
+
- [ ] **Step 6: Commit**
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
git add src/codegen/bin/cli.ts src/codegen/bin/cli.test.ts
|
|
288
|
+
git commit -m "feat(codegen): --help/-h/bare invocation prints usage and exits 0"
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## #2 — `writeDocEnvelope` offline helper
|
|
294
|
+
|
|
295
|
+
### Task 4: `writeDocEnvelope(source, path)` helper
|
|
296
|
+
|
|
297
|
+
**Files:**
|
|
298
|
+
- Create: `src/doc-envelope.ts`
|
|
299
|
+
- Test: `src/doc-envelope.test.ts`
|
|
300
|
+
|
|
301
|
+
- [ ] **Step 1: Write the failing test**
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
// src/doc-envelope.test.ts
|
|
305
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
306
|
+
import { readFile, rm, mkdtemp } from 'node:fs/promises'
|
|
307
|
+
import { tmpdir } from 'node:os'
|
|
308
|
+
import { join } from 'node:path'
|
|
309
|
+
import { writeDocEnvelope } from './doc-envelope.js'
|
|
310
|
+
import type { DocEnvelope } from './implementations/types.js'
|
|
311
|
+
|
|
312
|
+
const envelope: DocEnvelope = { basePath: '', headers: [], errors: [], routes: [] }
|
|
313
|
+
|
|
314
|
+
describe('writeDocEnvelope', () => {
|
|
315
|
+
let dir: string
|
|
316
|
+
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
|
317
|
+
|
|
318
|
+
it('writes a plain DocEnvelope as pretty JSON', async () => {
|
|
319
|
+
dir = await mkdtemp(join(tmpdir(), 'tsp-'))
|
|
320
|
+
const out = join(dir, 'nested', 'docs.json')
|
|
321
|
+
await writeDocEnvelope(envelope, out)
|
|
322
|
+
const parsed = JSON.parse(await readFile(out, 'utf8'))
|
|
323
|
+
expect(parsed).toEqual(envelope)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('accepts a builder-like object with toDocEnvelope()', async () => {
|
|
327
|
+
dir = await mkdtemp(join(tmpdir(), 'tsp-'))
|
|
328
|
+
const out = join(dir, 'docs.json')
|
|
329
|
+
const builderLike = { toDocEnvelope: () => envelope }
|
|
330
|
+
await writeDocEnvelope(builderLike, out)
|
|
331
|
+
const parsed = JSON.parse(await readFile(out, 'utf8'))
|
|
332
|
+
expect(parsed).toEqual(envelope)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('accepts a DocRegistry-like object (toJSON)', async () => {
|
|
336
|
+
dir = await mkdtemp(join(tmpdir(), 'tsp-'))
|
|
337
|
+
const out = join(dir, 'docs.json')
|
|
338
|
+
const registryLike = { toJSON: () => envelope }
|
|
339
|
+
await writeDocEnvelope(registryLike, out)
|
|
340
|
+
expect(JSON.parse(await readFile(out, 'utf8'))).toEqual(envelope)
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
346
|
+
|
|
347
|
+
Run: `npx vitest run src/doc-envelope.test.ts`
|
|
348
|
+
Expected: FAIL — module not found.
|
|
349
|
+
|
|
350
|
+
- [ ] **Step 3: Write the helper**
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
// src/doc-envelope.ts
|
|
354
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
355
|
+
import { dirname } from 'node:path'
|
|
356
|
+
import type { DocEnvelope } from './implementations/types.js'
|
|
357
|
+
|
|
358
|
+
/** A built builder/registry that can produce a DocEnvelope. */
|
|
359
|
+
export type DocEnvelopeSource =
|
|
360
|
+
| DocEnvelope
|
|
361
|
+
| { toDocEnvelope(): DocEnvelope }
|
|
362
|
+
| { toJSON(): DocEnvelope }
|
|
363
|
+
|
|
364
|
+
function resolveEnvelope(source: DocEnvelopeSource): DocEnvelope {
|
|
365
|
+
if (typeof (source as { toDocEnvelope?: unknown }).toDocEnvelope === 'function') {
|
|
366
|
+
return (source as { toDocEnvelope(): DocEnvelope }).toDocEnvelope()
|
|
367
|
+
}
|
|
368
|
+
if (typeof (source as { toJSON?: unknown }).toJSON === 'function') {
|
|
369
|
+
return (source as { toJSON(): DocEnvelope }).toJSON()
|
|
370
|
+
}
|
|
371
|
+
return source as DocEnvelope
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Serializes a doc envelope to disk as pretty JSON so codegen can run offline
|
|
376
|
+
* via `--file <path>` without a running server. Accepts a plain `DocEnvelope`,
|
|
377
|
+
* a builder exposing `toDocEnvelope()`, or a `DocRegistry` exposing `toJSON()`.
|
|
378
|
+
* Parent directories are created as needed.
|
|
379
|
+
*/
|
|
380
|
+
export async function writeDocEnvelope(source: DocEnvelopeSource, path: string): Promise<void> {
|
|
381
|
+
const envelope = resolveEnvelope(source)
|
|
382
|
+
await mkdir(dirname(path), { recursive: true })
|
|
383
|
+
await writeFile(path, JSON.stringify(envelope, null, 2), 'utf8')
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
> Note: if `DocEnvelope` is not exported from `./implementations/types.js`, import it from wherever `DocRegistry` re-exports it (`./implementations/http/doc-registry.js` re-exports `DocEnvelope`). Verify the correct specifier when implementing.
|
|
388
|
+
|
|
389
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
390
|
+
|
|
391
|
+
Run: `npx vitest run src/doc-envelope.test.ts`
|
|
392
|
+
Expected: PASS.
|
|
393
|
+
|
|
394
|
+
- [ ] **Step 5: Commit**
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
git add src/doc-envelope.ts src/doc-envelope.test.ts
|
|
398
|
+
git commit -m "feat: writeDocEnvelope helper for offline codegen"
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Task 5: Export `writeDocEnvelope` from the package entrypoint + document the loop
|
|
402
|
+
|
|
403
|
+
**Files:**
|
|
404
|
+
- Modify: the package root barrel (find it: `grep -n "writeDocEnvelope\|export" src/index.ts` and check `package.json` `exports`/`main`)
|
|
405
|
+
- Modify: `docs/client-and-codegen.md` (add a short "Offline codegen" subsection)
|
|
406
|
+
|
|
407
|
+
- [ ] **Step 1: Add the export**
|
|
408
|
+
|
|
409
|
+
In the root barrel (`src/index.ts` or the file `package.json` `main`/`exports` points to), add:
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
export { writeDocEnvelope, type DocEnvelopeSource } from './doc-envelope.js'
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Confirm it lands in a publicly-exported subpath. If the package exposes subpaths via `package.json` `exports`, ensure either the root or a sensible subpath re-exports it.
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 2: Document the offline loop**
|
|
418
|
+
|
|
419
|
+
In `docs/client-and-codegen.md`, add a subsection:
|
|
420
|
+
|
|
421
|
+
```markdown
|
|
422
|
+
### Offline codegen (no running server)
|
|
423
|
+
|
|
424
|
+
Emit the envelope to disk once, then generate against the file:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
// scripts/emit-docs.ts
|
|
428
|
+
import { writeDocEnvelope } from 'ts-procedures'
|
|
429
|
+
import { buildApp } from '../src/server/app.js' // your built HonoAppBuilder / DocRegistry
|
|
430
|
+
|
|
431
|
+
const builder = buildApp() // whatever produces your built app/registry
|
|
432
|
+
await writeDocEnvelope(builder, 'docs.json')
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
```bash
|
|
436
|
+
tsx scripts/emit-docs.ts
|
|
437
|
+
npx ts-procedures-codegen --file docs.json --out src/generated --service-name Api
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
`writeDocEnvelope` accepts a built `HonoAppBuilder`, a `DocRegistry`, or a plain
|
|
441
|
+
`DocEnvelope`. No running HTTP server required.
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
- [ ] **Step 3: Build + test**
|
|
445
|
+
|
|
446
|
+
Run: `npm run build && npm run test`
|
|
447
|
+
Expected: PASS.
|
|
448
|
+
|
|
449
|
+
- [ ] **Step 4: Commit**
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
git add src/index.ts docs/client-and-codegen.md
|
|
453
|
+
git commit -m "feat: export writeDocEnvelope; document offline codegen loop"
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
## #4 — Function-valued client headers (dynamic auth)
|
|
459
|
+
|
|
460
|
+
### Task 6: `HeadersInit` function support + async header resolution
|
|
461
|
+
|
|
462
|
+
**Files:**
|
|
463
|
+
- Modify: `src/client/types.ts:202-208` (`ProcedureCallDefaults.headers`)
|
|
464
|
+
- Modify: `src/client/resolve-options.ts:85-95` (`resolveHeaders` → async) and `140-158` (`applyRequestOptions` → async)
|
|
465
|
+
- Modify: `src/client/call.ts:58` and `src/client/stream.ts:189` (`await applyRequestOptions(...)`)
|
|
466
|
+
- Test: `src/client/resolve-options.test.ts` (locate; create if absent)
|
|
467
|
+
|
|
468
|
+
- [ ] **Step 1: Write the failing test**
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
// src/client/resolve-options.test.ts (add)
|
|
472
|
+
import { describe, it, expect } from 'vitest'
|
|
473
|
+
import { applyRequestOptions } from './resolve-options.js'
|
|
474
|
+
import type { AdapterRequest } from './types.js'
|
|
475
|
+
|
|
476
|
+
const baseReq: AdapterRequest = { method: 'POST', url: '/x', headers: undefined, body: undefined }
|
|
477
|
+
|
|
478
|
+
describe('function-valued headers', () => {
|
|
479
|
+
it('resolves a sync function-valued default header', async () => {
|
|
480
|
+
const { request } = await applyRequestOptions(baseReq, { headers: () => ({ Authorization: 'Bearer t1' }) }, undefined)
|
|
481
|
+
expect(request.headers).toMatchObject({ Authorization: 'Bearer t1' })
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('resolves an async function-valued header', async () => {
|
|
485
|
+
const { request } = await applyRequestOptions(
|
|
486
|
+
baseReq,
|
|
487
|
+
{ headers: async () => ({ Authorization: 'Bearer async' }) },
|
|
488
|
+
undefined,
|
|
489
|
+
)
|
|
490
|
+
expect(request.headers).toMatchObject({ Authorization: 'Bearer async' })
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('per-call headers win over default headers (both functions)', async () => {
|
|
494
|
+
const { request } = await applyRequestOptions(
|
|
495
|
+
baseReq,
|
|
496
|
+
{ headers: () => ({ Authorization: 'Bearer default', 'x-a': '1' }) },
|
|
497
|
+
{ headers: () => ({ Authorization: 'Bearer call' }) },
|
|
498
|
+
)
|
|
499
|
+
expect(request.headers).toMatchObject({ Authorization: 'Bearer call', 'x-a': '1' })
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('static record path still works', async () => {
|
|
503
|
+
const { request } = await applyRequestOptions(baseReq, { headers: { 'x-s': 'v' } }, undefined)
|
|
504
|
+
expect(request.headers).toMatchObject({ 'x-s': 'v' })
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
510
|
+
|
|
511
|
+
Run: `npx vitest run src/client/resolve-options.test.ts`
|
|
512
|
+
Expected: FAIL — `applyRequestOptions` is sync / returns non-promise; function headers not resolved.
|
|
513
|
+
|
|
514
|
+
- [ ] **Step 3: Update the `headers` type in `types.ts`**
|
|
515
|
+
|
|
516
|
+
Replace `headers?: Record<string, string>` on `ProcedureCallDefaults` (line 205) with a named type, and add the alias above the interface:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
/**
|
|
520
|
+
* Request headers as a static record OR a (possibly async) function evaluated
|
|
521
|
+
* per request. Use the function form for values that change between calls —
|
|
522
|
+
* e.g. a rotating bearer token: `headers: () => ({ Authorization: \`Bearer ${session.token}\` })`.
|
|
523
|
+
* A static record captured at construction goes stale; a function is re-evaluated each call.
|
|
524
|
+
*/
|
|
525
|
+
export type HeadersInit =
|
|
526
|
+
| Record<string, string>
|
|
527
|
+
| (() => Record<string, string> | Promise<Record<string, string>>)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
export interface ProcedureCallDefaults {
|
|
532
|
+
signal?: AbortSignal
|
|
533
|
+
timeout?: number
|
|
534
|
+
headers?: HeadersInit
|
|
535
|
+
basePath?: string
|
|
536
|
+
meta?: RequestMeta
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
(`ProcedureCallOptions extends ProcedureCallDefaults`, so per-call inherits it automatically.) Update the `headers` JSDoc bullet (lines 196-197) to mention the function form.
|
|
541
|
+
|
|
542
|
+
- [ ] **Step 4: Make `resolveHeaders` async and `applyRequestOptions` async**
|
|
543
|
+
|
|
544
|
+
In `resolve-options.ts`:
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
async function resolveHeadersValue(
|
|
548
|
+
h: HeadersInit | undefined,
|
|
549
|
+
): Promise<Record<string, string> | undefined> {
|
|
550
|
+
if (h == null) return undefined
|
|
551
|
+
return typeof h === 'function' ? await h() : h
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Merges headers with precedence: default < per-call. Function-valued headers
|
|
556
|
+
* are evaluated (awaited) per request. Returns undefined if none would be set.
|
|
557
|
+
*/
|
|
558
|
+
export async function resolveHeaders(
|
|
559
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
560
|
+
options: ProcedureCallOptions | undefined,
|
|
561
|
+
): Promise<Record<string, string> | undefined> {
|
|
562
|
+
const defaultHeaders = await resolveHeadersValue(defaults?.headers)
|
|
563
|
+
const callHeaders = await resolveHeadersValue(options?.headers)
|
|
564
|
+
if (!defaultHeaders && !callHeaders) return undefined
|
|
565
|
+
return { ...defaultHeaders, ...callHeaders }
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Make `applyRequestOptions` async and await the headers:
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
export async function applyRequestOptions(
|
|
573
|
+
request: AdapterRequest,
|
|
574
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
575
|
+
options: ProcedureCallOptions | undefined,
|
|
576
|
+
): Promise<ApplyRequestOptionsResult> {
|
|
577
|
+
const signalSources = resolveSignalSources(defaults, options)
|
|
578
|
+
const resolvedHeaders = await resolveHeaders(defaults, options)
|
|
579
|
+
const meta = resolveMeta(defaults, options)
|
|
580
|
+
|
|
581
|
+
const headers =
|
|
582
|
+
resolvedHeaders || request.headers
|
|
583
|
+
? { ...resolvedHeaders, ...request.headers }
|
|
584
|
+
: undefined
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
request: { ...request, headers, signal: signalSources.combined, meta },
|
|
588
|
+
signalSources,
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Add `HeadersInit` to the type imports at the top of `resolve-options.ts`.
|
|
594
|
+
|
|
595
|
+
- [ ] **Step 5: Await the call sites**
|
|
596
|
+
|
|
597
|
+
`src/client/call.ts:58` → `const applied = await applyRequestOptions(request, defaults, options)`.
|
|
598
|
+
`src/client/stream.ts:189` → `const applied = await applyRequestOptions(request, defaults, options)`.
|
|
599
|
+
Both are already inside `async` functions.
|
|
600
|
+
|
|
601
|
+
- [ ] **Step 6: Run tests**
|
|
602
|
+
|
|
603
|
+
Run: `npx vitest run src/client/resolve-options.test.ts src/client/call.test.ts`
|
|
604
|
+
Expected: PASS. Then `npm run test` to catch any other caller of `applyRequestOptions`/`resolveHeaders` that now needs `await` (grep first: `grep -rn "applyRequestOptions\|resolveHeaders" src`).
|
|
605
|
+
|
|
606
|
+
- [ ] **Step 7: Build**
|
|
607
|
+
|
|
608
|
+
Run: `npm run build`
|
|
609
|
+
Expected: clean. The self-contained `_client.ts`/`_types.ts` are emitted from these sources, so regeneration picks up the new type automatically.
|
|
610
|
+
|
|
611
|
+
- [ ] **Step 8: Commit**
|
|
612
|
+
|
|
613
|
+
```bash
|
|
614
|
+
git add src/client/types.ts src/client/resolve-options.ts src/client/call.ts src/client/stream.ts src/client/resolve-options.test.ts
|
|
615
|
+
git commit -m "feat(client): function-valued headers for dynamic auth (async-resolved per request)"
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
### Task 7: Document the auth seam
|
|
619
|
+
|
|
620
|
+
**Files:**
|
|
621
|
+
- Modify: `docs/client-and-codegen.md` (the hooks/auth area near lines 36-39 and 356-389)
|
|
622
|
+
|
|
623
|
+
- [ ] **Step 1: Add/expand the auth section**
|
|
624
|
+
|
|
625
|
+
```markdown
|
|
626
|
+
### Authentication (rotating tokens)
|
|
627
|
+
|
|
628
|
+
A bearer token that only exists after login — and rotates — must NOT live in a
|
|
629
|
+
static `headers` record: it is captured once at construction and goes stale after
|
|
630
|
+
the next login. Use one of the two dynamic seams instead.
|
|
631
|
+
|
|
632
|
+
**Function-valued `headers` (recommended for auth):**
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
createClient({
|
|
636
|
+
// ...
|
|
637
|
+
defaults: {
|
|
638
|
+
headers: () => ({ Authorization: `Bearer ${session.token}` }), // re-evaluated per call
|
|
639
|
+
},
|
|
640
|
+
})
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
The function may be async (`async () => ({ ... })`) and is awaited before each
|
|
644
|
+
request. Per-call `headers` still override defaults.
|
|
645
|
+
|
|
646
|
+
**`onBeforeRequest` hook (when you need the full request):**
|
|
647
|
+
|
|
648
|
+
```ts
|
|
649
|
+
hooks: {
|
|
650
|
+
onBeforeRequest(ctx) {
|
|
651
|
+
ctx.request.headers = { ...ctx.request.headers, Authorization: `Bearer ${getToken()}` }
|
|
652
|
+
return ctx
|
|
653
|
+
},
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
> ⚠️ A token placed in a static `headers: { Authorization: ... }` record compiles
|
|
658
|
+
> but silently goes stale. Reach for the function form or the hook.
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
- [ ] **Step 2: Commit**
|
|
662
|
+
|
|
663
|
+
```bash
|
|
664
|
+
git add docs/client-and-codegen.md
|
|
665
|
+
git commit -m "docs(client): document function-valued headers and onBeforeRequest as the auth seams"
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## #3 — Shared types keyed on `$id`
|
|
671
|
+
|
|
672
|
+
### Task 8: Verification spike (gates the emission mechanism)
|
|
673
|
+
|
|
674
|
+
**Files:**
|
|
675
|
+
- Create (throwaway, removed after): `src/codegen/__spike__/ajsc-ref.test.ts`
|
|
676
|
+
|
|
677
|
+
This task produces a **decision**, not shipped code. Record the outcome in the plan's Task 9-12 before implementing them.
|
|
678
|
+
|
|
679
|
+
- [ ] **Step 1: Confirm nested `$id` survives `extractJsonSchema`**
|
|
680
|
+
|
|
681
|
+
Write a quick test feeding a TypeBox schema with a nested `$id` through the same path `toDocEnvelope` uses (`src/schema/extract-json-schema.ts` → whatever the rpc/api doc builders call). Assert the nested `$id` is present in the resulting JSON schema.
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
import { describe, it, expect } from 'vitest'
|
|
685
|
+
import { Type } from 'typebox'
|
|
686
|
+
import { extractJsonSchema } from '../../schema/extract-json-schema.js' // verify exact path/name
|
|
687
|
+
|
|
688
|
+
describe('spike: nested $id survival', () => {
|
|
689
|
+
it('keeps $id on a nested object schema', () => {
|
|
690
|
+
const Message = Type.Object({ id: Type.String() }, { $id: 'urn:msg', title: 'Message' })
|
|
691
|
+
const Thread = Type.Object({ messages: Type.Array(Message) }, { $id: 'urn:thread', title: 'Thread' })
|
|
692
|
+
const js = extractJsonSchema(Thread) as any
|
|
693
|
+
// assert urn:msg is reachable somewhere in js
|
|
694
|
+
expect(JSON.stringify(js)).toContain('urn:msg')
|
|
695
|
+
})
|
|
696
|
+
})
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
Run it. **If `$id` is stripped:** the first emission task (Task 9) must first fix extraction to preserve nested `$id`. Record which.
|
|
700
|
+
|
|
701
|
+
- [ ] **Step 2: Probe ajsc `$ref` behaviour**
|
|
702
|
+
|
|
703
|
+
Using the same dynamic `import('ajsc')` path as `src/codegen/emit-types.ts` (`jsonSchemaToExtractedTypes`), feed a schema shaped like:
|
|
704
|
+
|
|
705
|
+
```json
|
|
706
|
+
{ "type": "object", "properties": { "msg": { "$ref": "#/$defs/Message" } },
|
|
707
|
+
"$defs": { "Message": { "type": "object", "title": "Message", "properties": { "id": { "type": "string" } } } } }
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Observe whether ajsc emits a **reference** to a `Message` type (preferred) or **inlines** the object. Record the result.
|
|
711
|
+
|
|
712
|
+
- [ ] **Step 3: Record the decision**
|
|
713
|
+
|
|
714
|
+
**SPIKE OUTCOME (2026-06-05):**
|
|
715
|
+
- **Probe 1:** nested `$id`/`title` **fully survive** into the route JSON schema (`extractJsonSchema` is a no-op for TypeBox; TypeBox inlines referenced objects but preserves `$id`/`title` on every copy). → Task 9 does **not** need to fix extraction.
|
|
716
|
+
- **Probe 2:** ajsc 7.2.0 **inlines** a `$ref` target and re-extracts it **named by property**, with no dedup and no verbatim-type / external-ref / `$defs`-reference option (full option surface checked; `tsType`/`x-tsType`/bare-`$ref` all ignored or rejected; `EmitResult.imports` is always empty for TS). So **Mechanism A is not viable.**
|
|
717
|
+
- **Re-spike → chosen mechanism: Mechanism C (placeholder-token).** Empirically verified clean and property-name-independent:
|
|
718
|
+
1. Walk each route schema; replace every subschema whose `$id` is in the model registry with `{ const: '__MODELREF__<ModelName>__' }` (identity comes from `$id`, not property name; reused models get the same token → free dedup).
|
|
719
|
+
2. Call ajsc **unchanged** (both `inlineTypes` modes; the token is a string-literal type, never extracted as a sub-type, survives inside `Array<...>`).
|
|
720
|
+
3. Single deterministic global replace on the emitted string: `/["']__MODELREF__([A-Za-z_$][\w$]*)__["']/g` → `$1`, accumulating captured names into a per-file import set.
|
|
721
|
+
4. Emit `import type { Message, … } from './_models.js'` from the collected set. Emit `_models.ts` standalone via `emitTypescript(modelSchema, { rootTypeName, inlineTypes:false })`.
|
|
722
|
+
- This avoids the brittle per-property rename machinery of Mechanism B entirely. **Task 12 implements Mechanism C.**
|
|
723
|
+
|
|
724
|
+
- [ ] **Step 4: Remove the spike files; commit the decision note**
|
|
725
|
+
|
|
726
|
+
```bash
|
|
727
|
+
rm -rf src/codegen/__spike__
|
|
728
|
+
git commit --allow-empty -m "chore(codegen): spike — chose Mechanism C (placeholder-token) for \$id model emission (see plan Task 8)"
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
### Task 9: `collect-models.ts` — registry + collision detection
|
|
732
|
+
|
|
733
|
+
**Files:**
|
|
734
|
+
- Create: `src/codegen/collect-models.ts`
|
|
735
|
+
- Test: `src/codegen/collect-models.test.ts`
|
|
736
|
+
|
|
737
|
+
- [ ] **Step 1: Write the failing test**
|
|
738
|
+
|
|
739
|
+
```ts
|
|
740
|
+
// src/codegen/collect-models.test.ts
|
|
741
|
+
import { describe, it, expect } from 'vitest'
|
|
742
|
+
import { collectModels } from './collect-models.js'
|
|
743
|
+
|
|
744
|
+
const messageSchema = { type: 'object', $id: 'urn:msg', title: 'Message', properties: { id: { type: 'string' } } }
|
|
745
|
+
|
|
746
|
+
it('collects each $id subschema once, named from title', () => {
|
|
747
|
+
const routes = [
|
|
748
|
+
{ jsonSchema: { response: messageSchema } },
|
|
749
|
+
{ jsonSchema: { body: { type: 'object', properties: { msg: messageSchema } } } },
|
|
750
|
+
] as any
|
|
751
|
+
const models = collectModels(routes)
|
|
752
|
+
expect(models.map((m) => m.name)).toEqual(['Message'])
|
|
753
|
+
expect(models[0].id).toBe('urn:msg')
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it('throws on $id collision with divergent body', () => {
|
|
757
|
+
const a = { type: 'object', $id: 'urn:x', title: 'X', properties: { a: { type: 'string' } } }
|
|
758
|
+
const b = { type: 'object', $id: 'urn:x', title: 'X', properties: { b: { type: 'number' } } }
|
|
759
|
+
const routes = [{ jsonSchema: { response: a } }, { jsonSchema: { body: b } }] as any
|
|
760
|
+
expect(() => collectModels(routes)).toThrow(/urn:x/)
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('disambiguates distinct $ids that derive the same name', () => {
|
|
764
|
+
const m1 = { type: 'object', $id: 'urn:a/message', title: 'Message', properties: { a: { type: 'string' } } }
|
|
765
|
+
const m2 = { type: 'object', $id: 'urn:b/message', title: 'Message', properties: { b: { type: 'string' } } }
|
|
766
|
+
const models = collectModels([{ jsonSchema: { response: m1 } }, { jsonSchema: { body: m2 } }] as any)
|
|
767
|
+
expect(models.map((m) => m.name).sort()).toEqual(['Message', 'Message2'])
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it('ignores schemas without $id', () => {
|
|
771
|
+
const routes = [{ jsonSchema: { response: { type: 'object', title: 'Loose', properties: {} } } }] as any
|
|
772
|
+
expect(collectModels(routes)).toEqual([])
|
|
773
|
+
})
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
777
|
+
|
|
778
|
+
Run: `npx vitest run src/codegen/collect-models.test.ts`
|
|
779
|
+
Expected: FAIL — module not found.
|
|
780
|
+
|
|
781
|
+
- [ ] **Step 3: Implement `collect-models.ts`**
|
|
782
|
+
|
|
783
|
+
```ts
|
|
784
|
+
// src/codegen/collect-models.ts
|
|
785
|
+
import type { AnyHttpRouteDoc } from '../implementations/types.js'
|
|
786
|
+
|
|
787
|
+
export interface CollectedModel {
|
|
788
|
+
id: string
|
|
789
|
+
name: string
|
|
790
|
+
schema: Record<string, unknown>
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function deriveName(schema: Record<string, unknown>, id: string): string {
|
|
794
|
+
const title = typeof schema.title === 'string' ? schema.title : undefined
|
|
795
|
+
const base = title ?? id.split(/[/#:]/).filter(Boolean).pop() ?? 'Model'
|
|
796
|
+
// PascalCase-ish; reuse toPascalCase from naming.ts if it fits the input shape.
|
|
797
|
+
return base.replace(/(^|[^A-Za-z0-9])([A-Za-z0-9])/g, (_, _s, c) => c.toUpperCase())
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Stable structural key for collision detection (order-insensitive JSON). */
|
|
801
|
+
function stableKey(schema: unknown): string {
|
|
802
|
+
return JSON.stringify(schema, Object.keys(schema as object).sort?.() ?? undefined)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function walk(node: unknown, visit: (s: Record<string, unknown>) => void): void {
|
|
806
|
+
if (node == null || typeof node !== 'object') return
|
|
807
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return }
|
|
808
|
+
const obj = node as Record<string, unknown>
|
|
809
|
+
if (typeof obj.$id === 'string') visit(obj)
|
|
810
|
+
for (const value of Object.values(obj)) walk(value, visit)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Walks every route's JSON schema and returns the deduped set of $id-bearing
|
|
815
|
+
* subschemas, named from `title`. Hoisting is keyed on declared identity ($id)
|
|
816
|
+
* — never on structure. Two subschemas sharing an $id but with divergent bodies
|
|
817
|
+
* are a hard error. Distinct $ids that derive the same name are disambiguated
|
|
818
|
+
* deterministically (Message, Message2, …) by first-seen order.
|
|
819
|
+
*/
|
|
820
|
+
export function collectModels(routes: AnyHttpRouteDoc[]): CollectedModel[] {
|
|
821
|
+
const byId = new Map<string, { schema: Record<string, unknown>; key: string }>()
|
|
822
|
+
for (const route of routes) {
|
|
823
|
+
walk((route as { jsonSchema?: unknown }).jsonSchema, (s) => {
|
|
824
|
+
const id = s.$id as string
|
|
825
|
+
const key = stableKey(s)
|
|
826
|
+
const existing = byId.get(id)
|
|
827
|
+
if (existing) {
|
|
828
|
+
if (existing.key !== key) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`[ts-procedures-codegen] Two schemas share $id "${id}" but have different shapes. ` +
|
|
831
|
+
`An $id must identify a single type. Give the divergent schema its own $id.`,
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
byId.set(id, { schema: s, key })
|
|
837
|
+
})
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const models: CollectedModel[] = []
|
|
841
|
+
const usedNames = new Map<string, number>()
|
|
842
|
+
for (const [id, { schema }] of byId) {
|
|
843
|
+
let name = deriveName(schema, id)
|
|
844
|
+
const seen = usedNames.get(name)
|
|
845
|
+
if (seen) { usedNames.set(name, seen + 1); name = `${name}${seen + 1}` }
|
|
846
|
+
else usedNames.set(name, 1)
|
|
847
|
+
models.push({ id, name, schema })
|
|
848
|
+
}
|
|
849
|
+
return models
|
|
850
|
+
}
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
> When implementing, prefer the existing `toPascalCase` from `src/codegen/naming.ts` for `deriveName` if its input contract matches; keep one naming utility.
|
|
854
|
+
|
|
855
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
856
|
+
|
|
857
|
+
Run: `npx vitest run src/codegen/collect-models.test.ts`
|
|
858
|
+
Expected: PASS.
|
|
859
|
+
|
|
860
|
+
- [ ] **Step 5: Commit**
|
|
861
|
+
|
|
862
|
+
```bash
|
|
863
|
+
git add src/codegen/collect-models.ts src/codegen/collect-models.test.ts
|
|
864
|
+
git commit -m "feat(codegen): collect-models — dedup \$id subschemas with collision detection"
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
### Task 10: Resolve the `sharedTypesImport` map onto collected models
|
|
868
|
+
|
|
869
|
+
**Files:**
|
|
870
|
+
- Modify: `src/codegen/collect-models.ts` (add import-map resolution)
|
|
871
|
+
- Test: `src/codegen/collect-models.test.ts`
|
|
872
|
+
|
|
873
|
+
- [ ] **Step 1: Write the failing test**
|
|
874
|
+
|
|
875
|
+
```ts
|
|
876
|
+
import { resolveModelImports } from './collect-models.js'
|
|
877
|
+
|
|
878
|
+
it('marks models mapped to a user package as imported', () => {
|
|
879
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
880
|
+
const resolved = resolveModelImports(models, { 'urn:msg': { module: '@shared/schemas', name: 'Message' } })
|
|
881
|
+
expect(resolved[0]).toMatchObject({ id: 'urn:msg', import: { module: '@shared/schemas', name: 'Message' } })
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('leaves unmapped models as generated (no import)', () => {
|
|
885
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
886
|
+
const resolved = resolveModelImports(models, {})
|
|
887
|
+
expect(resolved[0].import).toBeUndefined()
|
|
888
|
+
})
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
892
|
+
|
|
893
|
+
Run: `npx vitest run src/codegen/collect-models.test.ts`
|
|
894
|
+
Expected: FAIL — `resolveModelImports` not exported.
|
|
895
|
+
|
|
896
|
+
- [ ] **Step 3: Implement**
|
|
897
|
+
|
|
898
|
+
```ts
|
|
899
|
+
// add to collect-models.ts
|
|
900
|
+
export interface SharedTypeImport { module: string; name: string }
|
|
901
|
+
export type SharedTypesImportMap = Record<string, SharedTypeImport>
|
|
902
|
+
export interface ResolvedModel extends CollectedModel { import?: SharedTypeImport }
|
|
903
|
+
|
|
904
|
+
/** Tags each model with its user-package import when its $id is in the map. */
|
|
905
|
+
export function resolveModelImports(
|
|
906
|
+
models: CollectedModel[],
|
|
907
|
+
map: SharedTypesImportMap | undefined,
|
|
908
|
+
): ResolvedModel[] {
|
|
909
|
+
return models.map((m) => {
|
|
910
|
+
const mapped = map?.[m.id]
|
|
911
|
+
return mapped ? { ...m, import: mapped } : { ...m }
|
|
912
|
+
})
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
917
|
+
|
|
918
|
+
Run: `npx vitest run src/codegen/collect-models.test.ts`
|
|
919
|
+
Expected: PASS.
|
|
920
|
+
|
|
921
|
+
- [ ] **Step 5: Commit**
|
|
922
|
+
|
|
923
|
+
```bash
|
|
924
|
+
git add src/codegen/collect-models.ts src/codegen/collect-models.test.ts
|
|
925
|
+
git commit -m "feat(codegen): resolve sharedTypesImport map onto collected models"
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
### Task 11: `emit-models.ts` — generate `_models.ts`
|
|
929
|
+
|
|
930
|
+
**Files:**
|
|
931
|
+
- Create: `src/codegen/emit-models.ts`
|
|
932
|
+
- Test: `src/codegen/emit-models.test.ts`
|
|
933
|
+
|
|
934
|
+
Uses `jsonSchemaToTypeBody` (flat) / the extracted-types path from `emit-types.ts` to render each generated model as a root named type. Per Task 8 decision, generated models are rendered as standalone root types either way (`_models.ts` is target-of-reference in both mechanisms).
|
|
935
|
+
|
|
936
|
+
- [ ] **Step 1: Write the failing test**
|
|
937
|
+
|
|
938
|
+
```ts
|
|
939
|
+
// src/codegen/emit-models.test.ts
|
|
940
|
+
import { describe, it, expect } from 'vitest'
|
|
941
|
+
import { emitModelsFile } from './emit-models.js'
|
|
942
|
+
|
|
943
|
+
it('re-exports mapped models and declares generated ones', async () => {
|
|
944
|
+
const code = await emitModelsFile(
|
|
945
|
+
[
|
|
946
|
+
{ id: 'urn:msg', name: 'Message', schema: {} as any, import: { module: '@shared/schemas', name: 'Message' } },
|
|
947
|
+
{ id: 'urn:thread', name: 'Thread', schema: { type: 'object', title: 'Thread', properties: { id: { type: 'string' } } } as any },
|
|
948
|
+
],
|
|
949
|
+
{ ajsc: {}, namespaceTypes: true, serviceName: 'Api' },
|
|
950
|
+
)
|
|
951
|
+
expect(code).toContain("export { Message } from '@shared/schemas'")
|
|
952
|
+
expect(code).toMatch(/export (type|interface) Thread/)
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
it('aliases a re-export when the local name differs from the package name', async () => {
|
|
956
|
+
const code = await emitModelsFile(
|
|
957
|
+
[{ id: 'urn:msg', name: 'ChatMessage', schema: {} as any, import: { module: '@shared/schemas', name: 'Message' } }],
|
|
958
|
+
{ ajsc: {}, namespaceTypes: true, serviceName: 'Api' },
|
|
959
|
+
)
|
|
960
|
+
expect(code).toContain("export { Message as ChatMessage } from '@shared/schemas'")
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
it('returns null when there are no models', async () => {
|
|
964
|
+
expect(await emitModelsFile([], { ajsc: {}, namespaceTypes: true, serviceName: 'Api' })).toBeNull()
|
|
965
|
+
})
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
969
|
+
|
|
970
|
+
Run: `npx vitest run src/codegen/emit-models.test.ts`
|
|
971
|
+
Expected: FAIL — module not found.
|
|
972
|
+
|
|
973
|
+
- [ ] **Step 3: Implement `emit-models.ts`**
|
|
974
|
+
|
|
975
|
+
Render order: re-exports first, then generated declarations. In namespace mode, wrap generated models in `export namespace ${serviceName}Models { ... }` and re-exports above it (re-exports can also live inside the namespace via `export import`, but a top-level `export { X } from '...'` is simpler and is what scopes import). Reuse `jsonSchemaToTypeBody`/`jsonSchemaToExtractedTypes` from `emit-types.ts`.
|
|
976
|
+
|
|
977
|
+
```ts
|
|
978
|
+
// src/codegen/emit-models.ts
|
|
979
|
+
import type { ResolvedModel } from './collect-models.js'
|
|
980
|
+
import type { AjscOptions } from './emit-types.js'
|
|
981
|
+
import { jsonSchemaToTypeBody } from './emit-types.js' // verify export
|
|
982
|
+
|
|
983
|
+
export interface EmitModelsOptions {
|
|
984
|
+
ajsc?: AjscOptions
|
|
985
|
+
namespaceTypes: boolean
|
|
986
|
+
serviceName: string
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
export async function emitModelsFile(
|
|
990
|
+
models: ResolvedModel[],
|
|
991
|
+
opts: EmitModelsOptions,
|
|
992
|
+
): Promise<string | null> {
|
|
993
|
+
if (models.length === 0) return null
|
|
994
|
+
const header = '// Generated by ts-procedures-codegen. DO NOT EDIT.'
|
|
995
|
+
const reexports: string[] = []
|
|
996
|
+
const decls: string[] = []
|
|
997
|
+
|
|
998
|
+
for (const m of models) {
|
|
999
|
+
if (m.import) {
|
|
1000
|
+
const clause = m.import.name === m.name ? m.name : `${m.import.name} as ${m.name}`
|
|
1001
|
+
reexports.push(`export { ${clause} } from '${m.import.module}'`)
|
|
1002
|
+
} else {
|
|
1003
|
+
const body = await jsonSchemaToTypeBody(m.schema, opts.ajsc)
|
|
1004
|
+
if (body == null) continue
|
|
1005
|
+
decls.push(`export type ${m.name} = ${body}`)
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const lines = [header, '']
|
|
1010
|
+
if (reexports.length) lines.push(...reexports, '')
|
|
1011
|
+
if (decls.length) {
|
|
1012
|
+
if (opts.namespaceTypes) {
|
|
1013
|
+
// keep flat top-level types; scopes reference them via the _models import.
|
|
1014
|
+
lines.push(...decls)
|
|
1015
|
+
} else {
|
|
1016
|
+
lines.push(...decls)
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return lines.join('\n')
|
|
1020
|
+
}
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
> Decision note: keep `_models.ts` declarations **flat top-level** named types regardless of `namespaceTypes` — scope files import them by name (`import type { Message } from './_models.js'`). This avoids the value+type `export import` gymnastics the scope namespaces need; `_models.ts` is pure types. Confirm scope import wiring in Task 12 matches.
|
|
1024
|
+
|
|
1025
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1026
|
+
|
|
1027
|
+
Run: `npx vitest run src/codegen/emit-models.test.ts`
|
|
1028
|
+
Expected: PASS.
|
|
1029
|
+
|
|
1030
|
+
- [ ] **Step 5: Commit**
|
|
1031
|
+
|
|
1032
|
+
```bash
|
|
1033
|
+
git add src/codegen/emit-models.ts src/codegen/emit-models.test.ts
|
|
1034
|
+
git commit -m "feat(codegen): emit-models — _models.ts hub (re-exports + generated types)"
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
### Task 12: Wire collect→emit-models→scope-reference into the TS pipeline
|
|
1038
|
+
|
|
1039
|
+
**Files:**
|
|
1040
|
+
- Modify: `src/codegen/targets/_shared/target-run.ts` (add `shareModels?`, `sharedTypesImport?` to `TargetRunInput`)
|
|
1041
|
+
- Modify: `src/codegen/pipeline.ts` (thread options through)
|
|
1042
|
+
- Modify: `src/codegen/index.ts` (`GenerateClientOptions` + `generateClient` pass-through)
|
|
1043
|
+
- Modify: `src/codegen/targets/ts/run.ts` (collect models, emit `_models.ts`, pass model registry to `emitScopeFile`)
|
|
1044
|
+
- Modify: `src/codegen/emit-scope.ts` (reference hoisted models instead of inlining — per Task 8 mechanism)
|
|
1045
|
+
- Test: `src/codegen/targets/ts/run.test.ts` (locate the existing run/pipeline test that uses `__fixtures__/users-envelope.json`)
|
|
1046
|
+
|
|
1047
|
+
- [ ] **Step 1: Extend the fixture and write the failing integration test**
|
|
1048
|
+
|
|
1049
|
+
Add a `$id`'d schema reused across two routes to a **copy** of the canonical fixture (or extend it if other targets tolerate the extra `$id` — they ignore it). Assert:
|
|
1050
|
+
|
|
1051
|
+
```ts
|
|
1052
|
+
it('emits a single _models.ts entry for a reused $id and references it from scopes', async () => {
|
|
1053
|
+
const files = await generateClient({
|
|
1054
|
+
envelope: envelopeWithSharedMessage, outDir: tmp, serviceName: 'Api',
|
|
1055
|
+
namespaceTypes: true, selfContained: false, shareModels: true,
|
|
1056
|
+
})
|
|
1057
|
+
const models = files.find((f) => f.path.endsWith('_models.ts'))!
|
|
1058
|
+
expect(models.code.match(/export type Message =/g)).toHaveLength(1)
|
|
1059
|
+
const scope = files.find((f) => f.path.endsWith('messages.ts'))!
|
|
1060
|
+
expect(scope.code).toContain("from './_models.js'")
|
|
1061
|
+
expect(scope.code).toContain('Message')
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
it('--no-share-models (shareModels:false) restores byte-identical legacy output', async () => {
|
|
1065
|
+
const off = await generateClient({ envelope: envelopeWithSharedMessage, outDir: tmpA, serviceName: 'Api', shareModels: false, namespaceTypes: true, selfContained: false })
|
|
1066
|
+
// no _models.ts emitted
|
|
1067
|
+
expect(off.find((f) => f.path.endsWith('_models.ts'))).toBeUndefined()
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
it('schemas without $id are unchanged when shareModels is on', async () => {
|
|
1071
|
+
const on = await generateClient({ envelope: envelopeNoIds, outDir: t1, shareModels: true, namespaceTypes: true, selfContained: false, serviceName: 'Api' })
|
|
1072
|
+
const off = await generateClient({ envelope: envelopeNoIds, outDir: t2, shareModels: false, namespaceTypes: true, selfContained: false, serviceName: 'Api' })
|
|
1073
|
+
const code = (fs: typeof on, n: string) => fs.find((f) => f.path.endsWith(n))!.code
|
|
1074
|
+
// scope output identical (ignoring _models.ts which won't exist either way)
|
|
1075
|
+
expect(code(on, 'users.ts')).toBe(code(off, 'users.ts'))
|
|
1076
|
+
})
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1080
|
+
|
|
1081
|
+
Run: `npx vitest run src/codegen/targets/ts/run.test.ts`
|
|
1082
|
+
Expected: FAIL — `shareModels` not accepted / `_models.ts` not emitted.
|
|
1083
|
+
|
|
1084
|
+
- [ ] **Step 3: Thread the options**
|
|
1085
|
+
|
|
1086
|
+
- `GenerateClientOptions` (index.ts:7-26): add `shareModels?: boolean` and `sharedTypesImport?: SharedTypesImportMap`. Pass both into `runPipeline` (index.ts:30-48).
|
|
1087
|
+
- `pipeline.ts`: forward to the per-target input.
|
|
1088
|
+
- `target-run.ts` `TargetRunInput`: add `shareModels?: boolean`, `sharedTypesImport?: SharedTypesImportMap`.
|
|
1089
|
+
- Default `shareModels` to `true` at the public boundary (cli/pipeline), `false` fallback in low-level run module (mirror the `cleanOutDir` convention).
|
|
1090
|
+
|
|
1091
|
+
- [ ] **Step 4: Implement in `targets/ts/run.ts`**
|
|
1092
|
+
|
|
1093
|
+
After `const errorKeys = ...` and before the scope loop:
|
|
1094
|
+
|
|
1095
|
+
```ts
|
|
1096
|
+
import { collectModels, resolveModelImports } from '../../collect-models.js'
|
|
1097
|
+
import { emitModelsFile } from '../../emit-models.js'
|
|
1098
|
+
|
|
1099
|
+
const shareModels = input.shareModels ?? false
|
|
1100
|
+
const models = shareModels
|
|
1101
|
+
? resolveModelImports(collectModels(envelope.routes), input.sharedTypesImport)
|
|
1102
|
+
: []
|
|
1103
|
+
const modelsById = new Map(models.map((m) => [m.id, m]))
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
Pass `modelsById` into `emitScopeFile(group, { ..., models: modelsById })`. After the scope loop, emit `_models.ts`:
|
|
1107
|
+
|
|
1108
|
+
```ts
|
|
1109
|
+
if (models.length > 0) {
|
|
1110
|
+
const modelsCode = await emitModelsFile(models, { ajsc: ajscOpts, namespaceTypes, serviceName })
|
|
1111
|
+
if (modelsCode != null) {
|
|
1112
|
+
const ml = modelsCode.split('\n'); ml.splice(1, 0, hashComment)
|
|
1113
|
+
files.push({ path: join(outDir, '_models.ts'), code: ml.join('\n') })
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
Guard the reserved-filename check (run.ts:44-52) to also reject a scope named `_models` in self-contained-or-share-models mode.
|
|
1119
|
+
|
|
1120
|
+
- [ ] **Step 5: Implement the scope reference in `emit-scope.ts` (per Task 8 mechanism)**
|
|
1121
|
+
|
|
1122
|
+
`emitScopeFile`/`formatTypes` gain a `models?: Map<string, ResolvedModel>` context field. Before converting a route schema:
|
|
1123
|
+
|
|
1124
|
+
- **Mechanism A (`$ref`):** pre-process the route schema — replace any subschema whose `$id` is in `models` with `{ $ref: '#/$defs/<name>' }` and attach a `$defs` table containing each referenced model. Feed to ajsc; capture references to the model names. Emit `import type { Message } from './_models.js'` (flat) at the top of the scope file for every referenced model (collect referenced names per scope). In namespace mode, reference the bare `Message` (imported) inside the namespace — confirm the spike output uses the imported name.
|
|
1125
|
+
- **Mechanism B (substitution):** convert the route schema as today, then for each model whose `$id` appears in this route, string-substitute the emitted structural literal with the model name (word-boundary patch, same approach as `renameExtractedTypes`), and add the `_models.js` import.
|
|
1126
|
+
|
|
1127
|
+
Either way: add the `_models.js` type-import line for the set of model names referenced in the scope file. Match the existing import style (the file already imports from `./_types`/client path).
|
|
1128
|
+
|
|
1129
|
+
- [ ] **Step 6: Run the test + full suite**
|
|
1130
|
+
|
|
1131
|
+
Run: `npx vitest run src/codegen/targets/ts/run.test.ts && npm run test`
|
|
1132
|
+
Expected: PASS. Verify the no-`$id` regression (byte-identical scope output) is green.
|
|
1133
|
+
|
|
1134
|
+
- [ ] **Step 7: Build + smoke-generate against the demo server**
|
|
1135
|
+
|
|
1136
|
+
Run: `npm run build`. Then regenerate the repo's own client per the documented loop and eyeball `messages.ts` + `_models.ts`: one `Message` type, scopes reference it.
|
|
1137
|
+
|
|
1138
|
+
- [ ] **Step 8: Commit**
|
|
1139
|
+
|
|
1140
|
+
```bash
|
|
1141
|
+
git add src/codegen
|
|
1142
|
+
git commit -m "feat(codegen): hoist \$id schemas into _models.ts and reference them from scopes"
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
### Task 13: `--share-models` flag + `sharedTypesImport` config
|
|
1146
|
+
|
|
1147
|
+
**Files:**
|
|
1148
|
+
- Modify: `src/codegen/bin/cli.ts` (`CodegenConfig`, `ParsedArgs`, parse loop, pass-through to `generateClient`)
|
|
1149
|
+
- Modify: `src/codegen/bin/flag-specs.ts` is already done (Task 1 included the two flags)
|
|
1150
|
+
- Test: `src/codegen/bin/cli.test.ts`
|
|
1151
|
+
|
|
1152
|
+
- [ ] **Step 1: Write the failing test**
|
|
1153
|
+
|
|
1154
|
+
```ts
|
|
1155
|
+
it('parses --share-models / --no-share-models', () => {
|
|
1156
|
+
expect(parseArgs(['--out', 'g', '--url', 'u']).shareModels).toBe(true) // default on
|
|
1157
|
+
expect(parseArgs(['--out', 'g', '--url', 'u', '--no-share-models']).shareModels).toBe(false)
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
it('reads sharedTypesImport from config', () => {
|
|
1161
|
+
const cfg = { sharedTypesImport: { 'urn:msg': { module: '@shared/schemas', name: 'Message' } } }
|
|
1162
|
+
expect(parseArgs(['--out', 'g', '--url', 'u'], cfg as any).sharedTypesImport).toEqual(cfg.sharedTypesImport)
|
|
1163
|
+
})
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1167
|
+
|
|
1168
|
+
Run: `npx vitest run src/codegen/bin/cli.test.ts`
|
|
1169
|
+
Expected: FAIL — `shareModels`/`sharedTypesImport` absent.
|
|
1170
|
+
|
|
1171
|
+
- [ ] **Step 3: Implement**
|
|
1172
|
+
|
|
1173
|
+
- `CodegenConfig` (cli.ts:12-29): add `shareModels?: boolean` and `sharedTypesImport?: Record<string, { module: string; name: string }>`.
|
|
1174
|
+
- `ParsedArgs` (31-48): add `shareModels: boolean` and `sharedTypesImport?: ...`.
|
|
1175
|
+
- In `parseArgs`: `let shareModels = config?.shareModels ?? true` and `const sharedTypesImport = config?.sharedTypesImport`. Add parse branches:
|
|
1176
|
+
|
|
1177
|
+
```ts
|
|
1178
|
+
} else if (arg === '--share-models') {
|
|
1179
|
+
shareModels = true
|
|
1180
|
+
} else if (arg === '--no-share-models') {
|
|
1181
|
+
shareModels = false
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
- Return them in the object (spread `sharedTypesImport` only when defined).
|
|
1185
|
+
- In `main()` where `generateClient` is called, pass `shareModels` and `sharedTypesImport`.
|
|
1186
|
+
|
|
1187
|
+
(`--share-models`/`--no-share-models` are already in `FLAG_SPECS`/`KNOWN_FLAGS` from Task 1, so the unknown-flag guard accepts them — verify.)
|
|
1188
|
+
|
|
1189
|
+
- [ ] **Step 4: Run test + build**
|
|
1190
|
+
|
|
1191
|
+
Run: `npx vitest run src/codegen/bin/cli.test.ts && npm run build`
|
|
1192
|
+
Expected: PASS.
|
|
1193
|
+
|
|
1194
|
+
- [ ] **Step 5: Commit**
|
|
1195
|
+
|
|
1196
|
+
```bash
|
|
1197
|
+
git add src/codegen/bin/cli.ts src/codegen/bin/cli.test.ts
|
|
1198
|
+
git commit -m "feat(codegen): --share-models flag + sharedTypesImport config"
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
---
|
|
1202
|
+
|
|
1203
|
+
## #5 — Scaffolder file-naming knobs
|
|
1204
|
+
|
|
1205
|
+
### Task 14: `fileNameStyle` + `groupBy` in the scaffold skill + templates
|
|
1206
|
+
|
|
1207
|
+
**Files:**
|
|
1208
|
+
- Modify: `agent_config/claude-code/skills/ts-procedures/SKILL.md` (scaffold-mode section, ~lines 260-304)
|
|
1209
|
+
- Modify: `agent_config/claude-code/skills/ts-procedures/templates/procedure.md`, `stream-procedure.md`, `hono.md`, `client.md` (output-filename lines)
|
|
1210
|
+
|
|
1211
|
+
This is markdown/instruction content (no runtime tests). Verify by reading the rendered instructions and the placeholder logic.
|
|
1212
|
+
|
|
1213
|
+
- [ ] **Step 1: Add the two args to SKILL.md scaffold mode**
|
|
1214
|
+
|
|
1215
|
+
In the scaffold-mode section, document:
|
|
1216
|
+
|
|
1217
|
+
```markdown
|
|
1218
|
+
**Optional flags:**
|
|
1219
|
+
- `fileNameStyle` — `PascalCase` (default) | `kebab.concern`.
|
|
1220
|
+
- `PascalCase`: `GetUser.procedure.ts`
|
|
1221
|
+
- `kebab.concern`: `get-user.procedure.ts`
|
|
1222
|
+
- `groupBy` — `flat` (default, CWD) | `scope`.
|
|
1223
|
+
- `scope`: writes under `<scope>/`, e.g. `users/get-user.procedure.ts`. Scope is the
|
|
1224
|
+
procedure's `scope` (from its config), kebab-cased.
|
|
1225
|
+
|
|
1226
|
+
**Default inference (when neither flag is passed):** inspect the target directory.
|
|
1227
|
+
If existing procedure files are kebab-cased and grouped in per-scope folders, default
|
|
1228
|
+
to `fileNameStyle: kebab.concern` + `groupBy: scope`. Otherwise default to
|
|
1229
|
+
`PascalCase` + `flat`. State the inferred choice before writing files.
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
Add placeholder derivations:
|
|
1233
|
+
|
|
1234
|
+
```markdown
|
|
1235
|
+
Derived placeholders:
|
|
1236
|
+
- `{{Name}}` — PascalCase (e.g. `GetUser`)
|
|
1237
|
+
- `{{kebab}}` — kebab-case (e.g. `get-user`)
|
|
1238
|
+
- `{{scope}}` — kebab-cased scope (e.g. `users`)
|
|
1239
|
+
- `{{fileName}}` — `{{Name}}` when fileNameStyle=PascalCase, else `{{kebab}}`
|
|
1240
|
+
- `{{dir}}` — `` (empty) when groupBy=flat, else `{{scope}}/`
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
Update the "Files Generated" table to use the computed name, e.g.
|
|
1244
|
+
`{{dir}}{{fileName}}.procedure.ts`, `{{dir}}{{fileName}}.procedure.test.ts`.
|
|
1245
|
+
|
|
1246
|
+
- [ ] **Step 2: Update each template's output-filename line**
|
|
1247
|
+
|
|
1248
|
+
In `templates/procedure.md` (line ~3) and siblings, replace the hardcoded
|
|
1249
|
+
`{{Name}}.procedure.ts` output reference with `{{dir}}{{fileName}}.procedure.ts`
|
|
1250
|
+
(and `.test.ts`). Same for `stream-procedure.md` (`.stream.ts`), `hono.md`
|
|
1251
|
+
(`.hono.ts`), `client.md` (`.client.ts`). Leave the code bodies untouched.
|
|
1252
|
+
|
|
1253
|
+
- [ ] **Step 3: Verify the skill still installs cleanly**
|
|
1254
|
+
|
|
1255
|
+
The installer is a pure recursive copy (`agent_config/lib/install-claude.mjs`), so no transform to update. Run any agent_config check if present:
|
|
1256
|
+
|
|
1257
|
+
Run: `node agent_config/bin/setup.mjs --dry-run` (or `npx ts-procedures-setup --dry-run` from a consumer) to confirm the files still copy. If a `--check` CI script exists, run it.
|
|
1258
|
+
|
|
1259
|
+
- [ ] **Step 4: Commit**
|
|
1260
|
+
|
|
1261
|
+
```bash
|
|
1262
|
+
git add agent_config/claude-code/skills/ts-procedures
|
|
1263
|
+
git commit -m "feat(scaffold): fileNameStyle + groupBy with auto-default inference"
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
---
|
|
1267
|
+
|
|
1268
|
+
## Final verification
|
|
1269
|
+
|
|
1270
|
+
- [ ] **Run the full suite:** `npm run test` — all green.
|
|
1271
|
+
- [ ] **Lint:** `npm run lint` — clean.
|
|
1272
|
+
- [ ] **Build:** `npm run build` — clean.
|
|
1273
|
+
- [ ] **Regenerate the repo's own client** via the documented offline loop; confirm `_models.ts` holds one `Message`, scopes reference it, and `--help` prints usage.
|
|
1274
|
+
- [ ] **Update `CLAUDE.md`** if any new public surface needs documenting (writeDocEnvelope, `_models.ts`, `sharedTypesImport`, function-valued headers, `--share-models`).
|
|
1275
|
+
- [ ] **Update the downstream feedback file** marking #1–#5 resolved (mirror the 8.3.0 reset note style).
|
|
1276
|
+
|
|
1277
|
+
---
|
|
1278
|
+
|
|
1279
|
+
## Spec coverage check
|
|
1280
|
+
|
|
1281
|
+
| Spec section | Task(s) |
|
|
1282
|
+
|---|---|
|
|
1283
|
+
| #1 structured flags + `--help`/bare, exclude from suggester | 1, 2, 3 |
|
|
1284
|
+
| #2 `writeDocEnvelope` + docs | 4, 5 |
|
|
1285
|
+
| #3 collect by `$id`, dedup, collision error | 9 |
|
|
1286
|
+
| #3 `sharedTypesImport` map | 10, 13 |
|
|
1287
|
+
| #3 `_models.ts` hub (generated + re-export) | 11 |
|
|
1288
|
+
| #3 scope references model; default-on; no-`$id` unchanged; TS-only | 12, 13 |
|
|
1289
|
+
| #3 ajsc/nested-`$id` risk spike | 8 |
|
|
1290
|
+
| #4 function-valued headers, async resolve, per-call merge | 6 |
|
|
1291
|
+
| #4 auth-seam docs | 7 |
|
|
1292
|
+
| #5 `fileNameStyle` + `groupBy` + auto-default | 14 |
|