ts-procedures 6.0.2 → 6.2.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 (199) hide show
  1. package/agent_config/bin/setup.mjs +2 -2
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -0
  3. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +2 -0
  4. package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +106 -0
  5. package/agent_config/claude-code/skills/ts-procedures-swift/SKILL.md +119 -0
  6. package/agent_config/copilot/copilot-instructions.md +3 -0
  7. package/agent_config/cursor/cursorrules +3 -0
  8. package/agent_config/lib/install-claude.mjs +1 -1
  9. package/build/codegen/bin/cli.d.ts +39 -0
  10. package/build/codegen/bin/cli.js +164 -0
  11. package/build/codegen/bin/cli.js.map +1 -1
  12. package/build/codegen/bin/cli.test.js +180 -1
  13. package/build/codegen/bin/cli.test.js.map +1 -1
  14. package/build/codegen/index.d.ts +36 -0
  15. package/build/codegen/index.js +8 -0
  16. package/build/codegen/index.js.map +1 -1
  17. package/build/codegen/pipeline.d.ts +22 -4
  18. package/build/codegen/pipeline.js +44 -86
  19. package/build/codegen/pipeline.js.map +1 -1
  20. package/build/codegen/pipeline.test.js +162 -0
  21. package/build/codegen/pipeline.test.js.map +1 -1
  22. package/build/codegen/targets/_shared/error-schemas.d.ts +10 -0
  23. package/build/codegen/targets/_shared/error-schemas.js +17 -0
  24. package/build/codegen/targets/_shared/error-schemas.js.map +1 -0
  25. package/build/codegen/targets/_shared/error-schemas.test.d.ts +1 -0
  26. package/build/codegen/targets/_shared/error-schemas.test.js +38 -0
  27. package/build/codegen/targets/_shared/error-schemas.test.js.map +1 -0
  28. package/build/codegen/targets/_shared/indent.d.ts +6 -0
  29. package/build/codegen/targets/_shared/indent.js +13 -0
  30. package/build/codegen/targets/_shared/indent.js.map +1 -0
  31. package/build/codegen/targets/_shared/indent.test.d.ts +1 -0
  32. package/build/codegen/targets/_shared/indent.test.js +21 -0
  33. package/build/codegen/targets/_shared/indent.test.js.map +1 -0
  34. package/build/codegen/targets/_shared/pascal-case.d.ts +6 -0
  35. package/build/codegen/targets/_shared/pascal-case.js +13 -0
  36. package/build/codegen/targets/_shared/pascal-case.js.map +1 -0
  37. package/build/codegen/targets/_shared/pascal-case.test.d.ts +1 -0
  38. package/build/codegen/targets/_shared/pascal-case.test.js +25 -0
  39. package/build/codegen/targets/_shared/pascal-case.test.js.map +1 -0
  40. package/build/codegen/targets/_shared/path-utils.d.ts +12 -0
  41. package/build/codegen/targets/_shared/path-utils.js +20 -0
  42. package/build/codegen/targets/_shared/path-utils.js.map +1 -0
  43. package/build/codegen/targets/_shared/path-utils.test.d.ts +1 -0
  44. package/build/codegen/targets/_shared/path-utils.test.js +42 -0
  45. package/build/codegen/targets/_shared/path-utils.test.js.map +1 -0
  46. package/build/codegen/targets/_shared/pick-defined.d.ts +11 -0
  47. package/build/codegen/targets/_shared/pick-defined.js +21 -0
  48. package/build/codegen/targets/_shared/pick-defined.js.map +1 -0
  49. package/build/codegen/targets/_shared/pick-defined.test.d.ts +1 -0
  50. package/build/codegen/targets/_shared/pick-defined.test.js +25 -0
  51. package/build/codegen/targets/_shared/pick-defined.test.js.map +1 -0
  52. package/build/codegen/targets/_shared/route-slots.d.ts +17 -0
  53. package/build/codegen/targets/_shared/route-slots.js +17 -0
  54. package/build/codegen/targets/_shared/route-slots.js.map +1 -0
  55. package/build/codegen/targets/_shared/route-slots.test.d.ts +1 -0
  56. package/build/codegen/targets/_shared/route-slots.test.js +43 -0
  57. package/build/codegen/targets/_shared/route-slots.test.js.map +1 -0
  58. package/build/codegen/targets/_shared/target-run.d.ts +27 -0
  59. package/build/codegen/targets/_shared/target-run.js +2 -0
  60. package/build/codegen/targets/_shared/target-run.js.map +1 -0
  61. package/build/codegen/targets/_shared/write-files.d.ts +24 -0
  62. package/build/codegen/targets/_shared/write-files.js +35 -0
  63. package/build/codegen/targets/_shared/write-files.js.map +1 -0
  64. package/build/codegen/targets/_shared/write-files.test.d.ts +1 -0
  65. package/build/codegen/targets/_shared/write-files.test.js +79 -0
  66. package/build/codegen/targets/_shared/write-files.test.js.map +1 -0
  67. package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +6 -4
  68. package/build/codegen/targets/kotlin/ajsc-adapter.js +12 -7
  69. package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -1
  70. package/build/codegen/targets/kotlin/ajsc-adapter.test.js +20 -2
  71. package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -1
  72. package/build/codegen/targets/kotlin/e2e-compile.test.js +41 -9
  73. package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -1
  74. package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +6 -2
  75. package/build/codegen/targets/kotlin/emit-route-kotlin.js +18 -28
  76. package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -1
  77. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +120 -1
  78. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
  79. package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +4 -1
  80. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +12 -11
  81. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
  82. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +39 -0
  83. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
  84. package/build/codegen/targets/kotlin/format-kotlin.d.ts +0 -1
  85. package/build/codegen/targets/kotlin/format-kotlin.js +0 -7
  86. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
  87. package/build/codegen/targets/kotlin/format-kotlin.test.js +1 -8
  88. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
  89. package/build/codegen/targets/kotlin/integration.test.js +27 -10
  90. package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
  91. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.d.ts +1 -0
  92. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js +50 -0
  93. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js.map +1 -0
  94. package/build/codegen/targets/kotlin/run.d.ts +11 -0
  95. package/build/codegen/targets/kotlin/run.js +51 -0
  96. package/build/codegen/targets/kotlin/run.js.map +1 -0
  97. package/build/codegen/targets/swift/access-level.test.d.ts +1 -0
  98. package/build/codegen/targets/swift/access-level.test.js +98 -0
  99. package/build/codegen/targets/swift/access-level.test.js.map +1 -0
  100. package/build/codegen/targets/swift/ajsc-adapter.d.ts +27 -0
  101. package/build/codegen/targets/swift/ajsc-adapter.js +38 -0
  102. package/build/codegen/targets/swift/ajsc-adapter.js.map +1 -0
  103. package/build/codegen/targets/swift/ajsc-adapter.test.d.ts +1 -0
  104. package/build/codegen/targets/swift/ajsc-adapter.test.js +37 -0
  105. package/build/codegen/targets/swift/ajsc-adapter.test.js.map +1 -0
  106. package/build/codegen/targets/swift/e2e-compile.test.d.ts +1 -0
  107. package/build/codegen/targets/swift/e2e-compile.test.js +57 -0
  108. package/build/codegen/targets/swift/e2e-compile.test.js.map +1 -0
  109. package/build/codegen/targets/swift/emit-route-swift.d.ts +15 -0
  110. package/build/codegen/targets/swift/emit-route-swift.js +64 -0
  111. package/build/codegen/targets/swift/emit-route-swift.js.map +1 -0
  112. package/build/codegen/targets/swift/emit-route-swift.test.d.ts +1 -0
  113. package/build/codegen/targets/swift/emit-route-swift.test.js +258 -0
  114. package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -0
  115. package/build/codegen/targets/swift/emit-scope-swift.d.ts +13 -0
  116. package/build/codegen/targets/swift/emit-scope-swift.js +36 -0
  117. package/build/codegen/targets/swift/emit-scope-swift.js.map +1 -0
  118. package/build/codegen/targets/swift/emit-scope-swift.test.d.ts +1 -0
  119. package/build/codegen/targets/swift/emit-scope-swift.test.js +136 -0
  120. package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -0
  121. package/build/codegen/targets/swift/format-swift.d.ts +2 -0
  122. package/build/codegen/targets/swift/format-swift.js +10 -0
  123. package/build/codegen/targets/swift/format-swift.js.map +1 -0
  124. package/build/codegen/targets/swift/format-swift.test.d.ts +1 -0
  125. package/build/codegen/targets/swift/format-swift.test.js +14 -0
  126. package/build/codegen/targets/swift/format-swift.test.js.map +1 -0
  127. package/build/codegen/targets/swift/integration.test.d.ts +1 -0
  128. package/build/codegen/targets/swift/integration.test.js +53 -0
  129. package/build/codegen/targets/swift/integration.test.js.map +1 -0
  130. package/build/codegen/targets/swift/run.d.ts +11 -0
  131. package/build/codegen/targets/swift/run.js +47 -0
  132. package/build/codegen/targets/swift/run.js.map +1 -0
  133. package/build/codegen/targets/ts/run.d.ts +4 -0
  134. package/build/codegen/targets/ts/run.js +86 -0
  135. package/build/codegen/targets/ts/run.js.map +1 -0
  136. package/build/codegen/test-helpers/golden.d.ts +15 -0
  137. package/build/codegen/test-helpers/golden.js +30 -0
  138. package/build/codegen/test-helpers/golden.js.map +1 -0
  139. package/build/codegen/test-helpers/golden.test.d.ts +1 -0
  140. package/build/codegen/test-helpers/golden.test.js +76 -0
  141. package/build/codegen/test-helpers/golden.test.js.map +1 -0
  142. package/docs/codegen-kotlin.md +176 -0
  143. package/docs/codegen-swift.md +314 -0
  144. package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
  145. package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +1 -1
  146. package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
  147. package/docs/superpowers/specs/2026-04-25-swift-codegen-design.md +264 -0
  148. package/package.json +2 -2
  149. package/src/codegen/__fixtures__/users-envelope.json +144 -0
  150. package/src/codegen/bin/cli.test.ts +200 -1
  151. package/src/codegen/bin/cli.ts +187 -0
  152. package/src/codegen/index.ts +50 -0
  153. package/src/codegen/pipeline.test.ts +175 -0
  154. package/src/codegen/pipeline.ts +58 -101
  155. package/src/codegen/targets/_shared/error-schemas.test.ts +42 -0
  156. package/src/codegen/targets/_shared/error-schemas.ts +17 -0
  157. package/src/codegen/targets/_shared/indent.test.ts +25 -0
  158. package/src/codegen/targets/_shared/indent.ts +12 -0
  159. package/src/codegen/targets/_shared/pascal-case.test.ts +30 -0
  160. package/src/codegen/targets/_shared/pascal-case.ts +12 -0
  161. package/src/codegen/targets/_shared/path-utils.test.ts +51 -0
  162. package/src/codegen/targets/_shared/path-utils.ts +21 -0
  163. package/src/codegen/targets/_shared/pick-defined.test.ts +48 -0
  164. package/src/codegen/targets/_shared/pick-defined.ts +23 -0
  165. package/src/codegen/targets/_shared/route-slots.test.ts +55 -0
  166. package/src/codegen/targets/_shared/route-slots.ts +32 -0
  167. package/src/codegen/targets/_shared/target-run.ts +28 -0
  168. package/src/codegen/targets/_shared/write-files.test.ts +110 -0
  169. package/src/codegen/targets/_shared/write-files.ts +53 -0
  170. package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +121 -0
  171. package/src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap +27 -0
  172. package/src/codegen/targets/kotlin/ajsc-adapter.test.ts +47 -0
  173. package/src/codegen/targets/kotlin/ajsc-adapter.ts +66 -0
  174. package/src/codegen/targets/kotlin/e2e-compile.test.ts +86 -0
  175. package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +239 -0
  176. package/src/codegen/targets/kotlin/emit-route-kotlin.ts +89 -0
  177. package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
  178. package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +60 -0
  179. package/src/codegen/targets/kotlin/format-kotlin.test.ts +26 -0
  180. package/src/codegen/targets/kotlin/format-kotlin.ts +13 -0
  181. package/src/codegen/targets/kotlin/integration.test.ts +77 -0
  182. package/src/codegen/targets/kotlin/probe-unsupported-unions.test.ts +64 -0
  183. package/src/codegen/targets/kotlin/run.ts +78 -0
  184. package/src/codegen/targets/swift/__fixtures__/users-golden.swift +123 -0
  185. package/src/codegen/targets/swift/access-level.test.ts +108 -0
  186. package/src/codegen/targets/swift/ajsc-adapter.test.ts +47 -0
  187. package/src/codegen/targets/swift/ajsc-adapter.ts +67 -0
  188. package/src/codegen/targets/swift/e2e-compile.test.ts +66 -0
  189. package/src/codegen/targets/swift/emit-route-swift.test.ts +300 -0
  190. package/src/codegen/targets/swift/emit-route-swift.ts +90 -0
  191. package/src/codegen/targets/swift/emit-scope-swift.test.ts +164 -0
  192. package/src/codegen/targets/swift/emit-scope-swift.ts +59 -0
  193. package/src/codegen/targets/swift/format-swift.test.ts +23 -0
  194. package/src/codegen/targets/swift/format-swift.ts +9 -0
  195. package/src/codegen/targets/swift/integration.test.ts +80 -0
  196. package/src/codegen/targets/swift/run.ts +74 -0
  197. package/src/codegen/targets/ts/run.ts +117 -0
  198. package/src/codegen/test-helpers/golden.test.ts +80 -0
  199. package/src/codegen/test-helpers/golden.ts +34 -0
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { runPipeline } from '../../pipeline.js'
6
+ import { assertGoldenOrUpdate } from '../../test-helpers/golden.js'
7
+ import { createStubSwiftEmitter, type SwiftEmitResult } from './ajsc-adapter.js'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const __dirname = dirname(__filename)
11
+
12
+ const ok = (code: string, rootTypeName: string, imports: string[] = []): SwiftEmitResult => ({
13
+ code,
14
+ rootTypeName,
15
+ extractedTypeNames: [],
16
+ imports,
17
+ })
18
+
19
+ describe('swift codegen — integration', () => {
20
+ it('produces byte-identical output against the golden fixture', async () => {
21
+ const envelopePath = join(__dirname, '../../__fixtures__/users-envelope.json')
22
+ const goldenPath = join(__dirname, '__fixtures__/users-golden.swift')
23
+ const envelope = JSON.parse(await readFile(envelopePath, 'utf8'))
24
+
25
+ // Hand-authored slot outputs in the v7.2 nested-struct shape (inlineTypes: true).
26
+ //
27
+ // The stub map is keyed on rootTypeName, NOT on (route, slot). Both GetUser
28
+ // and CreateUser have a slot named "Response" — they intentionally share the
29
+ // single stub entry below. The golden file therefore shows three identical
30
+ // Response structs (one per route that has one). This is correct: the
31
+ // integration test pins our file-assembly logic, not real ajsc per-route
32
+ // output. The swiftc E2E test exercises real ajsc against the same fixture
33
+ // and would surface any incompatibility.
34
+ const emitter = createStubSwiftEmitter({
35
+ // GetUser
36
+ PathParams: ok(
37
+ 'public struct PathParams: Codable {\n public let id: String\n}',
38
+ 'PathParams',
39
+ ),
40
+ Response: ok(
41
+ 'public struct Response: Codable {\n public let id: String\n public let name: String\n public let createdAt: Date\n public let address: Address\n\n enum CodingKeys: String, CodingKey {\n case id, name\n case createdAt = "created-at"\n case address\n }\n\n public struct Address: Codable {\n public let street: String\n public let city: String\n }\n}',
42
+ 'Response',
43
+ ['Foundation'],
44
+ ),
45
+ NotFound: ok(
46
+ 'public struct NotFound: Codable {\n public let name: String\n public let message: String\n}',
47
+ 'NotFound',
48
+ ),
49
+
50
+ // CreateUser
51
+ Body: ok(
52
+ 'public enum Body: Codable {\n case guest(GuestBody)\n case registered(RegisteredBody)\n\n public struct GuestBody: Codable {\n public let kind: String\n public let displayName: String\n }\n\n public struct RegisteredBody: Codable {\n public let kind: String\n public let email: String\n public let name: String\n }\n}',
53
+ 'Body',
54
+ ),
55
+ ValidationError: ok(
56
+ 'public struct ValidationError: Codable {\n public let name: String\n public let message: String\n public let field: String?\n}',
57
+ 'ValidationError',
58
+ ),
59
+
60
+ // ListUsers
61
+ Query: ok(
62
+ 'public struct Query: Codable {\n public let status: Status?\n public let limit: Int64?\n\n public enum Status: String, Codable {\n case active\n case inactive\n }\n}',
63
+ 'Query',
64
+ ),
65
+ })
66
+
67
+ const files = await runPipeline({
68
+ envelope,
69
+ outDir: 'out',
70
+ dryRun: true,
71
+ target: 'swift',
72
+ swiftEmitter: emitter,
73
+ })
74
+
75
+ expect(files).toHaveLength(1)
76
+ expect(files[0]!.path).toBe(join('out', 'Users.swift'))
77
+
78
+ await assertGoldenOrUpdate(files[0]!.code, goldenPath)
79
+ })
80
+ })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Swift-target pipeline runner. Invoked by the dispatcher in
3
+ * `src/codegen/pipeline.ts` when `target === 'swift'`. Emits one `.swift`
4
+ * file per scope via `emitSwiftScope` and commits via the shared
5
+ * `writeGeneratedFiles` tail.
6
+ */
7
+ import { join } from 'node:path'
8
+ import type { GeneratedFile } from '../_shared/write-files.js'
9
+ import { writeGeneratedFiles } from '../_shared/write-files.js'
10
+ import { buildErrorSchemasMap } from '../_shared/error-schemas.js'
11
+ import { pickDefined } from '../_shared/pick-defined.js'
12
+ import type { TargetRunInput } from '../_shared/target-run.js'
13
+ import type { SwiftEmitter } from './ajsc-adapter.js'
14
+ import { emitSwiftScope } from './emit-scope-swift.js'
15
+
16
+ export interface SwiftRunInput extends TargetRunInput {
17
+ swiftSerializer?: 'codable' | 'none'
18
+ swiftAccessLevel?: 'public' | 'internal'
19
+ unsupportedUnions?: 'throw' | 'fallback'
20
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
21
+ swiftEmitter?: SwiftEmitter
22
+ }
23
+
24
+ /**
25
+ * `--array-item-naming`, `--depluralize`, `--uncountable-words` are reused
26
+ * across targets. The CLI parks them on `options.ajsc`; copy the relevant
27
+ * subset onto the scope opts here.
28
+ */
29
+ const AJSC_PASSTHROUGH_KEYS = ['arrayItemNaming', 'depluralize', 'uncountableWords'] as const
30
+
31
+ export async function runSwiftPipeline(input: SwiftRunInput): Promise<GeneratedFile[]> {
32
+ const { envelope, outDir, hash, groups, dryRun = false, cleanOutDir = false } = input
33
+
34
+ // Runtime guard for non-CLI callers (direct API consumers + tests).
35
+ // The CLI resolves the emitter before calling runPipeline, so end users
36
+ // never hit this throw. Swift currently has no required CLI flags.
37
+ if (input.swiftEmitter == null) {
38
+ throw new Error(
39
+ '[ts-procedures-codegen] target=swift requires a swiftEmitter (CLI resolves via resolveProductionSwiftEmitter)'
40
+ )
41
+ }
42
+
43
+ const errorSchemas = buildErrorSchemasMap(envelope.errors)
44
+ const ajscPassthrough = input.ajsc ?? {}
45
+
46
+ const files: GeneratedFile[] = []
47
+ const allSkipped: string[] = []
48
+ for (const group of groups) {
49
+ const emitted = emitSwiftScope(
50
+ group,
51
+ {
52
+ sourceHash: hash,
53
+ ...(input.swiftSerializer !== undefined ? { serializer: input.swiftSerializer } : {}),
54
+ ...(input.swiftAccessLevel !== undefined ? { accessLevel: input.swiftAccessLevel } : {}),
55
+ ...(input.unsupportedUnions !== undefined ? { unsupportedUnions: input.unsupportedUnions } : {}),
56
+ ...pickDefined(ajscPassthrough, AJSC_PASSTHROUGH_KEYS),
57
+ },
58
+ input.swiftEmitter,
59
+ errorSchemas,
60
+ )
61
+ files.push({ path: join(outDir, emitted.filename), code: emitted.code })
62
+ allSkipped.push(...emitted.skippedStreams)
63
+ }
64
+
65
+ if (allSkipped.length > 0) {
66
+ console.log(
67
+ `[ts-procedures-codegen] Skipped ${allSkipped.length} stream route${allSkipped.length === 1 ? '' : 's'} (swift target): ${allSkipped.join(', ')}`,
68
+ )
69
+ }
70
+
71
+ await writeGeneratedFiles(files, outDir, { dryRun, cleanOutDir })
72
+
73
+ return files
74
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * TypeScript-target pipeline runner. Invoked by the dispatcher in
3
+ * `src/codegen/pipeline.ts` when `target` is `'ts'` (or unset). Emits the
4
+ * scope `.ts` files, `_errors.ts`, `index.ts`, and — under self-contained
5
+ * mode — `_types.ts` + `_client.ts`, then commits via the shared
6
+ * `writeGeneratedFiles` tail.
7
+ */
8
+ import { join } from 'node:path'
9
+ import type { GeneratedFile } from '../_shared/write-files.js'
10
+ import { writeGeneratedFiles } from '../_shared/write-files.js'
11
+ import type { TargetRunInput } from '../_shared/target-run.js'
12
+ import { emitScopeFile } from '../../emit-scope.js'
13
+ import { emitIndexFile } from '../../emit-index.js'
14
+ import { emitErrorsFile } from '../../emit-errors.js'
15
+ import { emitClientTypesFile } from '../../emit-client-types.js'
16
+ import { emitClientRuntimeFile } from '../../emit-client-runtime.js'
17
+
18
+ export type TsRunInput = TargetRunInput
19
+
20
+ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]> {
21
+ const {
22
+ envelope,
23
+ outDir,
24
+ hash,
25
+ groups,
26
+ serviceName,
27
+ ajsc: ajscOpts,
28
+ clientImportPath,
29
+ dryRun = false,
30
+ namespaceTypes = false,
31
+ selfContained = false,
32
+ cleanOutDir = false,
33
+ } = input
34
+
35
+ const hashComment = `// Source hash: ${hash}`
36
+
37
+ // Error keys that will be emitted in `_errors.ts` — only those with a schema.
38
+ // Scope emit uses this to filter `route.errors` so generated code never
39
+ // references an undefined error type.
40
+ const errorKeys = new Set(
41
+ envelope.errors.filter((e) => e.schema != null).map((e) => e.name),
42
+ )
43
+
44
+ if (selfContained) {
45
+ for (const group of groups) {
46
+ if (group.scopeKey === '_types' || group.scopeKey === '_client') {
47
+ throw new Error(
48
+ `[ts-procedures-codegen] Scope "${group.scopeKey}" conflicts with self-contained mode reserved filename "${group.scopeKey}.ts". Rename the scope to avoid collision.`,
49
+ )
50
+ }
51
+ }
52
+ }
53
+
54
+ const files: GeneratedFile[] = []
55
+
56
+ for (const group of groups) {
57
+ const rawCode = await emitScopeFile(group, {
58
+ ajsc: ajscOpts,
59
+ clientImportPath,
60
+ namespaceTypes,
61
+ serviceName,
62
+ errorKeys: errorKeys.size > 0 ? errorKeys : undefined,
63
+ })
64
+ const lines = rawCode.split('\n')
65
+ lines.splice(1, 0, hashComment)
66
+ const code = lines.join('\n')
67
+ files.push({ path: join(outDir, `${group.scopeKey}.ts`), code })
68
+ }
69
+
70
+ const errorsCode = await emitErrorsFile(envelope.errors, {
71
+ ajsc: ajscOpts,
72
+ clientImportPath,
73
+ namespaceTypes,
74
+ serviceName,
75
+ })
76
+ const hasErrors = errorsCode != null
77
+ if (errorsCode != null) {
78
+ const errorsLines = errorsCode.split('\n')
79
+ errorsLines.splice(1, 0, hashComment)
80
+ const errorsWithHash = errorsLines.join('\n')
81
+ files.push({ path: join(outDir, '_errors.ts'), code: errorsWithHash })
82
+ }
83
+
84
+ // In self-contained mode types come from `./_types` but the runtime
85
+ // (`createClient`) lives in `./_client`. In regular mode both share the
86
+ // single `clientImportPath` (e.g. `ts-procedures/client`).
87
+ const clientRuntimeImportPath = selfContained ? './_client' : clientImportPath
88
+ const rawIndexCode = emitIndexFile(groups, {
89
+ clientImportPath,
90
+ clientRuntimeImportPath,
91
+ hasErrors,
92
+ namespaceTypes,
93
+ serviceName,
94
+ })
95
+ const indexLines = rawIndexCode.split('\n')
96
+ indexLines.splice(1, 0, hashComment)
97
+ const indexCode = indexLines.join('\n')
98
+ files.push({ path: join(outDir, 'index.ts'), code: indexCode })
99
+
100
+ if (selfContained) {
101
+ const rawTypesCode = await emitClientTypesFile()
102
+ const typesLines = rawTypesCode.split('\n')
103
+ typesLines.splice(1, 0, hashComment)
104
+ const typesCode = typesLines.join('\n')
105
+ files.push({ path: join(outDir, '_types.ts'), code: typesCode })
106
+
107
+ const rawClientCode = await emitClientRuntimeFile()
108
+ const clientLines = rawClientCode.split('\n')
109
+ clientLines.splice(1, 0, hashComment)
110
+ const clientCode = clientLines.join('\n')
111
+ files.push({ path: join(outDir, '_client.ts'), code: clientCode })
112
+ }
113
+
114
+ await writeGeneratedFiles(files, outDir, { dryRun, cleanOutDir })
115
+
116
+ return files
117
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it, afterEach } from 'vitest'
2
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { assertGoldenOrUpdate } from './golden.js'
6
+
7
+ let tmpDir: string | undefined
8
+
9
+ afterEach(() => {
10
+ if (tmpDir != null) {
11
+ rmSync(tmpDir, { recursive: true, force: true })
12
+ tmpDir = undefined
13
+ }
14
+ })
15
+
16
+ function makeTmp(): string {
17
+ tmpDir = mkdtempSync(join(tmpdir(), 'tsp-golden-'))
18
+ return tmpDir
19
+ }
20
+
21
+ describe('assertGoldenOrUpdate', () => {
22
+ it('asserts byte-equality against golden when produced matches (with hash splice)', async () => {
23
+ const dir = makeTmp()
24
+ const goldenPath = join(dir, 'expected.txt')
25
+ writeFileSync(goldenPath, 'hello\n// Source hash: <PLACEHOLDER>\nworld\n', 'utf-8')
26
+
27
+ const produced = 'hello\n// Source hash: abc123def456\nworld\n'
28
+ await expect(assertGoldenOrUpdate(produced, goldenPath)).resolves.toBeUndefined()
29
+ })
30
+
31
+ it('throws when produced does not match golden', async () => {
32
+ const dir = makeTmp()
33
+ const goldenPath = join(dir, 'expected.txt')
34
+ writeFileSync(goldenPath, 'expected content\n', 'utf-8')
35
+
36
+ const produced = 'different content\n'
37
+ await expect(assertGoldenOrUpdate(produced, goldenPath)).rejects.toThrow()
38
+ })
39
+
40
+ it('writes a portable golden when UPDATE_GOLDENS=1', async () => {
41
+ const original = process.env.UPDATE_GOLDENS
42
+ process.env.UPDATE_GOLDENS = '1'
43
+ try {
44
+ const dir = makeTmp()
45
+ const goldenPath = join(dir, 'expected.txt')
46
+ const produced = 'hello\n// Source hash: deadbeef1234\nworld\n'
47
+ await assertGoldenOrUpdate(produced, goldenPath)
48
+ const written = readFileSync(goldenPath, 'utf-8')
49
+ expect(written).toBe('hello\n// Source hash: <PLACEHOLDER>\nworld\n')
50
+ } finally {
51
+ if (original === undefined) delete process.env.UPDATE_GOLDENS
52
+ else process.env.UPDATE_GOLDENS = original
53
+ }
54
+ })
55
+
56
+ it('handles produced output without a source-hash line', async () => {
57
+ const dir = makeTmp()
58
+ const goldenPath = join(dir, 'expected.txt')
59
+ writeFileSync(goldenPath, 'plain content\n', 'utf-8')
60
+
61
+ // No source hash; splice is a no-op on both sides.
62
+ await expect(assertGoldenOrUpdate('plain content\n', goldenPath)).resolves.toBeUndefined()
63
+ })
64
+
65
+ it('regenerate mode handles produced output without a source-hash line', async () => {
66
+ const original = process.env.UPDATE_GOLDENS
67
+ process.env.UPDATE_GOLDENS = '1'
68
+ try {
69
+ const dir = makeTmp()
70
+ const goldenPath = join(dir, 'expected.txt')
71
+ const produced = 'no hash line here\n'
72
+ await assertGoldenOrUpdate(produced, goldenPath)
73
+ const written = readFileSync(goldenPath, 'utf-8')
74
+ expect(written).toBe('no hash line here\n')
75
+ } finally {
76
+ if (original === undefined) delete process.env.UPDATE_GOLDENS
77
+ else process.env.UPDATE_GOLDENS = original
78
+ }
79
+ })
80
+ })
@@ -0,0 +1,34 @@
1
+ import { expect } from 'vitest'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+
4
+ /**
5
+ * Compares produced source against a golden file, with optional regeneration.
6
+ *
7
+ * - Normal mode: reads `goldenPath`, splices the produced source-hash line into
8
+ * the golden's `<PLACEHOLDER>` slot, and asserts byte-equality.
9
+ * - Regen mode (`UPDATE_GOLDENS=1`): replaces the produced source-hash line with
10
+ * `<PLACEHOLDER>` and writes the result to `goldenPath`. The next normal run
11
+ * will assert against the new golden.
12
+ *
13
+ * The source-hash placeholder lets us check generated content into source
14
+ * control without coupling the golden to a specific envelope hash. Codegen
15
+ * outputs that don't include a `// Source hash:` line work too — the
16
+ * splice/replace is a no-op when no match is found.
17
+ */
18
+ export async function assertGoldenOrUpdate(produced: string, goldenPath: string): Promise<void> {
19
+ if (process.env.UPDATE_GOLDENS === '1') {
20
+ const goldenContent = produced.replace(
21
+ /^\/\/ Source hash: [a-f0-9]+$/m,
22
+ '// Source hash: <PLACEHOLDER>',
23
+ )
24
+ await writeFile(goldenPath, goldenContent, 'utf-8')
25
+ // eslint-disable-next-line no-console
26
+ console.log(`[golden-test] Wrote golden: ${goldenPath}`)
27
+ return
28
+ }
29
+
30
+ const goldenTemplate = await readFile(goldenPath, 'utf8')
31
+ const sourceHashLine = produced.split('\n').find((l) => l.startsWith('// Source hash:')) ?? ''
32
+ const goldenWithHash = goldenTemplate.replace('// Source hash: <PLACEHOLDER>', sourceHashLine)
33
+ expect(produced).toBe(goldenWithHash)
34
+ }