ts-procedures 7.0.0 → 7.1.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 (39) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +4 -0
  2. package/agent_config/copilot/copilot-instructions.md +2 -0
  3. package/agent_config/cursor/cursorrules +2 -0
  4. package/build/codegen/bin/cli.js +91 -0
  5. package/build/codegen/bin/cli.js.map +1 -1
  6. package/build/codegen/bin/cli.test.js +15 -0
  7. package/build/codegen/bin/cli.test.js.map +1 -1
  8. package/build/codegen/e2e.test.js +97 -74
  9. package/build/codegen/e2e.test.js.map +1 -1
  10. package/build/codegen/emit-index.js +11 -1
  11. package/build/codegen/emit-index.js.map +1 -1
  12. package/build/codegen/emit-scope.js +58 -16
  13. package/build/codegen/emit-scope.js.map +1 -1
  14. package/build/codegen/emit-scope.test.js +164 -2
  15. package/build/codegen/emit-scope.test.js.map +1 -1
  16. package/build/codegen/emit-types.d.ts +28 -0
  17. package/build/codegen/emit-types.js +69 -5
  18. package/build/codegen/emit-types.js.map +1 -1
  19. package/build/codegen/emit-types.test.js +30 -0
  20. package/build/codegen/emit-types.test.js.map +1 -1
  21. package/build/codegen/resolve-envelope.js +4 -1
  22. package/build/codegen/resolve-envelope.js.map +1 -1
  23. package/build/codegen/resolve-envelope.test.js +10 -0
  24. package/build/codegen/resolve-envelope.test.js.map +1 -1
  25. package/build/codegen/test-helpers/run-tsc.d.ts +33 -0
  26. package/build/codegen/test-helpers/run-tsc.js +49 -0
  27. package/build/codegen/test-helpers/run-tsc.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/codegen/bin/cli.test.ts +26 -0
  30. package/src/codegen/bin/cli.ts +91 -0
  31. package/src/codegen/e2e.test.ts +100 -78
  32. package/src/codegen/emit-index.ts +11 -1
  33. package/src/codegen/emit-scope.test.ts +172 -2
  34. package/src/codegen/emit-scope.ts +66 -13
  35. package/src/codegen/emit-types.test.ts +34 -0
  36. package/src/codegen/emit-types.ts +83 -5
  37. package/src/codegen/resolve-envelope.test.ts +11 -0
  38. package/src/codegen/resolve-envelope.ts +4 -1
  39. package/src/codegen/test-helpers/run-tsc.ts +56 -0
@@ -178,6 +178,40 @@ describe('jsonSchemaToExtractedTypes', () => {
178
178
  expect(result!.body).not.toContain('export type Root')
179
179
  expect(result!.body).toContain('id: string')
180
180
  })
181
+
182
+ // Bug repro (downstream): codegen emits `Cannot find name 'RootType'`.
183
+ // ajsc with inlineTypes:false renders an array-root schema as TWO blocks:
184
+ // export type RootType = { ... };
185
+ // export type Root = Array<RootType>;
186
+ // The current parser uses `block.startsWith('export type Root')` which
187
+ // matches BOTH `Root` and `RootType`, swallowing the `RootType` declaration
188
+ // and leaving the body's `Array<RootType>` reference dangling.
189
+ it('preserves the RootType items declaration when the root schema is an array of objects', async () => {
190
+ const schema = {
191
+ type: 'array',
192
+ items: {
193
+ type: 'object',
194
+ properties: {
195
+ id: { type: 'string' },
196
+ name: { type: 'string' },
197
+ },
198
+ required: ['id', 'name'],
199
+ },
200
+ }
201
+ const result = await jsonSchemaToExtractedTypes(schema)
202
+ expect(result).not.toBeUndefined()
203
+ // The body should be `Array<RootType>` (or equivalent referencing a named items type)
204
+ expect(result!.body).toMatch(/^Array<\w+>$/)
205
+
206
+ // The items type must be present in declarations so the body reference resolves.
207
+ // Extract the referenced name from the body and assert a matching declaration exists.
208
+ const refName = result!.body.match(/^Array<(\w+)>$/)?.[1]
209
+ expect(refName).toBeDefined()
210
+ const hasItemsDecl = result!.declarations.some((d) =>
211
+ new RegExp(`^export\\s+type\\s+${refName}\\s*=`).test(d)
212
+ )
213
+ expect(hasItemsDecl).toBe(true)
214
+ })
181
215
  })
182
216
 
