ts-procedures 9.1.0 → 10.0.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +4 -0
  3. package/agent_config/copilot/copilot-instructions.md +2 -0
  4. package/agent_config/cursor/cursorrules +2 -0
  5. package/build/codegen/bin/cli.d.ts +4 -0
  6. package/build/codegen/bin/cli.js +20 -1
  7. package/build/codegen/bin/cli.js.map +1 -1
  8. package/build/codegen/bin/cli.test.js +21 -0
  9. package/build/codegen/bin/cli.test.js.map +1 -1
  10. package/build/codegen/bin/flag-specs.js +3 -0
  11. package/build/codegen/bin/flag-specs.js.map +1 -1
  12. package/build/codegen/check-output.d.ts +28 -0
  13. package/build/codegen/check-output.js +216 -0
  14. package/build/codegen/check-output.js.map +1 -0
  15. package/build/codegen/check-output.test.d.ts +1 -0
  16. package/build/codegen/check-output.test.js +160 -0
  17. package/build/codegen/check-output.test.js.map +1 -0
  18. package/build/codegen/emit/api-route.js +2 -2
  19. package/build/codegen/emit/api-route.js.map +1 -1
  20. package/build/codegen/emit/context.d.ts +9 -0
  21. package/build/codegen/emit/http-stream-route.js +1 -1
  22. package/build/codegen/emit/http-stream-route.js.map +1 -1
  23. package/build/codegen/emit/rpc-route.js +1 -1
  24. package/build/codegen/emit/rpc-route.js.map +1 -1
  25. package/build/codegen/emit/scope-file.js +15 -1
  26. package/build/codegen/emit/scope-file.js.map +1 -1
  27. package/build/codegen/emit/stream-route.js +1 -1
  28. package/build/codegen/emit/stream-route.js.map +1 -1
  29. package/build/codegen/emit-errors.integration.test.js +2 -2
  30. package/build/codegen/emit-errors.integration.test.js.map +1 -1
  31. package/build/codegen/emit-scope.test.js +55 -0
  32. package/build/codegen/emit-scope.test.js.map +1 -1
  33. package/build/codegen/emit-types.js +19 -0
  34. package/build/codegen/emit-types.js.map +1 -1
  35. package/build/codegen/emit-types.test.js +34 -0
  36. package/build/codegen/emit-types.test.js.map +1 -1
  37. package/build/codegen/index.d.ts +14 -0
  38. package/build/codegen/index.js +11 -1
  39. package/build/codegen/index.js.map +1 -1
  40. package/build/codegen/resolve-envelope.js +1 -1
  41. package/build/codegen/resolve-envelope.js.map +1 -1
  42. package/build/codegen/targets/ts/name-collision.test.d.ts +1 -0
  43. package/build/codegen/targets/ts/name-collision.test.js +30 -0
  44. package/build/codegen/targets/ts/name-collision.test.js.map +1 -0
  45. package/build/codegen/targets/ts/run.js +13 -0
  46. package/build/codegen/targets/ts/run.js.map +1 -1
  47. package/build/codegen/test-helpers/run-tsc.js +1 -1
  48. package/build/codegen/test-helpers/run-tsc.js.map +1 -1
  49. package/docs/client-and-codegen.md +17 -0
  50. package/docs/migration-v8-to-v9.md +12 -4
  51. package/package.json +8 -8
  52. package/src/codegen/bin/cli.test.ts +28 -0
  53. package/src/codegen/bin/cli.ts +27 -1
  54. package/src/codegen/bin/flag-specs.ts +3 -0
  55. package/src/codegen/check-output.test.ts +177 -0
  56. package/src/codegen/check-output.ts +262 -0
  57. package/src/codegen/emit/api-route.ts +2 -2
  58. package/src/codegen/emit/context.ts +9 -0
  59. package/src/codegen/emit/http-stream-route.ts +1 -1
  60. package/src/codegen/emit/rpc-route.ts +1 -1
  61. package/src/codegen/emit/scope-file.ts +19 -1
  62. package/src/codegen/emit/stream-route.ts +1 -1
  63. package/src/codegen/emit-errors.integration.test.ts +2 -2
  64. package/src/codegen/emit-scope.test.ts +60 -0
  65. package/src/codegen/emit-types.test.ts +36 -0
  66. package/src/codegen/emit-types.ts +20 -0
  67. package/src/codegen/index.ts +23 -1
  68. package/src/codegen/resolve-envelope.ts +1 -1
  69. package/src/codegen/targets/ts/name-collision.test.ts +34 -0
  70. package/src/codegen/targets/ts/run.ts +16 -0
  71. package/src/codegen/test-helpers/run-tsc.ts +1 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Opt-in self-validation of generated output (`--check`).
