ts-procedures 8.1.1 → 8.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 (56) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +1 -1
  2. package/agent_config/claude-code/skills/ts-procedures/patterns.md +3 -3
  3. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +4 -2
  4. package/agent_config/copilot/copilot-instructions.md +3 -3
  5. package/agent_config/cursor/cursorrules +3 -3
  6. package/build/codegen/bin/cli.js +1 -1
  7. package/build/codegen/bin/cli.js.map +1 -1
  8. package/build/codegen/bin/cli.test.js +2 -2
  9. package/build/codegen/bin/cli.test.js.map +1 -1
  10. package/build/codegen/constants.d.ts +8 -1
  11. package/build/codegen/constants.js +9 -1
  12. package/build/codegen/constants.js.map +1 -1
  13. package/build/codegen/e2e.test.js +1 -1
  14. package/build/codegen/e2e.test.js.map +1 -1
  15. package/build/codegen/emit-errors.test.js +1 -1
  16. package/build/codegen/emit-errors.test.js.map +1 -1
  17. package/build/codegen/emit-index.test.js +1 -1
  18. package/build/codegen/emit-index.test.js.map +1 -1
  19. package/build/codegen/emit-scope.test.js +1 -1
  20. package/build/codegen/emit-scope.test.js.map +1 -1
  21. package/build/codegen/pipeline.js +1 -1
  22. package/build/codegen/pipeline.js.map +1 -1
  23. package/build/codegen/pipeline.test.js +33 -10
  24. package/build/codegen/pipeline.test.js.map +1 -1
  25. package/build/codegen/targets/_shared/write-files.d.ts +11 -5
  26. package/build/codegen/targets/_shared/write-files.js +49 -7
  27. package/build/codegen/targets/_shared/write-files.js.map +1 -1
  28. package/build/codegen/targets/_shared/write-files.test.js +87 -21
  29. package/build/codegen/targets/_shared/write-files.test.js.map +1 -1
  30. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +3 -2
  31. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
  32. package/build/codegen/targets/kotlin/format-kotlin.d.ts +6 -0
  33. package/build/codegen/targets/kotlin/format-kotlin.js +9 -0
  34. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
  35. package/build/codegen/targets/kotlin/format-kotlin.test.js +8 -1
  36. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
  37. package/build/codegen/targets/swift/format-swift.js +4 -1
  38. package/build/codegen/targets/swift/format-swift.js.map +1 -1
  39. package/docs/client-and-codegen.md +2 -2
  40. package/package.json +1 -1
  41. package/src/codegen/bin/cli.test.ts +2 -2
  42. package/src/codegen/bin/cli.ts +1 -1
  43. package/src/codegen/constants.ts +10 -1
  44. package/src/codegen/e2e.test.ts +1 -1
  45. package/src/codegen/emit-errors.test.ts +1 -1
  46. package/src/codegen/emit-index.test.ts +1 -1
  47. package/src/codegen/emit-scope.test.ts +1 -1
  48. package/src/codegen/pipeline.test.ts +39 -10
  49. package/src/codegen/pipeline.ts +1 -1
  50. package/src/codegen/targets/_shared/write-files.test.ts +143 -32
  51. package/src/codegen/targets/_shared/write-files.ts +53 -8
  52. package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +1 -0
  53. package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +3 -2
  54. package/src/codegen/targets/kotlin/format-kotlin.test.ts +9 -0
  55. package/src/codegen/targets/kotlin/format-kotlin.ts +11 -0
  56. package/src/codegen/targets/swift/format-swift.ts +5 -1
@@ -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
- 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')
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 outDir when cleanOutDir is false', async () => {
98
- await mkdir(outDir, { recursive: true })
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.txt'), 'utf-8')).toBe('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
- /** When true, recursively delete `outDir` before writing. */
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 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.
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
- console.log(`[dry-run] Would clean outDir: ${outDir}`)
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
- await rm(outDir, { recursive: true, force: true })
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,5 +1,6 @@
1
1
  package com.example.api
2
2
 
3
+ // ts-procedures-codegen — do not edit
3
4
  // Source hash: <PLACEHOLDER>
4
5
 
5
6
  import kotlinx.serialization.Contextual
@@ -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
- kotlinSourceHashHeader(opts.sourceHash),
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
- return `// Generated by ts-procedures-codegen do not edit.\n// Source hash: ${sourceHash}`
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 {