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.
- package/agent_config/bin/setup.mjs +2 -2
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +106 -0
- package/agent_config/claude-code/skills/ts-procedures-swift/SKILL.md +119 -0
- package/agent_config/copilot/copilot-instructions.md +3 -0
- package/agent_config/cursor/cursorrules +3 -0
- package/agent_config/lib/install-claude.mjs +1 -1
- package/build/codegen/bin/cli.d.ts +39 -0
- package/build/codegen/bin/cli.js +164 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +180 -1
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/index.d.ts +36 -0
- package/build/codegen/index.js +8 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/pipeline.d.ts +22 -4
- package/build/codegen/pipeline.js +44 -86
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/pipeline.test.js +162 -0
- package/build/codegen/pipeline.test.js.map +1 -1
- package/build/codegen/targets/_shared/error-schemas.d.ts +10 -0
- package/build/codegen/targets/_shared/error-schemas.js +17 -0
- package/build/codegen/targets/_shared/error-schemas.js.map +1 -0
- package/build/codegen/targets/_shared/error-schemas.test.d.ts +1 -0
- package/build/codegen/targets/_shared/error-schemas.test.js +38 -0
- package/build/codegen/targets/_shared/error-schemas.test.js.map +1 -0
- package/build/codegen/targets/_shared/indent.d.ts +6 -0
- package/build/codegen/targets/_shared/indent.js +13 -0
- package/build/codegen/targets/_shared/indent.js.map +1 -0
- package/build/codegen/targets/_shared/indent.test.d.ts +1 -0
- package/build/codegen/targets/_shared/indent.test.js +21 -0
- package/build/codegen/targets/_shared/indent.test.js.map +1 -0
- package/build/codegen/targets/_shared/pascal-case.d.ts +6 -0
- package/build/codegen/targets/_shared/pascal-case.js +13 -0
- package/build/codegen/targets/_shared/pascal-case.js.map +1 -0
- package/build/codegen/targets/_shared/pascal-case.test.d.ts +1 -0
- package/build/codegen/targets/_shared/pascal-case.test.js +25 -0
- package/build/codegen/targets/_shared/pascal-case.test.js.map +1 -0
- package/build/codegen/targets/_shared/path-utils.d.ts +12 -0
- package/build/codegen/targets/_shared/path-utils.js +20 -0
- package/build/codegen/targets/_shared/path-utils.js.map +1 -0
- package/build/codegen/targets/_shared/path-utils.test.d.ts +1 -0
- package/build/codegen/targets/_shared/path-utils.test.js +42 -0
- package/build/codegen/targets/_shared/path-utils.test.js.map +1 -0
- package/build/codegen/targets/_shared/pick-defined.d.ts +11 -0
- package/build/codegen/targets/_shared/pick-defined.js +21 -0
- package/build/codegen/targets/_shared/pick-defined.js.map +1 -0
- package/build/codegen/targets/_shared/pick-defined.test.d.ts +1 -0
- package/build/codegen/targets/_shared/pick-defined.test.js +25 -0
- package/build/codegen/targets/_shared/pick-defined.test.js.map +1 -0
- package/build/codegen/targets/_shared/route-slots.d.ts +17 -0
- package/build/codegen/targets/_shared/route-slots.js +17 -0
- package/build/codegen/targets/_shared/route-slots.js.map +1 -0
- package/build/codegen/targets/_shared/route-slots.test.d.ts +1 -0
- package/build/codegen/targets/_shared/route-slots.test.js +43 -0
- package/build/codegen/targets/_shared/route-slots.test.js.map +1 -0
- package/build/codegen/targets/_shared/target-run.d.ts +27 -0
- package/build/codegen/targets/_shared/target-run.js +2 -0
- package/build/codegen/targets/_shared/target-run.js.map +1 -0
- package/build/codegen/targets/_shared/write-files.d.ts +24 -0
- package/build/codegen/targets/_shared/write-files.js +35 -0
- package/build/codegen/targets/_shared/write-files.js.map +1 -0
- package/build/codegen/targets/_shared/write-files.test.d.ts +1 -0
- package/build/codegen/targets/_shared/write-files.test.js +79 -0
- package/build/codegen/targets/_shared/write-files.test.js.map +1 -0
- package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +6 -4
- package/build/codegen/targets/kotlin/ajsc-adapter.js +12 -7
- package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -1
- package/build/codegen/targets/kotlin/ajsc-adapter.test.js +20 -2
- package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -1
- package/build/codegen/targets/kotlin/e2e-compile.test.js +41 -9
- package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +6 -2
- package/build/codegen/targets/kotlin/emit-route-kotlin.js +18 -28
- package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +120 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +4 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js +12 -11
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +39 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.d.ts +0 -1
- package/build/codegen/targets/kotlin/format-kotlin.js +0 -7
- package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js +1 -8
- package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/integration.test.js +27 -10
- package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js +50 -0
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js.map +1 -0
- package/build/codegen/targets/kotlin/run.d.ts +11 -0
- package/build/codegen/targets/kotlin/run.js +51 -0
- package/build/codegen/targets/kotlin/run.js.map +1 -0
- package/build/codegen/targets/swift/access-level.test.d.ts +1 -0
- package/build/codegen/targets/swift/access-level.test.js +98 -0
- package/build/codegen/targets/swift/access-level.test.js.map +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.d.ts +27 -0
- package/build/codegen/targets/swift/ajsc-adapter.js +38 -0
- package/build/codegen/targets/swift/ajsc-adapter.js.map +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.d.ts +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.js +37 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.js.map +1 -0
- package/build/codegen/targets/swift/e2e-compile.test.d.ts +1 -0
- package/build/codegen/targets/swift/e2e-compile.test.js +57 -0
- package/build/codegen/targets/swift/e2e-compile.test.js.map +1 -0
- package/build/codegen/targets/swift/emit-route-swift.d.ts +15 -0
- package/build/codegen/targets/swift/emit-route-swift.js +64 -0
- package/build/codegen/targets/swift/emit-route-swift.js.map +1 -0
- package/build/codegen/targets/swift/emit-route-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/emit-route-swift.test.js +258 -0
- package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.d.ts +13 -0
- package/build/codegen/targets/swift/emit-scope-swift.js +36 -0
- package/build/codegen/targets/swift/emit-scope-swift.js.map +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.js +136 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/format-swift.d.ts +2 -0
- package/build/codegen/targets/swift/format-swift.js +10 -0
- package/build/codegen/targets/swift/format-swift.js.map +1 -0
- package/build/codegen/targets/swift/format-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/format-swift.test.js +14 -0
- package/build/codegen/targets/swift/format-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/integration.test.d.ts +1 -0
- package/build/codegen/targets/swift/integration.test.js +53 -0
- package/build/codegen/targets/swift/integration.test.js.map +1 -0
- package/build/codegen/targets/swift/run.d.ts +11 -0
- package/build/codegen/targets/swift/run.js +47 -0
- package/build/codegen/targets/swift/run.js.map +1 -0
- package/build/codegen/targets/ts/run.d.ts +4 -0
- package/build/codegen/targets/ts/run.js +86 -0
- package/build/codegen/targets/ts/run.js.map +1 -0
- package/build/codegen/test-helpers/golden.d.ts +15 -0
- package/build/codegen/test-helpers/golden.js +30 -0
- package/build/codegen/test-helpers/golden.js.map +1 -0
- package/build/codegen/test-helpers/golden.test.d.ts +1 -0
- package/build/codegen/test-helpers/golden.test.js +76 -0
- package/build/codegen/test-helpers/golden.test.js.map +1 -0
- package/docs/codegen-kotlin.md +176 -0
- package/docs/codegen-swift.md +314 -0
- package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
- package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +1 -1
- package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
- package/docs/superpowers/specs/2026-04-25-swift-codegen-design.md +264 -0
- package/package.json +2 -2
- package/src/codegen/__fixtures__/users-envelope.json +144 -0
- package/src/codegen/bin/cli.test.ts +200 -1
- package/src/codegen/bin/cli.ts +187 -0
- package/src/codegen/index.ts +50 -0
- package/src/codegen/pipeline.test.ts +175 -0
- package/src/codegen/pipeline.ts +58 -101
- package/src/codegen/targets/_shared/error-schemas.test.ts +42 -0
- package/src/codegen/targets/_shared/error-schemas.ts +17 -0
- package/src/codegen/targets/_shared/indent.test.ts +25 -0
- package/src/codegen/targets/_shared/indent.ts +12 -0
- package/src/codegen/targets/_shared/pascal-case.test.ts +30 -0
- package/src/codegen/targets/_shared/pascal-case.ts +12 -0
- package/src/codegen/targets/_shared/path-utils.test.ts +51 -0
- package/src/codegen/targets/_shared/path-utils.ts +21 -0
- package/src/codegen/targets/_shared/pick-defined.test.ts +48 -0
- package/src/codegen/targets/_shared/pick-defined.ts +23 -0
- package/src/codegen/targets/_shared/route-slots.test.ts +55 -0
- package/src/codegen/targets/_shared/route-slots.ts +32 -0
- package/src/codegen/targets/_shared/target-run.ts +28 -0
- package/src/codegen/targets/_shared/write-files.test.ts +110 -0
- package/src/codegen/targets/_shared/write-files.ts +53 -0
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +121 -0
- package/src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap +27 -0
- package/src/codegen/targets/kotlin/ajsc-adapter.test.ts +47 -0
- package/src/codegen/targets/kotlin/ajsc-adapter.ts +66 -0
- package/src/codegen/targets/kotlin/e2e-compile.test.ts +86 -0
- package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +239 -0
- package/src/codegen/targets/kotlin/emit-route-kotlin.ts +89 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +60 -0
- package/src/codegen/targets/kotlin/format-kotlin.test.ts +26 -0
- package/src/codegen/targets/kotlin/format-kotlin.ts +13 -0
- package/src/codegen/targets/kotlin/integration.test.ts +77 -0
- package/src/codegen/targets/kotlin/probe-unsupported-unions.test.ts +64 -0
- package/src/codegen/targets/kotlin/run.ts +78 -0
- package/src/codegen/targets/swift/__fixtures__/users-golden.swift +123 -0
- package/src/codegen/targets/swift/access-level.test.ts +108 -0
- package/src/codegen/targets/swift/ajsc-adapter.test.ts +47 -0
- package/src/codegen/targets/swift/ajsc-adapter.ts +67 -0
- package/src/codegen/targets/swift/e2e-compile.test.ts +66 -0
- package/src/codegen/targets/swift/emit-route-swift.test.ts +300 -0
- package/src/codegen/targets/swift/emit-route-swift.ts +90 -0
- package/src/codegen/targets/swift/emit-scope-swift.test.ts +164 -0
- package/src/codegen/targets/swift/emit-scope-swift.ts +59 -0
- package/src/codegen/targets/swift/format-swift.test.ts +23 -0
- package/src/codegen/targets/swift/format-swift.ts +9 -0
- package/src/codegen/targets/swift/integration.test.ts +80 -0
- package/src/codegen/targets/swift/run.ts +74 -0
- package/src/codegen/targets/ts/run.ts +117 -0
- package/src/codegen/test-helpers/golden.test.ts +80 -0
- package/src/codegen/test-helpers/golden.ts +34 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package com.example.api
|
|
2
|
+
|
|
3
|
+
// Source hash: <PLACEHOLDER>
|
|
4
|
+
|
|
5
|
+
import kotlinx.serialization.Contextual
|
|
6
|
+
import kotlinx.serialization.SerialName
|
|
7
|
+
import kotlinx.serialization.Serializable
|
|
8
|
+
import kotlinx.serialization.json.JsonClassDiscriminator
|
|
9
|
+
|
|
10
|
+
object Users {
|
|
11
|
+
object GetUser {
|
|
12
|
+
const val method = "GET"
|
|
13
|
+
const val pathTemplate = "/users/{id}"
|
|
14
|
+
fun path(p: PathParams): String = "/users/${p.id}"
|
|
15
|
+
|
|
16
|
+
@Serializable
|
|
17
|
+
data class PathParams(
|
|
18
|
+
val id: String,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
@Serializable
|
|
22
|
+
data class Response(
|
|
23
|
+
val id: String,
|
|
24
|
+
val name: String,
|
|
25
|
+
@SerialName("created-at") @Contextual val createdAt: java.time.Instant,
|
|
26
|
+
val address: Address,
|
|
27
|
+
) {
|
|
28
|
+
@Serializable
|
|
29
|
+
data class Address(
|
|
30
|
+
val street: String,
|
|
31
|
+
val city: String,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
object Errors {
|
|
36
|
+
@Serializable
|
|
37
|
+
data class NotFound(
|
|
38
|
+
val name: String = "NotFound",
|
|
39
|
+
val message: String,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
object CreateUser {
|
|
45
|
+
const val method = "POST"
|
|
46
|
+
const val pathTemplate = "/users"
|
|
47
|
+
const val path = "/users"
|
|
48
|
+
|
|
49
|
+
@Serializable
|
|
50
|
+
@JsonClassDiscriminator("kind")
|
|
51
|
+
sealed interface Body {
|
|
52
|
+
@Serializable
|
|
53
|
+
@SerialName("guest")
|
|
54
|
+
data class GuestBody(
|
|
55
|
+
val displayName: String,
|
|
56
|
+
) : Body
|
|
57
|
+
|
|
58
|
+
@Serializable
|
|
59
|
+
@SerialName("registered")
|
|
60
|
+
data class RegisteredBody(
|
|
61
|
+
val email: String,
|
|
62
|
+
val name: String,
|
|
63
|
+
) : Body
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Serializable
|
|
67
|
+
data class Response(
|
|
68
|
+
val id: String,
|
|
69
|
+
val name: String,
|
|
70
|
+
@SerialName("created-at") @Contextual val createdAt: java.time.Instant,
|
|
71
|
+
val address: Address,
|
|
72
|
+
) {
|
|
73
|
+
@Serializable
|
|
74
|
+
data class Address(
|
|
75
|
+
val street: String,
|
|
76
|
+
val city: String,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
object Errors {
|
|
81
|
+
@Serializable
|
|
82
|
+
data class ValidationError(
|
|
83
|
+
val name: String = "ValidationError",
|
|
84
|
+
val message: String,
|
|
85
|
+
val field: String? = null,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
object ListUsers {
|
|
91
|
+
const val method = "GET"
|
|
92
|
+
const val pathTemplate = "/users"
|
|
93
|
+
const val path = "/users"
|
|
94
|
+
|
|
95
|
+
@Serializable
|
|
96
|
+
data class Query(
|
|
97
|
+
val status: Status? = null,
|
|
98
|
+
val limit: Long? = null,
|
|
99
|
+
) {
|
|
100
|
+
@Serializable
|
|
101
|
+
enum class Status {
|
|
102
|
+
@SerialName("active") ACTIVE,
|
|
103
|
+
@SerialName("inactive") INACTIVE,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@Serializable
|
|
108
|
+
data class Response(
|
|
109
|
+
val id: String,
|
|
110
|
+
val name: String,
|
|
111
|
+
@SerialName("created-at") @Contextual val createdAt: java.time.Instant,
|
|
112
|
+
val address: Address,
|
|
113
|
+
) {
|
|
114
|
+
@Serializable
|
|
115
|
+
data class Address(
|
|
116
|
+
val street: String,
|
|
117
|
+
val city: String,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ajsc.emitKotlin — untagged oneOf behavior > produces a deterministic fallback shape for an untagged oneOf 1`] = `
|
|
4
|
+
{
|
|
5
|
+
"code": "@Serializable
|
|
6
|
+
data class Mixed()
|
|
7
|
+
",
|
|
8
|
+
"extractedTypeNames": [],
|
|
9
|
+
"imports": [
|
|
10
|
+
"kotlinx.serialization.Serializable",
|
|
11
|
+
],
|
|
12
|
+
"rootTypeName": "Mixed",
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
exports[`ajsc.emitKotlin — untagged oneOf behavior > silently falls back to empty data class when unsupportedUnions is not specified 1`] = `
|
|
17
|
+
{
|
|
18
|
+
"code": "@Serializable
|
|
19
|
+
data class Mixed()
|
|
20
|
+
",
|
|
21
|
+
"extractedTypeNames": [],
|
|
22
|
+
"imports": [
|
|
23
|
+
"kotlinx.serialization.Serializable",
|
|
24
|
+
],
|
|
25
|
+
"rootTypeName": "Mixed",
|
|
26
|
+
}
|
|
27
|
+
`;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createStubKotlinEmitter, resolveProductionKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
|
|
3
|
+
|
|
4
|
+
describe('createStubKotlinEmitter', () => {
|
|
5
|
+
it('returns the configured EmitResult for the matching root name', () => {
|
|
6
|
+
const expected: KotlinEmitResult = {
|
|
7
|
+
code: '@Serializable data class User(val id: String)',
|
|
8
|
+
rootTypeName: 'User',
|
|
9
|
+
extractedTypeNames: [],
|
|
10
|
+
imports: ['kotlinx.serialization.Serializable'],
|
|
11
|
+
}
|
|
12
|
+
const emitter = createStubKotlinEmitter({ User: expected })
|
|
13
|
+
expect(
|
|
14
|
+
emitter.emit(
|
|
15
|
+
{ type: 'object' },
|
|
16
|
+
{
|
|
17
|
+
rootTypeName: 'User',
|
|
18
|
+
inlineTypes: true,
|
|
19
|
+
serializer: 'kotlinx',
|
|
20
|
+
unsupportedUnions: 'throw',
|
|
21
|
+
},
|
|
22
|
+
),
|
|
23
|
+
).toEqual(expected)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('throws when asked to emit a name not in the stub map', () => {
|
|
27
|
+
const emitter = createStubKotlinEmitter({})
|
|
28
|
+
expect(() => emitter.emit({}, { rootTypeName: 'Missing' })).toThrow(/Missing/)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('resolveProductionKotlinEmitter', () => {
|
|
33
|
+
it('returns a working emitter that invokes ajsc.emitKotlin when ajsc is installed', async () => {
|
|
34
|
+
const emitter = await resolveProductionKotlinEmitter()
|
|
35
|
+
const result = emitter.emit(
|
|
36
|
+
{ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
37
|
+
{ rootTypeName: 'Probe' },
|
|
38
|
+
)
|
|
39
|
+
expect(typeof result.code).toBe('string')
|
|
40
|
+
expect(result.code.length).toBeGreaterThan(0)
|
|
41
|
+
expect(result.rootTypeName).toBe('Probe')
|
|
42
|
+
expect(Array.isArray(result.imports)).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
// Note: testing the failure path (ajsc unavailable) requires module mocking;
|
|
45
|
+
// we leave that as a manual-verification path. The error message is pinned
|
|
46
|
+
// by the message text below so any change requires updating both call sites.
|
|
47
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface KotlinEmitResult {
|
|
2
|
+
code: string
|
|
3
|
+
rootTypeName: string
|
|
4
|
+
extractedTypeNames: string[]
|
|
5
|
+
imports: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface KotlinEmitOptions {
|
|
9
|
+
rootTypeName: string
|
|
10
|
+
/** Always set true at our call sites; v7.2 default is false. */
|
|
11
|
+
inlineTypes?: boolean
|
|
12
|
+
serializer?: 'kotlinx' | 'none'
|
|
13
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
14
|
+
arrayItemNaming?: string | false
|
|
15
|
+
depluralize?: boolean
|
|
16
|
+
uncountableWords?: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface KotlinEmitter {
|
|
20
|
+
emit(schema: Record<string, unknown>, opts: KotlinEmitOptions): KotlinEmitResult
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createStubKotlinEmitter(
|
|
24
|
+
results: Record<string, KotlinEmitResult>,
|
|
25
|
+
): KotlinEmitter {
|
|
26
|
+
return {
|
|
27
|
+
emit(_schema, opts) {
|
|
28
|
+
const result = results[opts.rootTypeName]
|
|
29
|
+
if (result == null) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[stub-kotlin-emitter] No stubbed result for rootTypeName "${opts.rootTypeName}". ` +
|
|
32
|
+
`Provide one in the results map.`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return result
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves the production Kotlin emitter from `ajsc`. Throws a clear error
|
|
42
|
+
* if ajsc is not installed or does not expose `emitKotlin` (e.g. consumer
|
|
43
|
+
* ran `npm install --omit=optional` since ajsc is in optionalDependencies).
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveProductionKotlinEmitter(): Promise<KotlinEmitter> {
|
|
46
|
+
let ajsc: { emitKotlin?: unknown } | null = null
|
|
47
|
+
let importError: unknown
|
|
48
|
+
try {
|
|
49
|
+
ajsc = (await import('ajsc')) as { emitKotlin?: unknown }
|
|
50
|
+
} catch (err) {
|
|
51
|
+
importError = err
|
|
52
|
+
}
|
|
53
|
+
const emitKotlin = ajsc?.emitKotlin
|
|
54
|
+
if (typeof emitKotlin !== 'function') {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'[ts-procedures-codegen] ajsc.emitKotlin is not available. ' +
|
|
57
|
+
'Install ajsc (`npm install ajsc`) — it is an optional dependency.',
|
|
58
|
+
importError !== undefined ? { cause: importError } : undefined,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
emit(schema, opts) {
|
|
63
|
+
return (emitKotlin as (s: unknown, o: unknown) => KotlinEmitResult)(schema, opts)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|