3
+ *
4
+ * Codegen is otherwise throw-to-fail: a bug that emits syntactically-written
5
+ * but non-compiling output (a dangling type reference, a duplicate identifier)
6
+ * exits 0 and silently overwrites a previously-working client. `--check` turns
7
+ * that into a loud, non-zero failure.
8
+ *
9
+ * Two modes:
10
+ * - `'scan'` (default): a dependency-free, in-memory scan for *dangling type
11
+ * references* — a type alias whose body is a bare identifier that is never
12
+ * declared or imported (the exact shape of the namespace-mode root-union
13
+ * bug, `export type Params = Root`). It deliberately does NOT try to detect
14
+ * duplicate identifiers: that needs an accurate model of TypeScript's
15
+ * declaration-merging rules to avoid false positives (a value and a type
16
+ * legally share a name), the duplicate-from-name-collision case is already
17
+ * caught deterministically at the source (see `emitScopeFile`), and `'tsc'`
18
+ * mode catches the rest. Keeping the scan to one high-confidence check makes
19
+ * it reliable and trivial to reason about.
20
+ * - `'tsc'`: a real `tsc --noEmit` over the generated code via the programmatic
21
+ * `typescript` API — authoritative, catches everything. `typescript` is a
22
+ * dev/peer dependency that some consumers won't have installed, so it
23
+ * gracefully falls back to `'scan'` (with a warning) when it can't be
24
+ * resolved.
25
+ */
26
+ import { dirname, resolve } from 'node:path'
27
+ import type { GeneratedFile } from './targets/_shared/write-files.js'
28
+
29
+ export type CheckMode = 'scan' | 'tsc'
30
+
31
+ export interface CheckIssue {
32
+ /** Absolute path of the offending generated file. */
33
+ file: string
34
+ /** 1-based line number, when known. */
35
+ line?: number
36
+ message: string
37
+ }
38
+
39
+ export interface CheckResult {
40
+ ok: boolean
41
+ /** The mode actually run — may differ from the requested mode after a fallback. */
42
+ mode: CheckMode
43
+ issues: CheckIssue[]
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Scan mode
48
+ // ---------------------------------------------------------------------------
49
+
50
+ // Any exported declaration that introduces an identifier the file can later
51
+ // reference (so a `export type X = Foo` whose `Foo` is declared lower down
52
+ // doesn't read as dangling).
53
+ const DECL_RE =
54
+ /^\s*export\s+(?:declare\s+)?(?:type|interface|class|const|let|var|function|enum|namespace)\s+([A-Za-z_$][\w$]*)/
55
+
56
+ // `export type X = <body>` — body captured for the bare-identifier test.
57
+ const TYPE_ALIAS_RE = /^\s*export\s+type\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?);?\s*$/
58
+
59
+ const IMPORT_NAMES_RE = /import\s+(?:type\s+)?(?:\*\s+as\s+\w+|(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from/g
60
+
61
+ const BARE_IDENT_RE = /^[A-Za-z_$][\w$]*$/
62
+
63
+ // TS lib globals / primitives a bare type-alias body may legitimately resolve
64
+ // to without being declared or imported in the file.
65
+ const BUILTIN_TYPES = new Set([
66
+ 'string', 'number', 'boolean', 'void', 'undefined', 'null', 'unknown', 'any',
67
+ 'never', 'object', 'symbol', 'bigint', 'this', 'true', 'false', 'Date',
68
+ 'Array', 'ReadonlyArray', 'Record', 'Promise', 'Map', 'Set', 'Partial',
69
+ 'Required', 'Readonly', 'Pick', 'Omit', 'Exclude', 'Extract', 'NonNullable',
70
+ 'ReturnType', 'Parameters', 'Uint8Array', 'ArrayBuffer', 'Blob',
71
+ ])
72
+
73
+ /**
74
+ * Collects every identifier the file declares or imports — the set of names a
75
+ * bare type-alias body is allowed to reference. Declarations at any nesting are
76
+ * counted: the check only asks "does this name exist in the file at all", not
77
+ * "is it in scope here", so it never false-positives on a valid reference.
78
+ */
79
+ function collectKnownNames(code: string): Set<string> {
80
+ const names = new Set<string>()
81
+ for (const line of code.split('\n')) {
82
+ const decl = DECL_RE.exec(line)
83
+ if (decl) names.add(decl[1]!)
84
+ }
85
+ for (const m of code.matchAll(IMPORT_NAMES_RE)) {
86
+ if (m[1]) names.add(m[1])
87
+ if (m[2]) {
88
+ for (const part of m[2].split(',')) {
89
+ const name = part.split(/\s+as\s+/).pop()?.trim()
90
+ if (name) names.add(name)
91
+ }
92
+ }
93
+ }
94
+ return names
95
+ }
96
+
97
+ /**
98
+ * Flags `export type X = <Bare>` where `<Bare>` is a lone identifier that is
99
+ * neither declared nor imported in the file nor a TS built-in — the exact shape
100
+ * of the namespace-mode root-union bug (`export type Params = Root`). Bodies
101
+ * that are anything other than a single bare identifier (object literals,
102
+ * unions, generics, qualified names) are left alone: they're either valid or
103
+ * the kind of error only a real type-check can adjudicate.
104
+ */
105
+ function scanFile(file: GeneratedFile, known: Set<string>): CheckIssue[] {
106
+ const issues: CheckIssue[] = []
107
+ const lines = file.code.split('\n')
108
+ for (let i = 0; i < lines.length; i += 1) {
109
+ const alias = TYPE_ALIAS_RE.exec(lines[i]!)
110
+ if (alias == null) continue
111
+ const rhs = alias[2]!.trim()
112
+ if (BARE_IDENT_RE.test(rhs) && !BUILTIN_TYPES.has(rhs) && !known.has(rhs)) {
113
+ issues.push({
114
+ file: file.path,
115
+ line: i + 1,
116
+ message: `Type alias "${alias[1]}" references "${rhs}", which is never declared or imported in this file (dangling reference).`,
117
+ })
118
+ }
119
+ }
120
+ return issues
121
+ }
122
+
123
+ export function scanGeneratedFiles(files: GeneratedFile[]): CheckIssue[] {
124
+ return files
125
+ .filter((f) => f.path.endsWith('.ts'))
126
+ .flatMap((f) => scanFile(f, collectKnownNames(f.code)))
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // tsc mode
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Type-checks the generated files in-process with the programmatic `typescript`
135
+ * API. Builds a `Program` over the in-memory `code` via a `CompilerHost` that
136
+ * serves generated content for generated paths and delegates everything else
137
+ * (the default lib + `node_modules`) to the real filesystem. Files are anchored
138
+ * at `outDir`, so extensionless/bare imports (`./_models`, `ts-procedures/client`)
139
+ * resolve under Bundler resolution exactly as the consumer's tooling resolves
140
+ * them: self-contained output resolves entirely within the in-memory set;
141
+ * non-self-contained output finds the installed `ts-procedures` by walking up
142
+ * from `outDir` to the nearest `node_modules`. It reads no bytes it didn't emit
143
+ * and writes nothing, so it can't perturb the frozen codegen output. Works in
144
+ * dry-run too (nothing needs to be on disk). Returns `null` to signal "couldn't
145
+ * run, fall back to scan" when `typescript` can't be resolved.
146
+ *
147
+ * The in-memory host approach mirrors the sibling ts-channels generator's
148
+ * `assertGeneratedFilesCompile`, keeping the two generators' tsc checks aligned.
149
+ */
150
+ async function tscCheck(files: GeneratedFile[]): Promise<CheckIssue[] | null> {
151
+ let ts: typeof import('typescript')
152
+ try {
153
+ const mod = await import('typescript')
154
+ ts = (mod as { default?: typeof import('typescript') }).default ?? mod
155
+ } catch {
156
+ return null
157
+ }
158
+
159
+ const tsFiles = files.filter((f) => f.path.endsWith('.ts'))
160
+ if (tsFiles.length === 0) return []
161
+
162
+ // ts-procedures emits extensionless relative imports (`from './_models'`),
163
+ // which are illegal under NodeNext — so type-check under Bundler resolution to
164
+ // match how that output is actually consumed (tsx / bundlers).
165
+ const options: import('typescript').CompilerOptions = {
166
+ target: ts.ScriptTarget.ES2022,
167
+ module: ts.ModuleKind.ESNext,
168
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
169
+ strict: true,
170
+ noEmit: true,
171
+ skipLibCheck: true,
172
+ }
173
+
174
+ const generated = new Map<string, string>()
175
+ const generatedDirs = new Set<string>()
176
+ for (const f of tsFiles) {
177
+ const abs = resolve(f.path)
178
+ generated.set(abs, f.code)
179
+ generatedDirs.add(dirname(abs))
180
+ }
181
+
182
+ const host = ts.createCompilerHost(options, true)
183
+ const baseGetSourceFile = host.getSourceFile.bind(host)
184
+ const baseReadFile = host.readFile.bind(host)
185
+ const baseFileExists = host.fileExists.bind(host)
186
+ const baseDirectoryExists = host.directoryExists?.bind(host)
187
+ host.getSourceFile = (fileName, languageVersionOrOptions, onError, shouldCreate) => {
188
+ const content = generated.get(resolve(fileName))
189
+ if (content !== undefined) return ts.createSourceFile(fileName, content, languageVersionOrOptions, true)
190
+ return baseGetSourceFile(fileName, languageVersionOrOptions, onError, shouldCreate)
191
+ }
192
+ host.readFile = (fileName) => {
193
+ const content = generated.get(resolve(fileName))
194
+ return content !== undefined ? content : baseReadFile(fileName)
195
+ }
196
+ host.fileExists = (fileName) => generated.has(resolve(fileName)) || baseFileExists(fileName)
197
+ // Without this, Bundler resolution of `./_types` etc. bails when the virtual
198
+ // outDir doesn't exist on disk (e.g. --dry-run) and every relative import
199
+ // reports TS2307, cascading into TS7006 implicit-any noise.
200
+ host.directoryExists = (d) => generatedDirs.has(resolve(d)) || (baseDirectoryExists?.(d) ?? false)
201
+
202
+ const program = ts.createProgram([...generated.keys()], options, host)
203
+ const diagnostics = ts.getPreEmitDiagnostics(program).filter((d) => d.file != null && generated.has(resolve(d.file.fileName)))
204
+
205
+ return diagnostics.map((d) => {
206
+ const message = ts.flattenDiagnosticMessageText(d.messageText, '\n')
207
+ const issue: CheckIssue = {
208
+ file: d.file!.fileName,
209
+ message: `TS${d.code}: ${message}`,
210
+ }
211
+ if (d.start != null) {
212
+ issue.line = d.file!.getLineAndCharacterOfPosition(d.start).line + 1
213
+ }
214
+ return issue
215
+ })
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Orchestration
220
+ // ---------------------------------------------------------------------------
221
+
222
+ export interface CheckOptions {
223
+ mode: CheckMode
224
+ /** Surface fallback warnings to the user. */
225
+ logger?: (message: string) => void
226
+ }
227
+
228
+ export async function checkGeneratedFiles(
229
+ files: GeneratedFile[],
230
+ options: CheckOptions,
231
+ ): Promise<CheckResult> {
232
+ if (options.mode === 'tsc') {
233
+ // The in-memory type-check works from the emitted code, so it runs even in
234
+ // --dry-run. The only reason to fall back is a missing `typescript` peer.
235
+ const tscIssues = await tscCheck(files)
236
+ if (tscIssues != null) {
237
+ return { ok: tscIssues.length === 0, mode: 'tsc', issues: tscIssues }
238
+ }
239
+ options.logger?.(
240
+ '[ts-procedures-codegen] --check-mode tsc requested but `typescript` could not be resolved. Falling back to the scan check.',
241
+ )
242
+ }
243
+
244
+ const issues = scanGeneratedFiles(files)
245
+ return { ok: issues.length === 0, mode: 'scan', issues }
246
+ }
247
+
248
+ /**
249
+ * Renders a failed {@link CheckResult} as a multi-line, human-readable report.
250
+ * Shared by the CLI (stderr) and `generateClient({ validate })` (thrown error)
251
+ * so both surface findings identically.
252
+ */
253
+ export function formatCheckResult(result: CheckResult): string {
254
+ const lines = [
255
+ `[ts-procedures-codegen] Self-check (${result.mode}) found ${result.issues.length} problem(s) in the generated output:`,
256
+ ]
257
+ for (const issue of result.issues) {
258
+ const loc = issue.line != null ? `:${issue.line}` : ''
259
+ lines.push(` ${issue.file}${loc} — ${issue.message}`)
260
+ }
261
+ return lines.join('\n')
262
+ }
@@ -62,7 +62,7 @@ export async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext
62
62
 
63
63
  const declarations: string[] = []
64
64
  let paramsTypeName = 'void'
65
- let returnTypeName = 'void'
65
+ let returnTypeName: string
66
66
 
67
67
  // Track reserved names across all sub-namespaces. Model names are reserved
68
68
  // so an ajsc-extracted sub-type can't silently merge with a referenced model.
@@ -180,5 +180,5 @@ export async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext
180
180
  ? injectRouteErrors(declarations, pascal, errorUnion, ctx.namespaceTypes)
181
181
  : errorUnion !== null // flat mode already emitted errors above
182
182
 
183
- return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected }
183
+ return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected, routePascal: pascal }
184
184
  }
@@ -13,6 +13,15 @@ export interface RouteChunks {
13
13
  hasStream: boolean
14
14
  /** True when this route emitted an `Errors` type (drives the `_errors` import at the top of the scope file). */
15
15
  hasErrors: boolean
16
+ /**
17
+ * The PascalCase name this route emits its types/namespace under (e.g.
18
+ * `GetTask`). `emitScopeFile` checks these for collisions across routes in
19
+ * the scope — two routes whose names pascalize to the same value (e.g.
20
+ * `get-task` and `getTask`, or `Foo`@v2 and `FooV2`@v1) would otherwise emit
21
+ * merged namespaces / duplicate `export type` declarations and a duplicated
22
+ * callable key, none of which compile.
23
+ */
24
+ routePascal: string
16
25
  }
17
26
 
18
27
  export interface EmitRouteContext {
@@ -158,5 +158,5 @@ export async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRo
158
158
  ctx.namespaceTypes
159
159
  )
160
160
 
161
- return { typeDeclarations: declarations, callable, hasStream: true, hasErrors }
161
+ return { typeDeclarations: declarations, callable, hasStream: true, hasErrors, routePascal: pascal }
162
162
  }
