ts-procedures 6.1.0 → 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 (163) hide show
  1. package/agent_config/bin/setup.mjs +2 -2
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -0
  3. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +1 -1
  4. package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +1 -1
  5. package/agent_config/claude-code/skills/ts-procedures-swift/SKILL.md +119 -0
  6. package/agent_config/copilot/copilot-instructions.md +1 -0
  7. package/agent_config/cursor/cursorrules +1 -0
  8. package/agent_config/lib/install-claude.mjs +1 -1
  9. package/build/codegen/bin/cli.d.ts +17 -3
  10. package/build/codegen/bin/cli.js +79 -3
  11. package/build/codegen/bin/cli.js.map +1 -1
  12. package/build/codegen/index.d.ts +18 -1
  13. package/build/codegen/index.js +3 -0
  14. package/build/codegen/index.js.map +1 -1
  15. package/build/codegen/pipeline.d.ts +16 -5
  16. package/build/codegen/pipeline.js +44 -143
  17. package/build/codegen/pipeline.js.map +1 -1
  18. package/build/codegen/targets/_shared/error-schemas.d.ts +10 -0
  19. package/build/codegen/targets/_shared/error-schemas.js +17 -0
  20. package/build/codegen/targets/_shared/error-schemas.js.map +1 -0
  21. package/build/codegen/targets/_shared/error-schemas.test.d.ts +1 -0
  22. package/build/codegen/targets/_shared/error-schemas.test.js +38 -0
  23. package/build/codegen/targets/_shared/error-schemas.test.js.map +1 -0
  24. package/build/codegen/targets/_shared/indent.d.ts +6 -0
  25. package/build/codegen/targets/_shared/indent.js +13 -0
  26. package/build/codegen/targets/_shared/indent.js.map +1 -0
  27. package/build/codegen/targets/_shared/indent.test.d.ts +1 -0
  28. package/build/codegen/targets/_shared/indent.test.js +21 -0
  29. package/build/codegen/targets/_shared/indent.test.js.map +1 -0
  30. package/build/codegen/targets/_shared/pascal-case.d.ts +6 -0
  31. package/build/codegen/targets/_shared/pascal-case.js +13 -0
  32. package/build/codegen/targets/_shared/pascal-case.js.map +1 -0
  33. package/build/codegen/targets/_shared/pascal-case.test.d.ts +1 -0
  34. package/build/codegen/targets/_shared/pascal-case.test.js +25 -0
  35. package/build/codegen/targets/_shared/pascal-case.test.js.map +1 -0
  36. package/build/codegen/targets/_shared/path-utils.d.ts +12 -0
  37. package/build/codegen/targets/_shared/path-utils.js +20 -0
  38. package/build/codegen/targets/_shared/path-utils.js.map +1 -0
  39. package/build/codegen/targets/_shared/path-utils.test.d.ts +1 -0
  40. package/build/codegen/targets/_shared/path-utils.test.js +42 -0
  41. package/build/codegen/targets/_shared/path-utils.test.js.map +1 -0
  42. package/build/codegen/targets/_shared/pick-defined.d.ts +11 -0
  43. package/build/codegen/targets/_shared/pick-defined.js +21 -0
  44. package/build/codegen/targets/_shared/pick-defined.js.map +1 -0
  45. package/build/codegen/targets/_shared/pick-defined.test.d.ts +1 -0
  46. package/build/codegen/targets/_shared/pick-defined.test.js +25 -0
  47. package/build/codegen/targets/_shared/pick-defined.test.js.map +1 -0
  48. package/build/codegen/targets/_shared/route-slots.d.ts +17 -0
  49. package/build/codegen/targets/_shared/route-slots.js +17 -0
  50. package/build/codegen/targets/_shared/route-slots.js.map +1 -0
  51. package/build/codegen/targets/_shared/route-slots.test.d.ts +1 -0
  52. package/build/codegen/targets/_shared/route-slots.test.js +43 -0
  53. package/build/codegen/targets/_shared/route-slots.test.js.map +1 -0
  54. package/build/codegen/targets/_shared/target-run.d.ts +27 -0
  55. package/build/codegen/targets/_shared/target-run.js +2 -0
  56. package/build/codegen/targets/_shared/target-run.js.map +1 -0
  57. package/build/codegen/targets/_shared/write-files.d.ts +24 -0
  58. package/build/codegen/targets/_shared/write-files.js +35 -0
  59. package/build/codegen/targets/_shared/write-files.js.map +1 -0
  60. package/build/codegen/targets/_shared/write-files.test.d.ts +1 -0
  61. package/build/codegen/targets/_shared/write-files.test.js +79 -0
  62. package/build/codegen/targets/_shared/write-files.test.js.map +1 -0
  63. package/build/codegen/targets/kotlin/e2e-compile.test.js +1 -1
  64. package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -1
  65. package/build/codegen/targets/kotlin/emit-route-kotlin.js +5 -22
  66. package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -1
  67. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +4 -8
  68. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
  69. package/build/codegen/targets/kotlin/format-kotlin.d.ts +0 -12
  70. package/build/codegen/targets/kotlin/format-kotlin.js +0 -27
  71. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
  72. package/build/codegen/targets/kotlin/format-kotlin.test.js +1 -34
  73. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
  74. package/build/codegen/targets/kotlin/integration.test.js +1 -1
  75. package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
  76. package/build/codegen/targets/kotlin/run.d.ts +11 -0
  77. package/build/codegen/targets/kotlin/run.js +51 -0
  78. package/build/codegen/targets/kotlin/run.js.map +1 -0
  79. package/build/codegen/targets/swift/access-level.test.d.ts +1 -0
  80. package/build/codegen/targets/swift/access-level.test.js +98 -0
  81. package/build/codegen/targets/swift/access-level.test.js.map +1 -0
  82. package/build/codegen/targets/swift/ajsc-adapter.d.ts +27 -0
  83. package/build/codegen/targets/swift/ajsc-adapter.js +38 -0
  84. package/build/codegen/targets/swift/ajsc-adapter.js.map +1 -0
  85. package/build/codegen/targets/swift/ajsc-adapter.test.d.ts +1 -0
  86. package/build/codegen/targets/swift/ajsc-adapter.test.js +37 -0
  87. package/build/codegen/targets/swift/ajsc-adapter.test.js.map +1 -0
  88. package/build/codegen/targets/swift/e2e-compile.test.d.ts +1 -0
  89. package/build/codegen/targets/swift/e2e-compile.test.js +57 -0
  90. package/build/codegen/targets/swift/e2e-compile.test.js.map +1 -0
  91. package/build/codegen/targets/swift/emit-route-swift.d.ts +15 -0
  92. package/build/codegen/targets/swift/emit-route-swift.js +64 -0
  93. package/build/codegen/targets/swift/emit-route-swift.js.map +1 -0
  94. package/build/codegen/targets/swift/emit-route-swift.test.d.ts +1 -0
  95. package/build/codegen/targets/swift/emit-route-swift.test.js +258 -0
  96. package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -0
  97. package/build/codegen/targets/swift/emit-scope-swift.d.ts +13 -0
  98. package/build/codegen/targets/swift/emit-scope-swift.js +36 -0
  99. package/build/codegen/targets/swift/emit-scope-swift.js.map +1 -0
  100. package/build/codegen/targets/swift/emit-scope-swift.test.d.ts +1 -0
  101. package/build/codegen/targets/swift/emit-scope-swift.test.js +136 -0
  102. package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -0
  103. package/build/codegen/targets/swift/format-swift.d.ts +2 -0
  104. package/build/codegen/targets/swift/format-swift.js +10 -0
  105. package/build/codegen/targets/swift/format-swift.js.map +1 -0
  106. package/build/codegen/targets/swift/format-swift.test.d.ts +1 -0
  107. package/build/codegen/targets/swift/format-swift.test.js +14 -0
  108. package/build/codegen/targets/swift/format-swift.test.js.map +1 -0
  109. package/build/codegen/targets/swift/integration.test.d.ts +1 -0
  110. package/build/codegen/targets/swift/integration.test.js +53 -0
  111. package/build/codegen/targets/swift/integration.test.js.map +1 -0
  112. package/build/codegen/targets/swift/run.d.ts +11 -0
  113. package/build/codegen/targets/swift/run.js +47 -0
  114. package/build/codegen/targets/swift/run.js.map +1 -0
  115. package/build/codegen/targets/ts/run.d.ts +4 -0
  116. package/build/codegen/targets/ts/run.js +86 -0
  117. package/build/codegen/targets/ts/run.js.map +1 -0
  118. package/docs/codegen-kotlin.md +1 -0
  119. package/docs/codegen-swift.md +314 -0
  120. package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +1 -1
  121. package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +1 -1
  122. package/docs/superpowers/specs/2026-04-25-swift-codegen-design.md +264 -0
  123. package/package.json +2 -2
  124. package/src/codegen/bin/cli.ts +91 -7
  125. package/src/codegen/index.ts +24 -1
  126. package/src/codegen/pipeline.ts +52 -174
  127. package/src/codegen/targets/_shared/error-schemas.test.ts +42 -0
  128. package/src/codegen/targets/_shared/error-schemas.ts +17 -0
  129. package/src/codegen/targets/_shared/indent.test.ts +25 -0
  130. package/src/codegen/targets/_shared/indent.ts +12 -0
  131. package/src/codegen/targets/_shared/pascal-case.test.ts +30 -0
  132. package/src/codegen/targets/_shared/pascal-case.ts +12 -0
  133. package/src/codegen/targets/_shared/path-utils.test.ts +51 -0
  134. package/src/codegen/targets/_shared/path-utils.ts +21 -0
  135. package/src/codegen/targets/_shared/pick-defined.test.ts +48 -0
  136. package/src/codegen/targets/_shared/pick-defined.ts +23 -0
  137. package/src/codegen/targets/_shared/route-slots.test.ts +55 -0
  138. package/src/codegen/targets/_shared/route-slots.ts +32 -0
  139. package/src/codegen/targets/_shared/target-run.ts +28 -0
  140. package/src/codegen/targets/_shared/write-files.test.ts +110 -0
  141. package/src/codegen/targets/_shared/write-files.ts +53 -0
  142. package/src/codegen/targets/kotlin/e2e-compile.test.ts +1 -1
  143. package/src/codegen/targets/kotlin/emit-route-kotlin.ts +5 -25
  144. package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +4 -9
  145. package/src/codegen/targets/kotlin/format-kotlin.test.ts +0 -44
  146. package/src/codegen/targets/kotlin/format-kotlin.ts +0 -32
  147. package/src/codegen/targets/kotlin/integration.test.ts +1 -1
  148. package/src/codegen/targets/kotlin/run.ts +78 -0
  149. package/src/codegen/targets/swift/__fixtures__/users-golden.swift +123 -0
  150. package/src/codegen/targets/swift/access-level.test.ts +108 -0
  151. package/src/codegen/targets/swift/ajsc-adapter.test.ts +47 -0
  152. package/src/codegen/targets/swift/ajsc-adapter.ts +67 -0
  153. package/src/codegen/targets/swift/e2e-compile.test.ts +66 -0
  154. package/src/codegen/targets/swift/emit-route-swift.test.ts +300 -0
  155. package/src/codegen/targets/swift/emit-route-swift.ts +90 -0
  156. package/src/codegen/targets/swift/emit-scope-swift.test.ts +164 -0
  157. package/src/codegen/targets/swift/emit-scope-swift.ts +59 -0
  158. package/src/codegen/targets/swift/format-swift.test.ts +23 -0
  159. package/src/codegen/targets/swift/format-swift.ts +9 -0
  160. package/src/codegen/targets/swift/integration.test.ts +80 -0
  161. package/src/codegen/targets/swift/run.ts +74 -0
  162. package/src/codegen/targets/ts/run.ts +117 -0
  163. /package/src/codegen/{targets/kotlin/__fixtures__ → __fixtures__}/users-envelope.json +0 -0
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { COLON_PARAM_RE, toBracePath, pathParamNames } from './path-utils.js'
3
+
4
+ describe('path-utils', () => {
5
+ describe('toBracePath', () => {
6
+ it('converts colon params to brace form', () => {
7
+ expect(toBracePath('/users/:id')).toBe('/users/{id}')
8
+ })
9
+
10
+ it('converts multiple params', () => {
11
+ expect(toBracePath('/orgs/:orgId/users/:userId')).toBe('/orgs/{orgId}/users/{userId}')
12
+ })
13
+
14
+ it('returns the template unchanged when there are no params', () => {
15
+ expect(toBracePath('/users')).toBe('/users')
16
+ })
17
+
18
+ it('supports underscore-prefixed param names', () => {
19
+ expect(toBracePath('/x/:_id')).toBe('/x/{_id}')
20
+ })
21
+
22
+ it('does not match leading-digit identifiers', () => {
23
+ // ":1abc" is not a valid identifier; should not be rewritten.
24
+ expect(toBracePath('/x/:1abc')).toBe('/x/:1abc')
25
+ })
26
+ })
27
+
28
+ describe('pathParamNames', () => {
29
+ it('returns an empty list for templates without params', () => {
30
+ expect(pathParamNames('/users')).toEqual([])
31
+ })
32
+
33
+ it('returns the single param name', () => {
34
+ expect(pathParamNames('/users/:id')).toEqual(['id'])
35
+ })
36
+
37
+ it('preserves the order of multiple params', () => {
38
+ expect(pathParamNames('/orgs/:orgId/users/:userId')).toEqual(['orgId', 'userId'])
39
+ })
40
+
41
+ it('matches identifiers with digits and underscores', () => {
42
+ expect(pathParamNames('/x/:user_id1')).toEqual(['user_id1'])
43
+ })
44
+ })
45
+
46
+ describe('COLON_PARAM_RE', () => {
47
+ it('is a global regex (so matchAll works)', () => {
48
+ expect(COLON_PARAM_RE.global).toBe(true)
49
+ })
50
+ })
51
+ })
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Language-agnostic path-template utilities used by every codegen target that
3
+ * emits HTTP routes. Routes carry colon-prefixed path templates (e.g.
4
+ * `/users/:id`); targets typically need both the brace form (`/users/{id}`)
5
+ * for documentation/template constants and the parameter-name list for
6
+ * generating typed path-builder functions.
7
+ */
8
+
9
+ export const COLON_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g
10
+
11
+ /** Converts colon-prefixed path params (`:foo`) to brace form (`{foo}`). */
12
+ export function toBracePath(template: string): string {
13
+ return template.replace(COLON_PARAM_RE, '{$1}')
14
+ }
15
+
16
+ /** Extracts the parameter names from a colon-prefixed path template. */
17
+ export function pathParamNames(template: string): string[] {
18
+ const names: string[] = []
19
+ for (const match of template.matchAll(COLON_PARAM_RE)) names.push(match[1]!)
20
+ return names
21
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pickDefined } from './pick-defined.js'
3
+
4
+ describe('pickDefined', () => {
5
+ it('returns only the keys whose values are not undefined', () => {
6
+ expect(
7
+ pickDefined(
8
+ { a: 1, b: undefined, c: 3 } as { a?: number; b?: number; c?: number },
9
+ ['a', 'b', 'c'],
10
+ ),
11
+ ).toEqual({ a: 1, c: 3 })
12
+ })
13
+
14
+ it('preserves the literal `false` value for boolean opts', () => {
15
+ expect(
16
+ pickDefined(
17
+ { depluralize: false, x: undefined } as { depluralize?: boolean; x?: number },
18
+ ['depluralize', 'x'],
19
+ ),
20
+ ).toEqual({ depluralize: false })
21
+ })
22
+
23
+ it('preserves the literal `false` for `string | false` opts (e.g. arrayItemNaming)', () => {
24
+ type Opts = { arrayItemNaming?: string | false }
25
+ const result = pickDefined<Opts, keyof Opts>({ arrayItemNaming: false }, ['arrayItemNaming'])
26
+ expect(result).toEqual({ arrayItemNaming: false })
27
+ expect('arrayItemNaming' in result).toBe(true)
28
+ })
29
+
30
+ it('omits keys not in the keys list even if they are defined on src', () => {
31
+ expect(
32
+ pickDefined({ a: 1, b: 2 } as { a?: number; b?: number }, ['a']),
33
+ ).toEqual({ a: 1 })
34
+ })
35
+
36
+ it('returns an empty object when all keys are undefined', () => {
37
+ expect(
38
+ pickDefined(
39
+ { a: undefined, b: undefined } as { a?: number; b?: number },
40
+ ['a', 'b'],
41
+ ),
42
+ ).toEqual({})
43
+ })
44
+
45
+ it('returns an empty object when keys is empty', () => {
46
+ expect(pickDefined({ a: 1 }, [] as never[])).toEqual({})
47
+ })
48
+ })
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Returns a new object containing only the keys of `src` whose values are
3
+ * not `undefined`. Useful for building option objects where unset keys must
4
+ * be ABSENT (not `undefined`-valued), so they don't shadow downstream
5
+ * defaults — e.g., when forwarding into a target's `Emit*Options`.
6
+ *
7
+ * @example
8
+ * pickDefined({ a: 1, b: undefined, c: false }, ['a', 'b', 'c'])
9
+ * // → { a: 1, c: false }
10
+ */
11
+ export function pickDefined<T extends object, K extends keyof T>(
12
+ src: T,
13
+ keys: readonly K[],
14
+ ): Partial<Pick<T, K>> {
15
+ const out: Partial<Pick<T, K>> = {}
16
+ for (const key of keys) {
17
+ const value = src[key]
18
+ if (value !== undefined) {
19
+ out[key] = value
20
+ }
21
+ }
22
+ return out
23
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { extractRouteSlots } from './route-slots.js'
3
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
4
+
5
+ const r = (schema: Record<string, unknown> | undefined): AnyHttpRouteDoc =>
6
+ ({ name: 'X', kind: 'api', method: 'GET', fullPath: '/x', schema } as unknown as AnyHttpRouteDoc)
7
+
8
+ describe('extractRouteSlots', () => {
9
+ it('returns no slots when schema is undefined', () => {
10
+ expect(extractRouteSlots(r(undefined))).toEqual([])
11
+ })
12
+
13
+ it('returns no slots when schema is empty', () => {
14
+ expect(extractRouteSlots(r({}))).toEqual([])
15
+ })
16
+
17
+ it('returns the deterministic slot order: PathParams, Query, Body, Response', () => {
18
+ const slots = extractRouteSlots(
19
+ r({
20
+ input: {
21
+ body: { type: 'object', x: 'body' },
22
+ query: { type: 'object', x: 'query' },
23
+ pathParams: { type: 'object', x: 'path' },
24
+ },
25
+ returnType: { type: 'object', x: 'response' },
26
+ }),
27
+ )
28
+ expect(slots.map((s) => s.rootName)).toEqual(['PathParams', 'Query', 'Body', 'Response'])
29
+ })
30
+
31
+ it('omits slots whose source is null or undefined', () => {
32
+ const slots = extractRouteSlots(
33
+ r({
34
+ input: { pathParams: { type: 'object' }, query: null, body: undefined },
35
+ // returnType missing
36
+ }),
37
+ )
38
+ expect(slots.map((s) => s.rootName)).toEqual(['PathParams'])
39
+ })
40
+
41
+ it('attaches the source schema verbatim to each slot', () => {
42
+ const path = { type: 'object', tag: 'p' }
43
+ const ret = { type: 'object', tag: 'r' }
44
+ const slots = extractRouteSlots(r({ input: { pathParams: path }, returnType: ret }))
45
+ expect(slots).toEqual([
46
+ { rootName: 'PathParams', source: path },
47
+ { rootName: 'Response', source: ret },
48
+ ])
49
+ })
50
+
51
+ it('returns no slots for stream/no-schema routes', () => {
52
+ const stream = { name: 'S', kind: 'stream', method: 'GET', path: '/s' } as unknown as AnyHttpRouteDoc
53
+ expect(extractRouteSlots(stream)).toEqual([])
54
+ })
55
+ })
@@ -0,0 +1,32 @@
1
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
2
+
3
+ /**
4
+ * A "slot" is one of the deterministic schema sources a route exposes:
5
+ * `pathParams`, `query`, `body` (under `schema.input`) and the response
6
+ * (`schema.returnType`). Targets emit one type per non-null slot.
7
+ */
8
+ export interface RouteSlot {
9
+ /** Stable identifier used as the emitted type's `rootTypeName`. */
10
+ rootName: string
11
+ /** The raw JSON Schema source for this slot, or `null`/`undefined` when absent. */
12
+ source: unknown
13
+ }
14
+
15
+ /**
16
+ * Returns the deterministic ordered slot list for a route, filtered to slots
17
+ * with non-null sources. Order is fixed at the module level for stable output.
18
+ */
19
+ export function extractRouteSlots(route: AnyHttpRouteDoc): RouteSlot[] {
20
+ const schema = (route as { schema?: Record<string, unknown> }).schema ?? {}
21
+ const input = (schema.input ?? {}) as Record<string, unknown>
22
+
23
+ // Order is load-bearing: targets emit slots in this sequence.
24
+ const slots: RouteSlot[] = [
25
+ { rootName: 'PathParams', source: input.pathParams },
26
+ { rootName: 'Query', source: input.query },
27
+ { rootName: 'Body', source: input.body },
28
+ { rootName: 'Response', source: schema.returnType },
29
+ ]
30
+
31
+ return slots.filter((s) => s.source != null)
32
+ }
@@ -0,0 +1,28 @@
1
+ import type { DocEnvelope } from '../../../implementations/types.js'
2
+ import type { ScopeGroup } from '../../group-routes.js'
3
+ import type { AjscOptions } from '../../emit-types.js'
4
+
5
+ /**
6
+ * Inputs the dispatcher prepares once and forwards to every per-target
7
+ * `run*Pipeline` module. Each target module reads only the fields it needs.
8
+ *
9
+ * The fields here are deliberately narrow: target-specific knobs (e.g.
10
+ * `kotlinPackage`, `swiftSerializer`) are passed as additional options on
11
+ * each per-target input shape, not on this base.
12
+ */
13
+ export interface TargetRunInput {
14
+ envelope: DocEnvelope
15
+ outDir: string
16
+ /** MD5 hash of the envelope JSON, prefix-stamped into every emitted file. */
17
+ hash: string
18
+ /** Pre-grouped routes (one entry per scope key). */
19
+ groups: ScopeGroup[]
20
+ /** Validated service name (defaults to `Api` upstream). */
21
+ serviceName: string
22
+ ajsc?: AjscOptions
23
+ clientImportPath?: string
24
+ dryRun?: boolean
25
+ namespaceTypes?: boolean
26
+ selfContained?: boolean
27
+ cleanOutDir?: boolean
28
+ }
@@ -0,0 +1,110 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { mkdir, readFile, rm, writeFile, stat, mkdtemp } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { writeGeneratedFiles } from './write-files.js'
6
+
7
+ describe('writeGeneratedFiles', () => {
8
+ describe('dryRun mode', () => {
9
+ let logSpy: ReturnType<typeof vi.spyOn>
10
+
11
+ beforeEach(() => {
12
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
13
+ })
14
+
15
+ afterEach(() => {
16
+ logSpy.mockRestore()
17
+ })
18
+
19
+ it('logs each file with its byte length and does not touch the filesystem', async () => {
20
+ const dir = '/nonexistent/should-not-be-created'
21
+ await writeGeneratedFiles(
22
+ [
23
+ { path: `${dir}/a.txt`, code: 'hello' },
24
+ { path: `${dir}/b.txt`, code: 'hi' },
25
+ ],
26
+ dir,
27
+ { dryRun: true },
28
+ )
29
+ expect(logSpy).toHaveBeenCalledWith(`[dry-run] Would write: ${dir}/a.txt (5 bytes)`)
30
+ expect(logSpy).toHaveBeenCalledWith(`[dry-run] Would write: ${dir}/b.txt (2 bytes)`)
31
+ // Sanity: the dir was not created.
32
+ await expect(stat(dir)).rejects.toBeInstanceOf(Error)
33
+ })
34
+
35
+ it('logs a clean-outDir notice when cleanOutDir is true', async () => {
36
+ await writeGeneratedFiles([], '/out', { dryRun: true, cleanOutDir: true })
37
+ expect(logSpy).toHaveBeenCalledWith('[dry-run] Would clean outDir: /out')
38
+ })
39
+
40
+ it('does not log clean-outDir when cleanOutDir is false', async () => {
41
+ await writeGeneratedFiles([{ path: '/out/x', code: 'x' }], '/out', { dryRun: true })
42
+ const messages = logSpy.mock.calls.map((c: unknown[]) => c[0])
43
+ expect(messages.find((m: unknown) => String(m).includes('Would clean'))).toBeUndefined()
44
+ })
45
+
46
+ it('counts bytes as utf-8', async () => {
47
+ // "é" is 2 bytes in utf-8.
48
+ await writeGeneratedFiles([{ path: '/x/e.txt', code: 'é' }], '/x', { dryRun: true })
49
+ expect(logSpy).toHaveBeenCalledWith('[dry-run] Would write: /x/e.txt (2 bytes)')
50
+ })
51
+ })
52
+
53
+ describe('real write mode', () => {
54
+ let outDir: string
55
+
56
+ beforeEach(async () => {
57
+ outDir = await mkdtemp(join(tmpdir(), 'write-files-test-'))
58
+ })
59
+
60
+ afterEach(async () => {
61
+ await rm(outDir, { recursive: true, force: true })
62
+ })
63
+
64
+ it('writes each file with utf-8 contents', async () => {
65
+ await writeGeneratedFiles(
66
+ [
67
+ { path: join(outDir, 'a.txt'), code: 'alpha' },
68
+ { path: join(outDir, 'b.txt'), code: 'beta' },
69
+ ],
70
+ outDir,
71
+ )
72
+ expect(await readFile(join(outDir, 'a.txt'), 'utf-8')).toBe('alpha')
73
+ expect(await readFile(join(outDir, 'b.txt'), 'utf-8')).toBe('beta')
74
+ })
75
+
76
+ it('creates outDir recursively if missing', async () => {
77
+ const nested = join(outDir, 'a', 'b', 'c')
78
+ await writeGeneratedFiles([{ path: join(nested, 'x.txt'), code: 'x' }], nested)
79
+ expect(await readFile(join(nested, 'x.txt'), 'utf-8')).toBe('x')
80
+ })
81
+
82
+ it('removes outDir when cleanOutDir is true before writing', async () => {
83
+ // Pre-populate a stale file.
84
+ await mkdir(outDir, { recursive: true })
85
+ await writeFile(join(outDir, 'stale.txt'), 'stale', 'utf-8')
86
+
87
+ await writeGeneratedFiles(
88
+ [{ path: join(outDir, 'fresh.txt'), code: 'fresh' }],
89
+ outDir,
90
+ { cleanOutDir: true },
91
+ )
92
+
93
+ await expect(readFile(join(outDir, 'stale.txt'), 'utf-8')).rejects.toBeInstanceOf(Error)
94
+ expect(await readFile(join(outDir, 'fresh.txt'), 'utf-8')).toBe('fresh')
95
+ })
96
+
97
+ it('does not remove outDir when cleanOutDir is false', async () => {
98
+ await mkdir(outDir, { recursive: true })
99
+ await writeFile(join(outDir, 'keep.txt'), 'keep', 'utf-8')
100
+
101
+ await writeGeneratedFiles(
102
+ [{ path: join(outDir, 'new.txt'), code: 'new' }],
103
+ outDir,
104
+ )
105
+
106
+ expect(await readFile(join(outDir, 'keep.txt'), 'utf-8')).toBe('keep')
107
+ expect(await readFile(join(outDir, 'new.txt'), 'utf-8')).toBe('new')
108
+ })
109
+ })
110
+ })
@@ -0,0 +1,53 @@
1
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
2
+
3
+ export interface GeneratedFile {
4
+ path: string
5
+ code: string
6
+ }
7
+
8
+ export interface WriteOptions {
9
+ /** When true, log what would happen instead of touching the filesystem. */
10
+ dryRun?: boolean
11
+ /** When true, recursively delete `outDir` before writing. */
12
+ cleanOutDir?: boolean
13
+ }
14
+
15
+ /**
16
+ * Persists the generated files to disk (or simulates the writes under
17
+ * `dryRun`). Centralises the dryRun / cleanOutDir / mkdir / writeFile
18
+ * sequence shared by every codegen target so per-target run modules don't
19
+ * duplicate it.
20
+ *
21
+ * Behaviour:
22
+ * - `dryRun: true` — logs `[dry-run] Would write: <path> (<bytes> bytes)` per
23
+ * file, plus a leading `[dry-run] Would clean outDir: <outDir>` when
24
+ * `cleanOutDir: true`. No filesystem mutation.
25
+ * - `dryRun: false` (default) — when `cleanOutDir`, recursively removes
26
+ * `outDir`; then ensures it exists; then writes each file as utf-8.
27
+ */
28
+ export async function writeGeneratedFiles(
29
+ files: readonly GeneratedFile[],
30
+ outDir: string,
31
+ opts: WriteOptions = {},
32
+ ): Promise<void> {
33
+ const { dryRun = false, cleanOutDir = false } = opts
34
+
35
+ if (dryRun) {
36
+ if (cleanOutDir) {
37
+ console.log(`[dry-run] Would clean outDir: ${outDir}`)
38
+ }
39
+ for (const f of files) {
40
+ const bytes = Buffer.byteLength(f.code, 'utf-8')
41
+ console.log(`[dry-run] Would write: ${f.path} (${bytes} bytes)`)
42
+ }
43
+ return
44
+ }
45
+
46
+ if (cleanOutDir) {
47
+ await rm(outDir, { recursive: true, force: true })
48
+ }
49
+ await mkdir(outDir, { recursive: true })
50
+ for (const f of files) {
51
+ await writeFile(f.path, f.code, 'utf-8')
52
+ }
53
+ }
@@ -47,7 +47,7 @@ describe('kotlin codegen — kotlinc compile (gated)', () => {
47
47
  async () => {
48
48
  const emitter = await resolveProductionKotlinEmitter()
49
49
  const envelope = JSON.parse(
50
- readFileSync(join(__dirname, '__fixtures__/users-envelope.json'), 'utf8'),
50
+ readFileSync(join(__dirname, '../../__fixtures__/users-envelope.json'), 'utf8'),
51
51
  )
52
52
  const files = await runPipeline({
53
53
  envelope,
@@ -1,6 +1,9 @@
1
1
  import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
2
2
  import type { KotlinEmitter, KotlinEmitOptions } from './ajsc-adapter.js'
3
- import { indent, pickDefined } from './format-kotlin.js'
3
+ import { indent } from '../_shared/indent.js'
4
+ import { pickDefined } from '../_shared/pick-defined.js'
5
+ import { toBracePath, pathParamNames } from '../_shared/path-utils.js'
6
+ import { extractRouteSlots } from '../_shared/route-slots.js'
4
7
 
5
8
  export interface EmitRouteResult {
6
9
  /** Inner body of the `object RouteName { ... }` block — already indented one level. */
@@ -16,18 +19,6 @@ export interface EmitRouteResult {
16
19
  /** Subset of KotlinEmitOptions threaded by the pipeline; per-call rootTypeName is set inside. */
17
20
  export type EmitRouteOpts = Omit<KotlinEmitOptions, 'rootTypeName' | 'inlineTypes'>
18
21
 
19
- const COLON_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g
20
-
21
- function toBracePath(template: string): string {
22
- return template.replace(COLON_PARAM_RE, '{$1}')
23
- }
24
-
25
- function pathParamNames(template: string): string[] {
26
- const names: string[] = []
27
- for (const match of template.matchAll(COLON_PARAM_RE)) names.push(match[1]!)
28
- return names
29
- }
30
-
31
22
  function buildPathFn(bracePath: string, params: string[]): string {
32
23
  if (params.length === 0) return `const val path = "${bracePath}"`
33
24
  let body = bracePath
@@ -68,19 +59,8 @@ export function emitKotlinRoute(
68
59
  ]
69
60
  const imports: string[] = []
70
61
 
71
- const schema = (route as { schema?: Record<string, unknown> }).schema ?? {}
72
- const input = (schema.input ?? {}) as Record<string, unknown>
73
-
74
62
  // Per-slot emission. Order is fixed for deterministic output.
75
- const slots: Array<{ rootName: string; source: unknown }> = [
76
- { rootName: 'PathParams', source: input.pathParams },
77
- { rootName: 'Query', source: input.query },
78
- { rootName: 'Body', source: input.body },
79
- { rootName: 'Response', source: schema.returnType },
80
- ]
81
-
82
- for (const slot of slots) {
83
- if (slot.source == null) continue
63
+ for (const slot of extractRouteSlots(route)) {
84
64
  const result = emitter.emit(slot.source as Record<string, unknown>, emitOptsFor(slot.rootName, routeOpts))
85
65
  lines.push('')
86
66
  lines.push(result.code)
@@ -1,7 +1,10 @@
1
1
  import type { ScopeGroup } from '../../group-routes.js'
2
2
  import type { KotlinEmitter } from './ajsc-adapter.js'
3
3
  import { emitKotlinRoute, type EmitRouteOpts } from './emit-route-kotlin.js'
4
- import { kotlinPackageDecl, kotlinSourceHashHeader, kotlinImports, indent, pickDefined } from './format-kotlin.js'
4
+ import { kotlinPackageDecl, kotlinSourceHashHeader, kotlinImports } from './format-kotlin.js'
5
+ import { indent } from '../_shared/indent.js'
6
+ import { pickDefined } from '../_shared/pick-defined.js'
7
+ import { pascalCase } from '../_shared/pascal-case.js'
5
8
 
6
9
  export interface EmitScopeOptions extends EmitRouteOpts {
7
10
  kotlinPackage: string
@@ -15,14 +18,6 @@ export interface EmittedKotlinFile {
15
18
  skippedStreams: string[]
16
19
  }
17
20
 
18
- function pascalCase(scope: string): string {
19
- return scope
20
- .split('-')
21
- .filter((p) => p.length > 0)
22
- .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
23
- .join('')
24
- }
25
-
26
21
  export function emitKotlinScope(
27
22
  group: ScopeGroup,
28
23
  opts: EmitScopeOptions,
@@ -3,8 +3,6 @@ import {
3
3
  kotlinPackageDecl,
4
4
  kotlinSourceHashHeader,
5
5
  kotlinImports,
6
- indent,
7
- pickDefined,
8
6
  } from './format-kotlin.js'
9
7
 
10
8
  describe('format-kotlin', () => {
@@ -25,46 +23,4 @@ describe('format-kotlin', () => {
25
23
  it('returns empty string when no imports', () => {
26
24
  expect(kotlinImports([])).toBe('')
27
25
  })
28
-
29
- it('indents every line by 4 spaces per level', () => {
30
- expect(indent('a\nb', 1)).toBe(' a\n b')
31
- expect(indent('a', 2)).toBe(' a')
32
- })
33
-
34
- it('preserves blank lines without trailing whitespace when indenting', () => {
35
- expect(indent('a\n\nb', 1)).toBe(' a\n\n b')
36
- })
37
- })
38
-
39
- describe('pickDefined', () => {
40
- it('returns only the keys whose values are not undefined', () => {
41
- expect(pickDefined({ a: 1, b: undefined, c: 3 } as { a?: number; b?: number; c?: number }, ['a', 'b', 'c']))
42
- .toEqual({ a: 1, c: 3 })
43
- })
44
-
45
- it('preserves the literal `false` value for boolean opts', () => {
46
- expect(pickDefined({ depluralize: false, x: undefined } as { depluralize?: boolean; x?: number }, ['depluralize', 'x']))
47
- .toEqual({ depluralize: false })
48
- })
49
-
50
- it('preserves the literal `false` for `string | false` opts (e.g. arrayItemNaming)', () => {
51
- type Opts = { arrayItemNaming?: string | false }
52
- const result = pickDefined<Opts, keyof Opts>({ arrayItemNaming: false }, ['arrayItemNaming'])
53
- expect(result).toEqual({ arrayItemNaming: false })
54
- expect('arrayItemNaming' in result).toBe(true)
55
- })
56
-
57
- it('omits keys not in the keys list even if they are defined on src', () => {
58
- expect(pickDefined({ a: 1, b: 2 } as { a?: number; b?: number }, ['a']))
59
- .toEqual({ a: 1 })
60
- })
61
-
62
- it('returns an empty object when all keys are undefined', () => {
63
- expect(pickDefined({ a: undefined, b: undefined } as { a?: number; b?: number }, ['a', 'b']))
64
- .toEqual({})
65
- })
66
-
67
- it('returns an empty object when keys is empty', () => {
68
- expect(pickDefined({ a: 1 }, [] as never[])).toEqual({})
69
- })
70
26
  })
@@ -11,35 +11,3 @@ export function kotlinImports(imports: string[]): string {
11
11
  const unique = Array.from(new Set(imports)).sort()
12
12
  return unique.map((i) => `import ${i}`).join('\n')
13
13
  }
14
-
15
- export function indent(text: string, level: number): string {
16
- const prefix = ' '.repeat(level)
17
- return text
18
- .split('\n')
19
- .map((line) => (line.length === 0 ? line : `${prefix}${line}`))
20
- .join('\n')
21
- }
22
-
23
- /**
24
- * Returns a new object containing only the keys of `src` whose values are
25
- * not `undefined`. Useful for building option objects where unset keys must
26
- * be ABSENT (not `undefined`-valued), so they don't shadow downstream
27
- * defaults — e.g., when forwarding into `KotlinEmitOptions`.
28
- *
29
- * @example
30
- * pickDefined({ a: 1, b: undefined, c: false }, ['a', 'b', 'c'])
31
- * // → { a: 1, c: false }
32
- */
33
- export function pickDefined<T extends object, K extends keyof T>(
34
- src: T,
35
- keys: readonly K[],
36
- ): Partial<Pick<T, K>> {
37
- const out: Partial<Pick<T, K>> = {}
38
- for (const key of keys) {
39
- const value = src[key]
40
- if (value !== undefined) {
41
- out[key] = value
42
- }
43
- }
44
- return out
45
- }
@@ -18,7 +18,7 @@ const ok = (code: string, rootTypeName: string, imports: string[] = ['kotlinx.se
18
18
 
19
19
  describe('kotlin codegen — integration', () => {
20
20
  it('produces byte-identical output against the golden fixture', async () => {
21
- const envelopePath = join(__dirname, '__fixtures__/users-envelope.json')
21
+ const envelopePath = join(__dirname, '../../__fixtures__/users-envelope.json')
22
22
  const goldenPath = join(__dirname, '__fixtures__/users-golden.kt')
23
23
  const envelope = JSON.parse(await readFile(envelopePath, 'utf8'))
24
24