183
217
  describe('jsonSchemaToTypeString (prefix stripping)', () => {
@@ -133,13 +133,16 @@ export async function jsonSchemaToExtractedTypes(
133
133
  const declarations: string[] = []
134
134
  let body = ''
135
135
 
136
+ // Match `export type Root =` strictly — `Root` must be followed by `=` (with
137
+ // optional whitespace), so sibling extracted names like `RootType` (which
138
+ // ajsc emits for an `Array<RootType>` root schema) fall through to the
139
+ // declarations branch instead of being eaten as the body.
140
+ const rootDeclPattern = /^export\s+type\s+Root\s*=\s*/
141
+
136
142
  for (const block of blocks) {
137
- if (block.startsWith('export type Root')) {
143
+ if (rootDeclPattern.test(block)) {
138
144
  // Strip "export type Root = " prefix and trailing ";"
139
- body = block
140
- .replace(/^export\s+type\s+Root\s*=\s*/, '')
141
- .replace(/;\s*$/, '')
142
- .trim()
145
+ body = block.replace(rootDeclPattern, '').replace(/;\s*$/, '').trim()
143
146
  } else {
144
147
  // Sub-type or enum declaration — remove trailing ";" for consistency
145
148
  declarations.push(block.replace(/;\s*$/, ''))
@@ -156,3 +159,78 @@ export async function jsonSchemaToExtractedTypes(
156
159
 
157
160
  return { declarations, body }
158
161
  }
162
+
163
+ /**
164
+ * Returns the declared name of an `export type|enum|interface X = …` block,
165
+ * or `undefined` if none can be parsed. Used by `renameExtractedTypes` to
166
+ * detect collisions between ajsc-extracted sub-types and reserved identifiers.
167
+ */
168
+ export function extractedDeclName(decl: string): string | undefined {
169
+ const m = decl.match(/^export\s+(?:type|enum|interface)\s+(\w+)/)
170
+ return m?.[1]
171
+ }
172
+
173
+ /**
174
+ * Rewrites an {@link ExtractedTypeOutput} to avoid identifier collisions.
175
+ *
176
+ * ajsc with `inlineTypes: false` derives sub-type names from the parent
177
+ * property's name — so a schema property literally called `params` produces
178
+ * `export type Params = {…}`. When that collides with the route's own
179
+ * `Params` shortName (or any other reserved name), the resulting namespace
180
+ * has duplicate `export type Params` declarations and won't compile.
181
+ *
182
+ * This helper takes:
183
+ * - `result`: the raw ajsc output for one schema
184
+ * - `taken`: a set of names that are NOT free to use (mutated as renames
185
+ * are applied so subsequent calls share the same allocation map)
186
+ *
187
+ * For every extracted declaration whose name is already in `taken`, a unique
188
+ * alias is generated (`Params` → `ParamsInner`, then `ParamsInner2`, …) so
189
+ * the renamed type reads like a real, intentional name (not a placeholder).
190
+ * The declaration is rewritten with the new name and every word-boundary
191
+ * occurrence in `result.body` is substituted so the body keeps referencing
192
+ * the renamed type.
193
+ */
194
+ export function renameExtractedTypes(
195
+ result: ExtractedTypeOutput,
196
+ taken: Set<string>,
197
+ ): ExtractedTypeOutput {
198
+ let body = result.body
199
+ const declarations: string[] = []
200
+
201
+ for (const decl of result.declarations) {
202
+ const name = extractedDeclName(decl)
203
+ if (name == null) {
204
+ declarations.push(decl)
205
+ continue
206
+ }
207
+ if (!taken.has(name)) {
208
+ taken.add(name)
209
+ declarations.push(decl)
210
+ continue
211
+ }
212
+
213
+ // Allocate a unique alias. `Inner` reads as an intentional name (the
214
+ // sub-type referenced *by* the conflicting outer type) rather than the
215
+ // `_` suffix which looks like a forgotten placeholder.
216
+ let alias = `${name}Inner`
217
+ let suffix = 2
218
+ while (taken.has(alias)) {
219
+ alias = `${name}Inner${suffix}`
220
+ suffix += 1
221
+ }
222
+ taken.add(alias)
223
+
224
+ // Rewrite the declaration's leading identifier.
225
+ const renamedDecl = decl.replace(
226
+ new RegExp(`^(export\\s+(?:type|enum|interface)\\s+)${name}\\b`),
227
+ `$1${alias}`,
228
+ )
229
+ declarations.push(renamedDecl)
230
+
231
+ // Patch every word-boundary occurrence of the old name in the body.
232
+ body = body.replace(new RegExp(`\\b${name}\\b`, 'g'), alias)
233
+ }
234
+
235
+ return { declarations, body }
236
+ }
@@ -53,6 +53,17 @@ describe('resolveEnvelope', () => {
53
53
  await expect(resolveEnvelope({ envelope: empty })).rejects.toThrow(/routes/)
54
54
  })
55
55
 
56
+ // Defensive (downstream bug repro): forgetting `builder.build()` is a common
57
+ // cause of an empty routes array because hono-rpc/hono-api/hono-stream/express-rpc
58
+ // builders only populate their `_docs` array inside `build()`. The current
59
+ // error message says "Register at least one procedure", which led the
60
+ // downstream dev to look in the wrong place. The message should mention
61
+ // `.build()` as a likely cause so the next person hits the right fix faster.
62
+ it('empty-routes error message mentions builder.build() as a likely cause', async () => {
63
+ const empty: DocEnvelope = { basePath: '', headers: [], errors: [], routes: [] }
64
+ await expect(resolveEnvelope({ envelope: empty })).rejects.toThrow(/\.build\(\)/)
65
+ })
66
+
56
67
  it('throws when routes array is empty (file input)', async () => {
57
68
  const dir = await mkdtemp(join(tmpdir(), 'ts-proc-test-'))
58
69
  const filePath = join(dir, 'empty.json')
@@ -53,7 +53,10 @@ export async function resolveEnvelope(input: ResolveInput): Promise<DocEnvelope>
53
53
 
54
54
  if (envelope.routes.length === 0) {
55
55
  throw new Error(
56
- '[ts-procedures-codegen] DocEnvelope has an empty "routes" array. Register at least one procedure before generating.'
56
+ '[ts-procedures-codegen] DocEnvelope has an empty "routes" array. ' +
57
+ 'Common causes: (1) you forgot to call `builder.build()` before passing ' +
58
+ 'the builder to `DocRegistry.from(...)` — hono/express builders only populate ' +
59
+ '`docs` inside `build()`; (2) no procedures registered with the builder.'
57
60
  )
58
61
  }
59
62
 
@@ -0,0 +1,56 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Default tsconfig used by codegen e2e tests when none is supplied.
7
+ * Mirrors what consumers running `tsc --strict` typically use, plus the
8
+ * codegen output's expected module/target settings.
9
+ */
10
+ export const DEFAULT_E2E_TSCONFIG = {
11
+ compilerOptions: {
12
+ strict: true,
13
+ target: 'ES2022',
14
+ module: 'ES2022',
15
+ moduleResolution: 'bundler',
16
+ noEmit: true,
17
+ skipLibCheck: true,
18
+ },
19
+ include: ['**/*.ts'],
20
+ } as const
21
+
22
+ /**
23
+ * Runs `tsc --noEmit` on the given tsconfig and throws an `AssertionError`-style
24
+ * message that includes captured stderr/stdout when compilation fails. Returns
25
+ * silently on success.
26
+ *
27
+ * Replaces the previously-duplicated try/execSync/stderr-format dance that
28
+ * appeared in 5+ places in `e2e.test.ts` — keeps tests focused on what they
29
+ * generated, not how they shell out to tsc.
30
+ *
31
+ * `tsconfigInline` is written to `tmpDir/tsconfig.json` automatically; pass
32
+ * `tsconfigPath` instead to point at an existing file.
33
+ */
34
+ export function runTsc(args: {
35
+ tmpDir: string
36
+ tsconfigInline?: Record<string, unknown>
37
+ tsconfigPath?: string
38
+ }): void {
39
+ const tsconfigPath = args.tsconfigPath ?? join(args.tmpDir, 'tsconfig.json')
40
+ if (args.tsconfigInline != null) {
41
+ writeFileSync(tsconfigPath, JSON.stringify(args.tsconfigInline))
42
+ }
43
+
44
+ const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
45
+ try {
46
+ execSync(`${tscPath} --noEmit --project ${tsconfigPath}`, { stdio: 'pipe' })
47
+ } catch (err) {
48
+ const e = err as { stdout?: Buffer; stderr?: Buffer; message?: string }
49
+ const stdout = e.stdout?.toString() ?? ''
50
+ const stderr = e.stderr?.toString() ?? ''
51
+ const combined = [stdout, stderr].filter(Boolean).join('\n').trim()
52
+ throw new Error(
53
+ `[runTsc] tsc failed for ${tsconfigPath}:\n${combined || (e.message ?? '(no output)')}`,
54
+ )
55
+ }
56
+ }