@@ -45,5 +45,5 @@ export async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext
45
45
 
46
46
  const hasErrorsInjected = injectRouteErrors(declarations, pascal, errorUnion, ctx.namespaceTypes)
47
47
 
48
- return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected }
48
+ return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected, routePascal: pascal }
49
49
  }
@@ -105,6 +105,13 @@ export async function emitScopeFile(
105
105
  const callables: string[] = []
106
106
  let hasStream = false
107
107
  let scopeHasErrors = false
108
+ // routePascal → the first route name that claimed it. Two routes whose names
109
+ // pascalize to the same value (e.g. `get-task`/`getTask`, or `Foo`@v2 and
110
+ // `FooV2`@v1) would emit merged namespaces, duplicate `export type`
111
+ // declarations, and a duplicated callable key — none of which compile. We
112
+ // fail fast with an actionable message rather than silently emit a broken
113
+ // scope file.
114
+ const pascalToName = new Map<string, string>()
108
115
 
109
116
  for (const route of group.routes) {
110
117
  let chunks: RouteChunks
@@ -125,10 +132,21 @@ export async function emitScopeFile(
125
132
  } catch (err) {
126
133
  const msg = err instanceof Error ? err.message : String(err)
127
134
  throw new Error(
128
- `[ts-procedures-codegen] Failed to emit route "${route.name}" (kind: ${kind}, scope: ${group.scopeKey}): ${msg}`
135
+ `[ts-procedures-codegen] Failed to emit route "${route.name}" (kind: ${kind}, scope: ${group.scopeKey}): ${msg}`,
136
+ { cause: err }
129
137
  )
130
138
  }
131
139
 
140
+ const prior = pascalToName.get(chunks.routePascal)
141
+ if (prior != null) {
142
+ throw new Error(
143
+ `[ts-procedures-codegen] Scope "${group.scopeKey}": routes "${prior}" and "${route.name}" both generate the name "${chunks.routePascal}", ` +
144
+ `whose generated types and callable would collide (duplicate identifier / duplicate object key). ` +
145
+ `Rename one of the routes so they pascalize to distinct names.`,
146
+ )
147
+ }
148
+ pascalToName.set(chunks.routePascal, route.name)
149
+
132
150
  allTypeDeclarations.push(...chunks.typeDeclarations)
133
151
  callables.push(chunks.callable)
134
152
  if (chunks.hasStream) hasStream = true
@@ -77,5 +77,5 @@ export async function emitStreamRoute(route: StreamHttpRouteDoc, ctx: EmitRouteC
77
77
  ctx.namespaceTypes
78
78
  )
79
79
 
80
- return { typeDeclarations: declarations, callable, hasStream: true, hasErrors }
80
+ return { typeDeclarations: declarations, callable, hasStream: true, hasErrors, routePascal: pascal }
81
81
  }
@@ -91,7 +91,7 @@ describe('generated _errors.ts — runtime behavior', () => {
91
91
  const err = e as { stdout?: Buffer; stderr?: Buffer }
92
92
  const stdout = err.stdout?.toString() ?? ''
93
93
  const stderr = err.stderr?.toString() ?? ''
94
- throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
94
+ throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`, { cause: e })
95
95
  }
96
96
 
97
97
  // Dynamic import of the compiled output.
@@ -164,7 +164,7 @@ describe('generated _errors.ts — runtime behavior', () => {
164
164
  const err = e as { stdout?: Buffer; stderr?: Buffer }
165
165
  const stdout = err.stdout?.toString() ?? ''
166
166
  const stderr = err.stderr?.toString() ?? ''
167
- throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
167
+ throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`, { cause: e })
168
168
  }
169
169
 
170
170
  const errorsUrl = `file://${join(outDir, 'out', '_errors.js')}`
@@ -1748,4 +1748,64 @@ describe('emitScopeFile errors-only routes (DX #6)', () => {
1748
1748
  expect(out).toContain('export type LogoutErrors = _errors.Unauthorized')
1749
1749
  expect(out).toContain('client.bindCallableTyped<void, void, LogoutErrors>')
1750
1750
  })
1751
+
1752
+ // Bug repro (downstream): two routes whose names pascalize to the same value
1753
+ // (`get-task`/`getTask`) emit merged namespaces with duplicate `export type`
1754
+ // declarations (the `Ref`/`Params` the downstream saw) AND a duplicated
1755
+ // callable key — none of which compile. We fail fast with both route names.
1756
+ it('throws when two routes in a scope pascalize to the same name', async () => {
1757
+ const group: ScopeGroup = {
1758
+ scopeKey: 'tasks',
1759
+ camelCase: 'tasks',
1760
+ routes: [
1761
+ {
1762
+ kind: 'rpc', name: 'get-task', path: '/t/1', method: 'post', scope: 'tasks', version: 1,
1763
+ jsonSchema: { body: { type: 'object', properties: { ref: { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] } }, required: ['ref'] } },
1764
+ } satisfies RPCHttpRouteDoc,
1765
+ {
1766
+ kind: 'rpc', name: 'getTask', path: '/t/2', method: 'post', scope: 'tasks', version: 1,
1767
+ jsonSchema: { body: { type: 'object', properties: { ref: { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] } }, required: ['ref'] } },
1768
+ } satisfies RPCHttpRouteDoc,
1769
+ ],
1770
+ }
1771
+ await expect(emitScopeFile(group, { namespaceTypes: true })).rejects.toThrow(
1772
+ /routes "get-task" and "getTask" both generate the name "GetTask"/,
1773
+ )
1774
+ })
1775
+
1776
+ // Version-suffix collision: `Foo`@v2 → `FooV2` and `FooV2`@v1 → `FooV2`.
1777
+ it('throws when a version suffix collides with a literal route name', async () => {
1778
+ const group: ScopeGroup = {
1779
+ scopeKey: 'tasks',
1780
+ camelCase: 'tasks',
1781
+ routes: [
1782
+ { kind: 'rpc', name: 'Foo', path: '/t/1', method: 'post', scope: 'tasks', version: 2, jsonSchema: {} } satisfies RPCHttpRouteDoc,
1783
+ { kind: 'rpc', name: 'FooV2', path: '/t/2', method: 'post', scope: 'tasks', version: 1, jsonSchema: {} } satisfies RPCHttpRouteDoc,
1784
+ ],
1785
+ }
1786
+ await expect(emitScopeFile(group, { namespaceTypes: true })).rejects.toThrow(/both generate the name "FooV2"/)
1787
+ })
1788
+
1789
+ // Distinct pascal names — including the same name across DIFFERENT scopes and
1790
+ // same-name-different-version within a scope — must NOT trip the guard.
1791
+ it('allows two routes with the same property-derived sub-type in distinct route namespaces', async () => {
1792
+ const group: ScopeGroup = {
1793
+ scopeKey: 'tasks',
1794
+ camelCase: 'tasks',
1795
+ routes: [
1796
+ {
1797
+ kind: 'rpc', name: 'CreateTask', path: '/t/1', method: 'post', scope: 'tasks', version: 1,
1798
+ jsonSchema: { body: { type: 'object', properties: { ref: { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] } }, required: ['ref'] } },
1799
+ } satisfies RPCHttpRouteDoc,
1800
+ {
1801
+ kind: 'rpc', name: 'UpdateTask', path: '/t/2', method: 'post', scope: 'tasks', version: 1,
1802
+ jsonSchema: { body: { type: 'object', properties: { ref: { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] } }, required: ['ref'] } },
1803
+ } satisfies RPCHttpRouteDoc,
1804
+ ],
1805
+ }
1806
+ const out = await emitScopeFile(group, { namespaceTypes: true })
1807
+ // Both Refs survive, each fenced inside its own route namespace.
1808
+ expect(out).toContain('export namespace CreateTask')
1809
+ expect(out).toContain('export namespace UpdateTask')
1810
+ })
1751
1811
  })
