ts-procedures 8.1.1 → 8.2.1
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/.claude-plugin/plugin.json +1 -1
- package/agent_config/claude-code/agents/ts-procedures-architect.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +348 -5
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +2 -2
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +3 -3
- package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/client.md +4 -2
- package/agent_config/copilot/copilot-instructions.md +3 -3
- package/agent_config/cursor/cursorrules +3 -3
- package/agent_config/lib/install-claude.mjs +4 -4
- package/build/codegen/bin/cli.js +1 -1
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +2 -2
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/constants.d.ts +8 -1
- package/build/codegen/constants.js +9 -1
- package/build/codegen/constants.js.map +1 -1
- package/build/codegen/e2e.test.js +1 -1
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-errors.test.js +1 -1
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.test.js +1 -1
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.test.js +1 -1
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/pipeline.js +1 -1
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/pipeline.test.js +33 -10
- package/build/codegen/pipeline.test.js.map +1 -1
- package/build/codegen/targets/_shared/write-files.d.ts +11 -5
- package/build/codegen/targets/_shared/write-files.js +49 -7
- package/build/codegen/targets/_shared/write-files.js.map +1 -1
- package/build/codegen/targets/_shared/write-files.test.js +87 -21
- package/build/codegen/targets/_shared/write-files.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js +3 -2
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.d.ts +6 -0
- package/build/codegen/targets/kotlin/format-kotlin.js +9 -0
- package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js +8 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
- package/build/codegen/targets/swift/format-swift.js +4 -1
- package/build/codegen/targets/swift/format-swift.js.map +1 -1
- package/docs/ai-agent-setup.md +5 -6
- package/docs/client-and-codegen.md +2 -2
- package/package.json +1 -1
- package/src/codegen/bin/cli.test.ts +2 -2
- package/src/codegen/bin/cli.ts +1 -1
- package/src/codegen/constants.ts +10 -1
- package/src/codegen/e2e.test.ts +1 -1
- package/src/codegen/emit-errors.test.ts +1 -1
- package/src/codegen/emit-index.test.ts +1 -1
- package/src/codegen/emit-scope.test.ts +1 -1
- package/src/codegen/pipeline.test.ts +39 -10
- package/src/codegen/pipeline.ts +1 -1
- package/src/codegen/targets/_shared/write-files.test.ts +143 -32
- package/src/codegen/targets/_shared/write-files.ts +53 -8
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +1 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +3 -2
- package/src/codegen/targets/kotlin/format-kotlin.test.ts +9 -0
- package/src/codegen/targets/kotlin/format-kotlin.ts +11 -0
- package/src/codegen/targets/swift/format-swift.ts +5 -1
- package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +0 -106
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +0 -48
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +0 -50
- package/agent_config/claude-code/skills/ts-procedures-swift/SKILL.md +0 -119
- /package/agent_config/claude-code/skills/{ts-procedures-review → ts-procedures}/checklist.md +0 -0
- /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/astro-catchall.md +0 -0
- /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/hono.md +0 -0
- /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/procedure.md +0 -0
- /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/stream-procedure.md +0 -0
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { mkdir, readFile, rm, writeFile, stat, mkdtemp } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, readFile, rm, writeFile, stat, mkdtemp, readdir } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { writeGeneratedFiles } from './write-files.js'
|
|
6
|
+
import { CODEGEN_HEADER } from '../../constants.js'
|
|
7
|
+
|
|
8
|
+
/** Build file contents that carry the package signature (a generated file). */
|
|
9
|
+
function signed(body = 'export const x = 1\n'): string {
|
|
10
|
+
return `${CODEGEN_HEADER}\n\n${body}`
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
describe('writeGeneratedFiles', () => {
|
|
8
14
|
describe('dryRun mode', () => {
|
|
@@ -32,22 +38,46 @@ describe('writeGeneratedFiles', () => {
|
|
|
32
38
|
await expect(stat(dir)).rejects.toBeInstanceOf(Error)
|
|
33
39
|
})
|
|
34
40
|
|
|
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
41
|
it('counts bytes as utf-8', async () => {
|
|
47
42
|
// "é" is 2 bytes in utf-8.
|
|
48
43
|
await writeGeneratedFiles([{ path: '/x/e.txt', code: 'é' }], '/x', { dryRun: true })
|
|
49
44
|
expect(logSpy).toHaveBeenCalledWith('[dry-run] Would write: /x/e.txt (2 bytes)')
|
|
50
45
|
})
|
|
46
|
+
|
|
47
|
+
it('logs would-remove for an orphaned generated file under cleanOutDir, without deleting it', async () => {
|
|
48
|
+
const outDir = await mkdtemp(join(tmpdir(), 'write-files-dry-'))
|
|
49
|
+
try {
|
|
50
|
+
await writeFile(join(outDir, 'orphan.ts'), signed(), 'utf-8')
|
|
51
|
+
|
|
52
|
+
await writeGeneratedFiles(
|
|
53
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
54
|
+
outDir,
|
|
55
|
+
{ dryRun: true, cleanOutDir: true },
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
59
|
+
`[dry-run] Would remove orphaned generated file: ${join(outDir, 'orphan.ts')}`,
|
|
60
|
+
)
|
|
61
|
+
// Nothing was actually removed.
|
|
62
|
+
expect(await readFile(join(outDir, 'orphan.ts'), 'utf-8')).toBe(signed())
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(outDir, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('does not log a would-remove notice when cleanOutDir is false', async () => {
|
|
69
|
+
const outDir = await mkdtemp(join(tmpdir(), 'write-files-dry-'))
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(join(outDir, 'orphan.ts'), signed(), 'utf-8')
|
|
72
|
+
await writeGeneratedFiles([{ path: join(outDir, 'x.ts'), code: signed() }], outDir, {
|
|
73
|
+
dryRun: true,
|
|
74
|
+
})
|
|
75
|
+
const messages = logSpy.mock.calls.map((c: unknown[]) => String(c[0]))
|
|
76
|
+
expect(messages.find((m: string) => m.includes('Would remove'))).toBeUndefined()
|
|
77
|
+
} finally {
|
|
78
|
+
await rm(outDir, { recursive: true, force: true })
|
|
79
|
+
}
|
|
80
|
+
})
|
|
51
81
|
})
|
|
52
82
|
|
|
53
83
|
describe('real write mode', () => {
|
|
@@ -79,32 +109,113 @@ describe('writeGeneratedFiles', () => {
|
|
|
79
109
|
expect(await readFile(join(nested, 'x.txt'), 'utf-8')).toBe('x')
|
|
80
110
|
})
|
|
81
111
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
describe('cleanOutDir (marker-based prune)', () => {
|
|
113
|
+
it('removes an orphaned generated file that is no longer emitted', async () => {
|
|
114
|
+
// A previously-generated scope file that this run does not re-emit.
|
|
115
|
+
await writeFile(join(outDir, 'orphan-scope.ts'), signed(), 'utf-8')
|
|
116
|
+
|
|
117
|
+
await writeGeneratedFiles(
|
|
118
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
119
|
+
outDir,
|
|
120
|
+
{ cleanOutDir: true },
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
await expect(readFile(join(outDir, 'orphan-scope.ts'), 'utf-8')).rejects.toBeInstanceOf(
|
|
124
|
+
Error,
|
|
125
|
+
)
|
|
126
|
+
expect(await readFile(join(outDir, 'fresh.ts'), 'utf-8')).toBe(signed())
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('never deletes a file that lacks the package signature', async () => {
|
|
130
|
+
// Hand-written file the developer co-located in the output dir.
|
|
131
|
+
await writeFile(join(outDir, 'hand-written.ts'), 'export const mine = 1\n', 'utf-8')
|
|
132
|
+
// Even a file that merely imports from ts-procedures (no -codegen token).
|
|
133
|
+
await writeFile(
|
|
134
|
+
join(outDir, 'helper.ts'),
|
|
135
|
+
"import { createClient } from 'ts-procedures/client'\n",
|
|
136
|
+
'utf-8',
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
await writeGeneratedFiles(
|
|
140
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
141
|
+
outDir,
|
|
142
|
+
{ cleanOutDir: true },
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
expect(await readFile(join(outDir, 'hand-written.ts'), 'utf-8')).toBe(
|
|
146
|
+
'export const mine = 1\n',
|
|
147
|
+
)
|
|
148
|
+
expect(await readFile(join(outDir, 'helper.ts'), 'utf-8')).toBe(
|
|
149
|
+
"import { createClient } from 'ts-procedures/client'\n",
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('does not delete a signed file that is part of the current output', async () => {
|
|
154
|
+
// Pre-existing signed file that IS re-emitted this run — it should be
|
|
155
|
+
// overwritten, not removed-then-rewritten in a way that loses it.
|
|
156
|
+
await writeFile(join(outDir, 'users.ts'), signed('old'), 'utf-8')
|
|
157
|
+
|
|
158
|
+
await writeGeneratedFiles(
|
|
159
|
+
[{ path: join(outDir, 'users.ts'), code: signed('new') }],
|
|
160
|
+
outDir,
|
|
161
|
+
{ cleanOutDir: true },
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
expect(await readFile(join(outDir, 'users.ts'), 'utf-8')).toBe(signed('new'))
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('prunes orphans left by a different package version (version-agnostic)', async () => {
|
|
168
|
+
// A file generated by an older release: different version in the header.
|
|
169
|
+
const oldHeader = '// Auto-generated by ts-procedures-codegen (v0.0.1) — do not edit'
|
|
170
|
+
await writeFile(join(outDir, 'legacy.ts'), `${oldHeader}\n\nexport const y = 2\n`, 'utf-8')
|
|
171
|
+
|
|
172
|
+
await writeGeneratedFiles(
|
|
173
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
174
|
+
outDir,
|
|
175
|
+
{ cleanOutDir: true },
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
await expect(readFile(join(outDir, 'legacy.ts'), 'utf-8')).rejects.toBeInstanceOf(Error)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('skips subdirectories entirely', async () => {
|
|
182
|
+
const sub = join(outDir, 'nested')
|
|
183
|
+
await mkdir(sub, { recursive: true })
|
|
184
|
+
await writeFile(join(sub, 'inner.ts'), signed(), 'utf-8')
|
|
185
|
+
|
|
186
|
+
await writeGeneratedFiles(
|
|
187
|
+
[{ path: join(outDir, 'fresh.ts'), code: signed() }],
|
|
188
|
+
outDir,
|
|
189
|
+
{ cleanOutDir: true },
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// The nested dir and its signed file are untouched.
|
|
193
|
+
expect(await readFile(join(sub, 'inner.ts'), 'utf-8')).toBe(signed())
|
|
194
|
+
const entries = await readdir(outDir)
|
|
195
|
+
expect(entries).toContain('nested')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('does not throw when outDir does not exist yet', async () => {
|
|
199
|
+
const fresh = join(outDir, 'brand-new')
|
|
200
|
+
await expect(
|
|
201
|
+
writeGeneratedFiles([{ path: join(fresh, 'a.ts'), code: signed() }], fresh, {
|
|
202
|
+
cleanOutDir: true,
|
|
203
|
+
}),
|
|
204
|
+
).resolves.toBeUndefined()
|
|
205
|
+
expect(await readFile(join(fresh, 'a.ts'), 'utf-8')).toBe(signed())
|
|
206
|
+
})
|
|
95
207
|
})
|
|
96
208
|
|
|
97
|
-
it('does not remove
|
|
98
|
-
await
|
|
209
|
+
it('does not remove anything when cleanOutDir is false', async () => {
|
|
210
|
+
await writeFile(join(outDir, 'orphan.ts'), signed(), 'utf-8')
|
|
99
211
|
await writeFile(join(outDir, 'keep.txt'), 'keep', 'utf-8')
|
|
100
212
|
|
|
101
|
-
await writeGeneratedFiles(
|
|
102
|
-
[{ path: join(outDir, 'new.txt'), code: 'new' }],
|
|
103
|
-
outDir,
|
|
104
|
-
)
|
|
213
|
+
await writeGeneratedFiles([{ path: join(outDir, 'new.ts'), code: signed() }], outDir)
|
|
105
214
|
|
|
215
|
+
// Even a signed orphan stays put when cleanOutDir is not requested.
|
|
216
|
+
expect(await readFile(join(outDir, 'orphan.ts'), 'utf-8')).toBe(signed())
|
|
106
217
|
expect(await readFile(join(outDir, 'keep.txt'), 'utf-8')).toBe('keep')
|
|
107
|
-
expect(await readFile(join(outDir, 'new.
|
|
218
|
+
expect(await readFile(join(outDir, 'new.ts'), 'utf-8')).toBe(signed())
|
|
108
219
|
})
|
|
109
220
|
})
|
|
110
221
|
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { CODEGEN_SIGNATURE } from '../../constants.js'
|
|
2
4
|
|
|
3
5
|
export interface GeneratedFile {
|
|
4
6
|
path: string
|
|
@@ -8,10 +10,46 @@ export interface GeneratedFile {
|
|
|
8
10
|
export interface WriteOptions {
|
|
9
11
|
/** When true, log what would happen instead of touching the filesystem. */
|
|
10
12
|
dryRun?: boolean
|
|
11
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* When true, remove orphaned generated files from `outDir` before writing —
|
|
15
|
+
* i.e. files this run no longer emits that carry the `ts-procedures-codegen`
|
|
16
|
+
* signature. Files lacking the signature (hand-written, other tools' output)
|
|
17
|
+
* are never touched.
|
|
18
|
+
*/
|
|
12
19
|
cleanOutDir?: boolean
|
|
13
20
|
}
|
|
14
21
|
|
|
22
|
+
/** Only the head of a file is read to detect the signature; it lives at the top. */
|
|
23
|
+
const SIGNATURE_SCAN_BYTES = 512
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the top-level files in `outDir` that carry the package signature but
|
|
27
|
+
* are NOT part of `written` — the orphans left behind by a prior run whose
|
|
28
|
+
* scope/route no longer exists. Subdirectories are skipped (targets emit flat),
|
|
29
|
+
* and any file without the signature is left untouched.
|
|
30
|
+
*/
|
|
31
|
+
async function findOrphans(outDir: string, written: Set<string>): Promise<string[]> {
|
|
32
|
+
let entries
|
|
33
|
+
try {
|
|
34
|
+
entries = await readdir(outDir, { withFileTypes: true })
|
|
35
|
+
} catch (err) {
|
|
36
|
+
// Nothing to prune if the dir doesn't exist yet (first run).
|
|
37
|
+
if ((err as { code?: string }).code === 'ENOENT') return []
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const orphans: string[] = []
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isFile()) continue
|
|
44
|
+
const path = join(outDir, entry.name)
|
|
45
|
+
if (written.has(path)) continue // re-emitted this run — it will be overwritten
|
|
46
|
+
const buf = await readFile(path)
|
|
47
|
+
const head = buf.subarray(0, SIGNATURE_SCAN_BYTES).toString('utf-8')
|
|
48
|
+
if (head.includes(CODEGEN_SIGNATURE)) orphans.push(path)
|
|
49
|
+
}
|
|
50
|
+
return orphans
|
|
51
|
+
}
|
|
52
|
+
|
|
15
53
|
/**
|
|
16
54
|
* Persists the generated files to disk (or simulates the writes under
|
|
17
55
|
* `dryRun`). Centralises the dryRun / cleanOutDir / mkdir / writeFile
|
|
@@ -20,10 +58,11 @@ export interface WriteOptions {
|
|
|
20
58
|
*
|
|
21
59
|
* Behaviour:
|
|
22
60
|
* - `dryRun: true` — logs `[dry-run] Would write: <path> (<bytes> bytes)` per
|
|
23
|
-
* file, plus
|
|
24
|
-
* `cleanOutDir: true`. No filesystem mutation.
|
|
25
|
-
* - `dryRun: false` (default) — when `cleanOutDir`,
|
|
26
|
-
* `
|
|
61
|
+
* file, plus `[dry-run] Would remove orphaned generated file: <path>` for
|
|
62
|
+
* each prunable orphan when `cleanOutDir: true`. No filesystem mutation.
|
|
63
|
+
* - `dryRun: false` (default) — when `cleanOutDir`, removes orphaned generated
|
|
64
|
+
* files (signed by `ts-procedures-codegen`, no longer emitted); then ensures
|
|
65
|
+
* `outDir` exists; then writes each file as utf-8.
|
|
27
66
|
*/
|
|
28
67
|
export async function writeGeneratedFiles(
|
|
29
68
|
files: readonly GeneratedFile[],
|
|
@@ -31,10 +70,13 @@ export async function writeGeneratedFiles(
|
|
|
31
70
|
opts: WriteOptions = {},
|
|
32
71
|
): Promise<void> {
|
|
33
72
|
const { dryRun = false, cleanOutDir = false } = opts
|
|
73
|
+
const written = new Set(files.map((f) => f.path))
|
|
34
74
|
|
|
35
75
|
if (dryRun) {
|
|
36
76
|
if (cleanOutDir) {
|
|
37
|
-
|
|
77
|
+
for (const orphan of await findOrphans(outDir, written)) {
|
|
78
|
+
console.log(`[dry-run] Would remove orphaned generated file: ${orphan}`)
|
|
79
|
+
}
|
|
38
80
|
}
|
|
39
81
|
for (const f of files) {
|
|
40
82
|
const bytes = Buffer.byteLength(f.code, 'utf-8')
|
|
@@ -44,7 +86,10 @@ export async function writeGeneratedFiles(
|
|
|
44
86
|
}
|
|
45
87
|
|
|
46
88
|
if (cleanOutDir) {
|
|
47
|
-
|
|
89
|
+
for (const orphan of await findOrphans(outDir, written)) {
|
|
90
|
+
await rm(orphan, { force: true })
|
|
91
|
+
console.log(`[ts-procedures-codegen] Removed orphaned generated file: ${orphan}`)
|
|
92
|
+
}
|
|
48
93
|
}
|
|
49
94
|
await mkdir(outDir, { recursive: true })
|
|
50
95
|
for (const f of files) {
|
|
@@ -1,7 +1,7 @@
|
|
|
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 } from './format-kotlin.js'
|
|
4
|
+
import { kotlinPackageDecl, kotlinSignatureHeader, kotlinSourceHashHeader, kotlinImports } from './format-kotlin.js'
|
|
5
5
|
import { indent } from '../_shared/indent.js'
|
|
6
6
|
import { pickDefined } from '../_shared/pick-defined.js'
|
|
7
7
|
import { pascalCase } from '../_shared/pascal-case.js'
|
|
@@ -49,9 +49,10 @@ export function emitKotlinScope(
|
|
|
49
49
|
: `object ${scopeName} {\n${innerScope}\n}`
|
|
50
50
|
|
|
51
51
|
const importsBlock = kotlinImports(allImports)
|
|
52
|
+
const headerBlock = [kotlinSignatureHeader(), kotlinSourceHashHeader(opts.sourceHash)].join('\n')
|
|
52
53
|
const parts = [
|
|
53
54
|
kotlinPackageDecl(opts.kotlinPackage),
|
|
54
|
-
|
|
55
|
+
headerBlock,
|
|
55
56
|
importsBlock,
|
|
56
57
|
scopeBlock,
|
|
57
58
|
].filter((p) => p.length > 0)
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
kotlinPackageDecl,
|
|
4
|
+
kotlinSignatureHeader,
|
|
4
5
|
kotlinSourceHashHeader,
|
|
5
6
|
kotlinImports,
|
|
6
7
|
} from './format-kotlin.js'
|
|
8
|
+
import { CODEGEN_SIGNATURE } from '../../constants.js'
|
|
7
9
|
|
|
8
10
|
describe('format-kotlin', () => {
|
|
9
11
|
it('emits a package declaration', () => {
|
|
10
12
|
expect(kotlinPackageDecl('com.example.api')).toBe('package com.example.api')
|
|
11
13
|
})
|
|
12
14
|
|
|
15
|
+
it('emits a signature header line carrying the package token', () => {
|
|
16
|
+
const header = kotlinSignatureHeader()
|
|
17
|
+
expect(header).toBe(`// ${CODEGEN_SIGNATURE} — do not edit`)
|
|
18
|
+
// The marker-based prune depends on this token being present.
|
|
19
|
+
expect(header).toContain(CODEGEN_SIGNATURE)
|
|
20
|
+
})
|
|
21
|
+
|
|
13
22
|
it('emits a source-hash header line', () => {
|
|
14
23
|
expect(kotlinSourceHashHeader('abc123')).toBe('// Source hash: abc123')
|
|
15
24
|
})
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import { CODEGEN_SIGNATURE } from '../../constants.js'
|
|
2
|
+
|
|
1
3
|
export function kotlinPackageDecl(pkg: string): string {
|
|
2
4
|
return `package ${pkg}`
|
|
3
5
|
}
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Stable signature comment embedded at the top of every generated Kotlin file.
|
|
9
|
+
* Carries the version-agnostic `ts-procedures-codegen` token so the marker-based
|
|
10
|
+
* prune in `writeGeneratedFiles` can recognise generated Kotlin files as owned.
|
|
11
|
+
*/
|
|
12
|
+
export function kotlinSignatureHeader(): string {
|
|
13
|
+
return `// ${CODEGEN_SIGNATURE} — do not edit`
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
export function kotlinSourceHashHeader(hash: string): string {
|
|
6
17
|
return `// Source hash: ${hash}`
|
|
7
18
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { CODEGEN_SIGNATURE } from '../../constants.js'
|
|
2
|
+
|
|
1
3
|
export function swiftHeader(sourceHash: string): string {
|
|
2
|
-
|
|
4
|
+
// The marker-based prune in `writeGeneratedFiles` relies on CODEGEN_SIGNATURE
|
|
5
|
+
// appearing in this header.
|
|
6
|
+
return `// Generated by ${CODEGEN_SIGNATURE} — do not edit.\n// Source hash: ${sourceHash}`
|
|
3
7
|
}
|
|
4
8
|
|
|
5
9
|
export function swiftImports(imports: string[]): string {
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ts-procedures-kotlin
|
|
3
|
-
description: "Kotlin client codegen for ts-procedures — generate types-only Kotlin source from a ts-procedures DocEnvelope for Android/JVM consumers. Use when the user mentions Kotlin, Android, mobile clients, kotlinx-serialization, or asks how to generate non-TypeScript types from a ts-procedures server."
|
|
4
|
-
user-invocable: false
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# ts-procedures — Kotlin Client Codegen
|
|
8
|
-
|
|
9
|
-
You are assisting a developer who needs to generate Kotlin types from a `ts-procedures` server's `DocEnvelope`. The Kotlin target is **types-only** — no runtime, no adapter, no error registry. Mobile/Android consumers own the HTTP layer.
|
|
10
|
-
|
|
11
|
-
## When this skill applies
|
|
12
|
-
|
|
13
|
-
- The user mentions Kotlin, Android, kotlinx-serialization, mobile client, or `--target kotlin`.
|
|
14
|
-
- The user wants to share API types between a `ts-procedures` server and a Kotlin/JVM consumer.
|
|
15
|
-
- The user is debugging Kotlin codegen output, Gradle setup, or contextual serializer registration.
|
|
16
|
-
|
|
17
|
-
If the user is generating a **TypeScript** client, redirect them to the main `ts-procedures` skill. For **Swift / iOS / macOS / Apple-platform** consumers, redirect to `ts-procedures-swift`.
|
|
18
|
-
|
|
19
|
-
## Quickstart
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
npx ts-procedures-codegen \
|
|
23
|
-
--target kotlin \
|
|
24
|
-
--kotlin-package com.example.api \
|
|
25
|
-
--url https://api.example.com/_ts-procedures.json \
|
|
26
|
-
--out ./src/main/kotlin/com/example/api
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
One `.kt` file per scope. Types accessed as `Users.GetUser.Response`, `Users.GetUser.Body.Address` (nested classes via `inlineTypes: true`), `Users.GetUser.Errors.NotFound`.
|
|
30
|
-
|
|
31
|
-
## CLI flags (Kotlin-specific)
|
|
32
|
-
|
|
33
|
-
| Flag | Default | Purpose |
|
|
34
|
-
|---|---|---|
|
|
35
|
-
| `--target kotlin` | `ts` | Switch to the Kotlin codegen path |
|
|
36
|
-
| `--kotlin-package <com.example.api>` | required | Sets the `package` declaration on every emitted `.kt` file |
|
|
37
|
-
| `--kotlin-serializer <kotlinx\|none>` | `kotlinx` | `kotlinx` emits `@Serializable`; `none` emits plain data classes for Moshi/Gson/hand-written serialization |
|
|
38
|
-
| `--unsupported-unions <throw\|fallback>` | `throw` | **Currently a no-op for Kotlin** — ajsc v7.2 silently emits an empty `data class` for untagged `oneOf` regardless. CLI warns when set |
|
|
39
|
-
|
|
40
|
-
`--array-item-naming`, `--depluralize`, `--uncountable-words` also apply to the Kotlin target.
|
|
41
|
-
|
|
42
|
-
## Output shape (what consumers see)
|
|
43
|
-
|
|
44
|
-
```kotlin
|
|
45
|
-
package com.example.api
|
|
46
|
-
|
|
47
|
-
import kotlinx.serialization.Serializable
|
|
48
|
-
import kotlinx.serialization.SerialName
|
|
49
|
-
import kotlinx.serialization.Contextual
|
|
50
|
-
import kotlinx.serialization.json.JsonClassDiscriminator
|
|
51
|
-
|
|
52
|
-
object Users {
|
|
53
|
-
object GetUser {
|
|
54
|
-
const val method = "GET"
|
|
55
|
-
const val pathTemplate = "/users/{id}"
|
|
56
|
-
fun path(p: PathParams): String = "/users/${p.id}"
|
|
57
|
-
|
|
58
|
-
@Serializable data class PathParams(val id: String)
|
|
59
|
-
|
|
60
|
-
@Serializable
|
|
61
|
-
data class Response(
|
|
62
|
-
val id: String,
|
|
63
|
-
@SerialName("created-at") @Contextual val createdAt: java.time.Instant,
|
|
64
|
-
val address: Address,
|
|
65
|
-
) {
|
|
66
|
-
@Serializable data class Address(val street: String, val city: String)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
object Errors {
|
|
70
|
-
@Serializable
|
|
71
|
-
data class NotFound(val name: String = "NotFound", val message: String)
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
For routes without path params, `path` is a `const val`, not a function.
|
|
78
|
-
|
|
79
|
-
## Consumer-side setup the dev MUST do
|
|
80
|
-
|
|
81
|
-
The generated code requires these on the Android/JVM side. **Don't let the user assume the codegen handles them.**
|
|
82
|
-
|
|
83
|
-
1. **Gradle:** `kotlin("plugin.serialization")` plugin + `org.jetbrains.kotlinx:kotlinx-serialization-json` dependency. (`kotlinx-serialization-core` is a transitive dep; no need to declare it explicitly.)
|
|
84
|
-
|
|
85
|
-
2. **Contextual serializers:** `format: date-time`/`uuid`/`uri`/`date`/`time` map to JVM stdlib types annotated with `@Contextual`. The consumer's `Json` configuration MUST register `contextual(java.time.Instant::class, ...)` etc., otherwise decoding fails. We don't ship the serializers — choice between ISO-8601 and epoch ms is application-specific.
|
|
86
|
-
|
|
87
|
-
3. **Discriminated unions:** `@JsonClassDiscriminator` is read automatically by `kotlinx-serialization-json` — no extra config needed.
|
|
88
|
-
|
|
89
|
-
4. **No runtime dispatch:** error types are emitted as nested data classes (`Users.GetUser.Errors.NotFound`), but there's no `instanceof`-style registry, no `dispatchTypedError`. Consumers catch HTTP failures themselves and inspect `body.name` (a regular `String` field, not a type-system discriminator) to decide which error data class to deserialize against. This is by design; don't suggest implementing it.
|
|
90
|
-
|
|
91
|
-
The full setup guide lives at `docs/codegen-kotlin.md` in the `ts-procedures` repo.
|
|
92
|
-
|
|
93
|
-
## Documented limitations to flag during reviews
|
|
94
|
-
|
|
95
|
-
- **Untagged `oneOf` produces an empty `data class`.** Won't round-trip. Add a server-side discriminator, hand-write a `KSerializer`, or pre-process the envelope.
|
|
96
|
-
- **Tuples > 3 elements throw** at codegen time. Refactor to a struct schema upstream.
|
|
97
|
-
- **`additionalProperties: { type: T }` is silently dropped** with a KDoc note. Add a sibling `Map<String, T>` field by hand if your contract uses extra keys.
|
|
98
|
-
- **Schema-level `examples` are not modeled.** They're documentation-only on the server side; consumers don't see them.
|
|
99
|
-
|
|
100
|
-
## Anti-patterns
|
|
101
|
-
|
|
102
|
-
- Suggesting the Kotlin target ships an HTTP adapter or error registry.
|
|
103
|
-
- Recommending `--kotlin-serializer none` without noting the consumer is responsible for adapter setup.
|
|
104
|
-
- Treating `--unsupported-unions fallback` as functional for Kotlin — it's a no-op (the CLI itself warns when set).
|
|
105
|
-
- Saying KMP (Kotlin Multiplatform) is supported — JVM only for now.
|
|
106
|
-
- Mixing `--target kotlin` flags into a TypeScript-target invocation; some flags are silently ignored, others (like `--kotlin-package`) are required only for kotlin.
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ts-procedures-review
|
|
3
|
-
description: "Review ts-procedures code for pattern adherence, schema correctness, error handling, and signal propagation."
|
|
4
|
-
argument-hint: "<path>"
|
|
5
|
-
allowed-tools: Read Grep Glob
|
|
6
|
-
context: fork
|
|
7
|
-
effort: high
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
# Review ts-procedures Code
|
|
11
|
-
|
|
12
|
-
Parse `$ARGUMENTS` as a file or directory path. If a directory, review all `.ts` and `.tsx` files within it.
|
|
13
|
-
|
|
14
|
-
## Instructions
|
|
15
|
-
|
|
16
|
-
1. Read the target file(s).
|
|
17
|
-
2. Identify ts-procedures imports (`ts-procedures`, `ts-procedures/hono`, `ts-procedures/http`, `ts-procedures/http-docs`, `ts-procedures/http-errors`, `ts-procedures/client`, `ts-procedures/codegen`) to determine file types.
|
|
18
|
-
3. Check each file against the categorized checklist in [checklist.md](checklist.md).
|
|
19
|
-
4. For detailed code examples of each violation pattern, reference [anti-patterns.md](../ts-procedures/anti-patterns.md) — it shows 20 common mistakes with before/after code fixes and severity ratings.
|
|
20
|
-
5. Output findings grouped by severity.
|
|
21
|
-
|
|
22
|
-
## Output Format
|
|
23
|
-
|
|
24
|
-
For each finding:
|
|
25
|
-
|
|
26
|
-
```
|
|
27
|
-
[SEVERITY] file:line — Violation
|
|
28
|
-
Problem: What's wrong
|
|
29
|
-
Fix: Concrete before/after code
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Severity levels:
|
|
33
|
-
- **CRITICAL** — Will cause bugs, silent failures, resource leaks, or runtime errors. Must fix.
|
|
34
|
-
- **WARNING** — Anti-pattern that hurts maintainability or correctness. Should fix.
|
|
35
|
-
- **SUGGESTION** — Improvement for readability, type safety, or DX. Nice to have.
|
|
36
|
-
|
|
37
|
-
## Summary
|
|
38
|
-
|
|
39
|
-
After individual findings, provide:
|
|
40
|
-
- Total findings by severity
|
|
41
|
-
- Overall assessment (healthy / needs attention / significant issues)
|
|
42
|
-
- Top 3 priorities to address
|
|
43
|
-
- If structural issues found, suggest `/ts-procedures:scaffold <type> <Name>` to generate correct implementations
|
|
44
|
-
|
|
45
|
-
## Reference
|
|
46
|
-
|
|
47
|
-
See [checklist.md](checklist.md) for the complete categorized checklist by file type.
|
|
48
|
-
See [anti-patterns.md](../ts-procedures/anti-patterns.md) for detailed code examples of each violation.
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ts-procedures-scaffold
|
|
3
|
-
description: "Generate ts-procedures implementations with correct patterns — procedures, streams, Hono apps (RPC, streams, REST in one builder), and client setup."
|
|
4
|
-
argument-hint: "<type> <Name>"
|
|
5
|
-
allowed-tools: Read Write
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Scaffold ts-procedures Code
|
|
9
|
-
|
|
10
|
-
Scaffold a `$0` ts-procedures implementation named `$1`.
|
|
11
|
-
|
|
12
|
-
If either argument is missing, ask the user for `<type>` and `<Name>`.
|
|
13
|
-
|
|
14
|
-
## Instructions
|
|
15
|
-
|
|
16
|
-
1. Validate `$0` is a recognized type (see table below). Case-insensitive.
|
|
17
|
-
2. Validate `$1` is PascalCase (e.g., `UserProfile`).
|
|
18
|
-
3. Derive placeholder variants from the provided PascalCase Name:
|
|
19
|
-
- `{{Name}}` — PascalCase as given (e.g., `UserProfile`)
|
|
20
|
-
- `{{name}}` — camelCase (e.g., `userProfile`)
|
|
21
|
-
- `{{kebab}}` — kebab-case (e.g., `user-profile`)
|
|
22
|
-
4. Read the template file from `templates/$0.md` (relative to this skill directory).
|
|
23
|
-
5. Replace all `{{Name}}`, `{{name}}`, and `{{kebab}}` placeholders with the appropriate variants.
|
|
24
|
-
6. Generate the implementation file(s) following the template exactly.
|
|
25
|
-
7. Also generate a colocated test file following ts-procedures test conventions.
|
|
26
|
-
|
|
27
|
-
## Valid Types
|
|
28
|
-
|
|
29
|
-
| Type | Template | Files Generated |
|
|
30
|
-
|------|----------|----------------|
|
|
31
|
-
| `procedure` | `templates/procedure.md` | `{{Name}}.procedure.ts`, `{{Name}}.procedure.test.ts` |
|
|
32
|
-
| `stream-procedure` | `templates/stream-procedure.md` | `{{Name}}.stream.ts`, `{{Name}}.stream.test.ts` |
|
|
33
|
-
| `hono` | `templates/hono.md` | `{{Name}}.hono.ts`, `{{Name}}.hono.test.ts` |
|
|
34
|
-
| `client` | `templates/client.md` | `{{Name}}.client.ts`, `{{Name}}.client.test.ts` |
|
|
35
|
-
| `astro-catchall` | `templates/astro-catchall.md` | `src/pages/api/[...rest].ts` |
|
|
36
|
-
|
|
37
|
-
## Rules
|
|
38
|
-
|
|
39
|
-
- Use `ctx.error()` for ad-hoc business errors; for structured errors with status codes and typed client dispatch, throw custom error classes and register them via `defineErrorTaxonomy` in the builder's `errors` config (see the HTTP builder templates).
|
|
40
|
-
- Never throw raw `Error` — either use `ctx.error()` or a typed class registered in the taxonomy.
|
|
41
|
-
- `schema.params` is validated at runtime; `schema.returnType` is documentation only.
|
|
42
|
-
- Pass `ctx.signal` to all downstream async calls.
|
|
43
|
-
- Stream handlers always have `ctx.signal` (guaranteed `AbortSignal`).
|
|
44
|
-
- Use TypeBox for schema definitions (`import { Type } from 'typebox'`).
|
|
45
|
-
- AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
|
|
46
|
-
- Test files use `describe`/`test` from vitest.
|
|
47
|
-
|
|
48
|
-
## Workflow
|
|
49
|
-
|
|
50
|
-
Use the **ts-procedures-architect** agent to plan your API structure first, then scaffold each procedure. After scaffolding, use `/ts-procedures:review <path>` to validate the implementation.
|