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.
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +4 -0
- package/agent_config/copilot/copilot-instructions.md +2 -0
- package/agent_config/cursor/cursorrules +2 -0
- package/build/codegen/bin/cli.js +91 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +15 -0
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/e2e.test.js +97 -74
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-index.js +11 -1
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-scope.js +58 -16
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +164 -2
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/emit-types.d.ts +28 -0
- package/build/codegen/emit-types.js +69 -5
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/emit-types.test.js +30 -0
- package/build/codegen/emit-types.test.js.map +1 -1
- package/build/codegen/resolve-envelope.js +4 -1
- package/build/codegen/resolve-envelope.js.map +1 -1
- package/build/codegen/resolve-envelope.test.js +10 -0
- package/build/codegen/resolve-envelope.test.js.map +1 -1
- package/build/codegen/test-helpers/run-tsc.d.ts +33 -0
- package/build/codegen/test-helpers/run-tsc.js +49 -0
- package/build/codegen/test-helpers/run-tsc.js.map +1 -0
- package/package.json +1 -1
- package/src/codegen/bin/cli.test.ts +26 -0
- package/src/codegen/bin/cli.ts +91 -0
- package/src/codegen/e2e.test.ts +100 -78
- package/src/codegen/emit-index.ts +11 -1
- package/src/codegen/emit-scope.test.ts +172 -2
- package/src/codegen/emit-scope.ts +66 -13
- package/src/codegen/emit-types.test.ts +34 -0
- package/src/codegen/emit-types.ts +83 -5
- package/src/codegen/resolve-envelope.test.ts +11 -0
- package/src/codegen/resolve-envelope.ts +4 -1
- 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 (
|
|
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.
|
|
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
|
+
}
|