@@ -179,6 +179,42 @@ describe('jsonSchemaToExtractedTypes', () => {
179
179
  expect(result!.body).toContain('id: string')
180
180
  })
181
181
 
182
+ // Bug repro (downstream): a ROOT-LEVEL union (anyOf/oneOf, no top-level
183
+ // `type`) whose arms ajsc can merge makes ajsc's inlineTypes:false path emit
184
+ // `export type Root = Root;` — a self-reference to a name it never declares.
185
+ // Stripping the prefix left `body === 'Root'`, which the namespace emitter
186
+ // wrapped as `export type Params = Root` → TS2304. The body must instead be
187
+ // inlined exactly as flat mode produces it.
188
+ it('inlines a root-level mergeable union instead of emitting a dangling Root self-reference', async () => {
189
+ const schema = {
190
+ anyOf: [
191
+ { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] },
192
+ { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] },
193
+ ],
194
+ }
195
+ const result = await jsonSchemaToExtractedTypes(schema)
196
+ expect(result).not.toBeUndefined()
197
+ expect(result!.body).not.toBe('Root')
198
+ expect(result!.declarations.some((d) => /^export\s+type\s+Root\b/.test(d))).toBe(false)
199
+ expect(result!.body).toContain('a: string')
200
+ })
201
+
202
+ // A genuinely distinct root union must keep working (it never produced the
203
+ // degenerate `Root` body — guard against the fix over-reaching).
204
+ it('keeps a root-level union of distinct objects inlined', async () => {
205
+ const schema = {
206
+ anyOf: [
207
+ { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] },
208
+ { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] },
209
+ ],
210
+ }
211
+ const result = await jsonSchemaToExtractedTypes(schema)
212
+ expect(result).not.toBeUndefined()
213
+ expect(result!.body).not.toBe('Root')
214
+ expect(result!.body).toContain('a: string')
215
+ expect(result!.body).toContain('b: number')
216
+ })
217
+
182
218
  // Bug repro (downstream): codegen emits `Cannot find name 'RootType'`.
183
219
  // ajsc with inlineTypes:false renders an array-root schema as TWO blocks:
184
220
  // export type RootType = { ... };
@@ -241,6 +241,26 @@ export async function jsonSchemaToExtractedTypes(
241
241
  .trim()
242
242
  }
243
243
 
244
+ // Degenerate self-reference guard. When a ROOT-LEVEL union (`anyOf`/`oneOf`
245
+ // with no top-level `type`) has arms ajsc can merge, ajsc's inlineTypes:false
246
+ // path emits `export type Root = Root;` — a self-reference to a name it never
247
+ // declares. Stripping the `Root =` prefix above leaves `body === 'Root'` with
248
+ // no `Root` declaration, so the namespace emitter would wrap it as
249
+ // `export type Params = Root` and the output wouldn't compile (TS2304). Flat
250
+ // mode never hits this because it force-inlines everything; here we do the
251
+ // same — recompute the body via the inline path so the union is materialised
252
+ // as `{ … } | { … }` exactly as flat mode produces it.
253
+ const hasRootDecl = declarations.some((d) => extractedDeclName(d) === 'Root')
254
+ if (body === 'Root' && !hasRootDecl) {
255
+ const inline = await resolveTypeBody(schema, options)
256
+ return {
257
+ declarations: [],
258
+ body: inline.body,
259
+ referencedNamedTypes: inline.referencedNamedTypes,
260
+ extractedTypeNames: [],
261
+ }
262
+ }
263
+
244
264
  return { declarations, body, referencedNamedTypes, extractedTypeNames }
245
265
  }
246
266
 
@@ -1,5 +1,6 @@
1
1
  import { resolveEnvelope, type ResolveInput } from './resolve-envelope.js'
2
2
  import { runPipeline, type GeneratedFile } from './pipeline.js'
3
+ import { checkGeneratedFiles, formatCheckResult, type CheckMode } from './check-output.js'
3
4
  import type { AjscOptions } from './emit-types.js'
4
5
  import type { SharedTypesImportMap } from './collect-models.js'
5
6
  import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
@@ -22,6 +23,15 @@ export interface GenerateClientOptions extends ResolveInput {
22
23
  sharedModelsModule?: string
23
24
  /** Hard-fail codegen if any $id-bearing model would be generated as a local structural twin. */
24
25
  strictSharedModels?: boolean
26
+ /**
27
+ * Self-validate the generated output and throw if it won't compile, instead of
28
+ * silently returning a broken client (the programmatic peer of the CLI's
29
+ * `--check`). `true` runs the dependency-free `'scan'`; pass `{ mode: 'tsc' }`
30
+ * for a full type-check via the optional `typescript` peer (falls back to the
31
+ * scan if it isn't installed). The check runs on the in-memory output, so it
32
+ * works under `dryRun` too. TS target only. (default: off)
33
+ */
34
+ validate?: boolean | { mode?: CheckMode }
25
35
  /**
26
36
  * Sink for non-error progress messages (e.g. the shared-models summary).
27
37
  * Omitted by default so programmatic callers produce no console output; pass
@@ -42,7 +52,7 @@ export interface GenerateClientOptions extends ResolveInput {
42
52
 
43
53
  export async function generateClient(options: GenerateClientOptions): Promise<GeneratedFile[]> {
44
54
  const envelope = await resolveEnvelope(options)
45
- return runPipeline({
55
+ const files = await runPipeline({
46
56
  envelope,
47
57
  outDir: options.outDir,
48
58
  ajsc: options.ajsc,
@@ -66,10 +76,22 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
66
76
  swiftAccessLevel: options.swiftAccessLevel,
67
77
  swiftEmitter: options.swiftEmitter,
68
78
  })
79
+
80
+ if (options.validate) {
81
+ const mode: CheckMode = options.validate === true ? 'scan' : options.validate.mode ?? 'scan'
82
+ const result = await checkGeneratedFiles(files, { mode, logger: options.logger })
83
+ if (!result.ok) {
84
+ throw new Error(`${formatCheckResult(result)}\nThe generated client may not compile.`)
85
+ }
86
+ }
87
+
88
+ return files
69
89
  }
70
90
 
71
91
  export type { AjscOptions } from './emit-types.js'
72
92
  export type { ResolveInput } from './resolve-envelope.js'
93
+ export { checkGeneratedFiles, scanGeneratedFiles, formatCheckResult } from './check-output.js'
94
+ export type { CheckMode, CheckIssue, CheckResult } from './check-output.js'
73
95
 
74
96
  /**
75
97
  * @internal Subject to change with ajsc minor versions. Use for test injection
@@ -21,7 +21,7 @@ export async function resolveEnvelope(input: ResolveInput): Promise<DocEnvelope>
21
21
  response = await fetch(input.url)
22
22
  } catch (err) {
23
23
  const msg = err instanceof Error ? err.message : String(err)
24
- throw new Error(`[ts-procedures-codegen] Failed to connect to ${input.url}: ${msg}`)
24
+ throw new Error(`[ts-procedures-codegen] Failed to connect to ${input.url}: ${msg}`, { cause: err })
25
25
  }
26
26
  if (!response.ok) {
27
27
  throw new Error(`[ts-procedures-codegen] ${input.url} returned HTTP ${response.status} ${response.statusText}`)
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { runPipeline } from '../../pipeline.js'
3
+ import { makeRpcRoute, makeEnvelope } from '../../__fixtures__/make-envelope.js'
4
+
5
+ // Bug repro (downstream): `--service-name Godmode` together with a scope named
6
+ // `godmode` emitted two `export type GodmodeClient = …` declarations (the
7
+ // aggregate client type AND the scope port type) → duplicate identifier. We now
8
+ // fail fast at generation time with both knobs the user can turn.
9
+ describe('ts target: --service-name vs scope-name collision', () => {
10
+ const envelope = makeEnvelope([
11
+ makeRpcRoute({
12
+ name: 'DoThing',
13
+ scope: 'godmode',
14
+ jsonSchema: { body: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
15
+ }),
16
+ ])
17
+
18
+ it('throws when a scope pascalizes to the same name as --service-name', async () => {
19
+ await expect(
20
+ runPipeline({ envelope, outDir: 'out', dryRun: true, serviceName: 'Godmode', namespaceTypes: true }),
21
+ ).rejects.toThrow(/Scope "godmode" produces client port type "GodmodeClient" which collides/)
22
+ })
23
+
24
+ it('does not throw when --service-name differs from every scope', async () => {
25
+ const files = await runPipeline({
26
+ envelope,
27
+ outDir: 'out',
28
+ dryRun: true,
29
+ serviceName: 'GodmodeApi',
30
+ namespaceTypes: true,
31
+ })
32
+ expect(files.length).toBeGreaterThan(0)
33
+ })
34
+ })
@@ -16,6 +16,7 @@ import { emitClientTypesFile } from '../../emit-client-types.js'
16
16
  import { emitClientRuntimeFile } from '../../emit-client-runtime.js'
17
17
  import { collectModels, resolveModelImports } from '../../collect-models.js'
18
18
  import { emitModelsFile } from '../../emit-models.js'
19
+ import { toPascalCase } from '../../naming.js'
19
20
 
20
21
  export type TsRunInput = TargetRunInput
21
22
 
@@ -48,6 +49,21 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
48
49
  envelope.errors.filter((e) => e.schema != null).map((e) => e.name),
49
50
  )
50
51
 
52
+ // The index emits one aggregate `${ServiceName}Client` type AND one
53
+ // `${ScopePascal}Client` port type per scope. When a scope's PascalCase name
54
+ // equals the service name's, the two declarations collide (duplicate
55
+ // identifier). Fail fast with both knobs the user can turn, rather than emit
56
+ // a non-compiling index.
57
+ const servicePascal = toPascalCase(serviceName)
58
+ for (const group of groups) {
59
+ if (toPascalCase(group.camelCase) === servicePascal) {
60
+ throw new Error(
61
+ `[ts-procedures-codegen] Scope "${group.scopeKey}" produces client port type "${servicePascal}Client" which collides with the aggregate client type "${servicePascal}Client" derived from --service-name "${serviceName}". ` +
62
+ `Rename the scope or change --service-name so they differ.`,
63
+ )
64
+ }
65
+ }
66
+
51
67
  if (selfContained) {
52
68
  for (const group of groups) {
53
69
  if (group.scopeKey === '_types' || group.scopeKey === '_client') {
@@ -51,6 +51,7 @@ export function runTsc(args: {
51
51
  const combined = [stdout, stderr].filter(Boolean).join('\n').trim()
52
52
  throw new Error(
53
53
  `[runTsc] tsc failed for ${tsconfigPath}:\n${combined || (e.message ?? '(no output)')}`,
54
+ { cause: err },
54
55
  )
55
56
  }
56
57
  }