ts-procedures 8.0.0 → 8.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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import { jsonSchemaToTypeString, jsonSchemaToTypeBody, jsonSchemaToExtractedTypes } from './emit-types.js'
2
+ import { jsonSchemaToTypeString, jsonSchemaToTypeBody, jsonSchemaToExtractedTypes, renameExtractedTypes, extractedDeclName } from './emit-types.js'
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
5
  // Tests
@@ -212,6 +212,78 @@ describe('jsonSchemaToExtractedTypes', () => {
212
212
  )
213
213
  expect(hasItemsDecl).toBe(true)
214
214
  })
215
+
216
+ // Root-cause guard: ajsc glues SIBLING extracted declarations with a single
217
+ // "\n" inside one block. If they aren't split into separate elements, the
218
+ // downstream rename/dedup logic only sees the first one -> duplicate
219
+ // identifiers (TS2300). Each extracted sub-type must occupy its own element.
220
+ it('returns one declaration per element when a schema yields multiple sub-types', async () => {
221
+ const schema = {
222
+ type: 'object',
223
+ required: ['contact'],
224
+ properties: {
225
+ contact: {
226
+ type: 'object',
227
+ required: ['name', 'address'],
228
+ properties: {
229
+ name: { type: 'string' },
230
+ address: {
231
+ type: 'object',
232
+ required: ['city'],
233
+ properties: { city: { type: 'string' } },
234
+ },
235
+ },
236
+ },
237
+ },
238
+ }
239
+ const result = await jsonSchemaToExtractedTypes(schema)
240
+ expect(result).not.toBeUndefined()
241
+ // Two sub-types are extracted: Contact and Address.
242
+ const names = result!.declarations.map((d) => extractedDeclName(d))
243
+ expect(names).toContain('Contact')
244
+ expect(names).toContain('Address')
245
+ // Each declaration element holds exactly one `export type|enum|interface`.
246
+ for (const decl of result!.declarations) {
247
+ const count = decl.match(/export\s+(?:type|enum|interface)\s+/g)?.length ?? 0
248
+ expect(count).toBe(1)
249
+ }
250
+ })
251
+ })
252
+
253
+ describe('renameExtractedTypes', () => {
254
+ it('renames a colliding declaration and patches its references in the body', () => {
255
+ const result = {
256
+ declarations: ['export type Address = { city: string; }'],
257
+ body: '{ address: Address; }',
258
+ }
259
+ const out = renameExtractedTypes(result, new Set(['Address']))
260
+ expect(out.declarations[0]).toContain('export type AddressInner =')
261
+ expect(out.body).toBe('{ address: AddressInner; }')
262
+ })
263
+
264
+ // Latent correctness guard: extracted sub-types reference each other. When a
265
+ // referenced sub-type is renamed, the reference inside its SIBLING
266
+ // declaration must be patched too — otherwise the renamed type silently
267
+ // resolves to a same-named sub-type from a different schema.
268
+ it('patches cross-references between sibling declarations on rename', () => {
269
+ const result = {
270
+ declarations: [
271
+ 'export type Contact = { address: Address; }',
272
+ 'export type Address = { city: string; }',
273
+ ],
274
+ body: '{ contact: Contact; }',
275
+ }
276
+ // Both names already taken (as if extracted from a second schema sharing them).
277
+ const out = renameExtractedTypes(result, new Set(['Contact', 'Address']))
278
+ // Both declarations renamed.
279
+ expect(out.declarations[0]).toContain('export type ContactInner =')
280
+ expect(out.declarations[1]).toContain('export type AddressInner =')
281
+ // The reference inside ContactInner now points at AddressInner, not the
282
+ // stale Address (which would belong to the other schema).
283
+ expect(out.declarations[0]).toContain('address: AddressInner')
284
+ expect(out.declarations[0]).not.toMatch(/address: Address\b/)
285
+ expect(out.body).toBe('{ contact: ContactInner; }')
286
+ })
215
287
  })
216
288
 
217
289
  describe('jsonSchemaToTypeString (prefix stripping)', () => {
@@ -91,6 +91,45 @@ export interface ExtractedTypeOutput {
91
91
  body: string
92
92
  }
93
93
 
94
+ /**
95
+ * Splits a block of ajsc output into individual top-level declarations.
96
+ *
97
+ * ajsc glues sibling extracted declarations with a single `\n`, but each
98
+ * declaration may itself span multiple lines (multi-line enums, object bodies
99
+ * with per-property JSDoc). A simple line/`export`-prefix split would therefore
100
+ * break multi-line declarations apart. Instead we walk lines tracking brace
101
+ * depth and close a declaration when depth returns to 0 on a line that ends in
102
+ * a statement terminator (`;` or `}`). Any leading comment lines (JSDoc) stay
103
+ * attached to the declaration that follows them.
104
+ */
105
+ function splitTopLevelDeclarations(block: string): string[] {
106
+ const decls: string[] = []
107
+ let current: string[] = []
108
+ let depth = 0
109
+
110
+ const flush = () => {
111
+ const joined = current.join('\n').trim()
112
+ if (joined) decls.push(joined)
113
+ current = []
114
+ }
115
+
116
+ for (const line of block.split('\n')) {
117
+ current.push(line)
118
+ for (const ch of line) {
119
+ if (ch === '{') depth += 1
120
+ else if (ch === '}') depth -= 1
121
+ }
122
+ const trimmed = line.trimEnd()
123
+ if (depth <= 0 && (trimmed.endsWith(';') || trimmed.endsWith('}'))) {
124
+ flush()
125
+ depth = 0
126
+ }
127
+ }
128
+ flush()
129
+
130
+ return decls
131
+ }
132
+
94
133
  /**
95
134
  * Converts a JSON Schema to extracted TypeScript types using ajsc with
96
135
  * `inlineTypes: false`. This produces named sub-types (objects, enums) that
@@ -122,13 +161,23 @@ export async function jsonSchemaToExtractedTypes(
122
161
  )
123
162
  }
124
163
 
125
- // ajsc with inlineTypes: false produces blocks separated by blank lines:
164
+ // ajsc with inlineTypes: false produces output like:
126
165
  // export enum Status { Active = "active" }
127
166
  // export type Contact = { name: string; };
167
+ //
128
168
  // export type Root = { status: Status; contacts: Array<Contact>; };
129
169
  //
130
- // The Root type is always the last declaration.
170
+ // Crucially, *sibling* extracted declarations are joined by a SINGLE "\n"
171
+ // (one block) while the Root is separated by a blank line ("\n\n"). Splitting
172
+ // only on blank lines would therefore fuse two or more sibling declarations
173
+ // into one array element — and every downstream consumer (extractedDeclName,
174
+ // renameExtractedTypes, the namespace dedup) assumes one declaration per
175
+ // element, so the non-first siblings would be mis-parsed and emitted twice
176
+ // (`error TS2300: Duplicate identifier`). We split on blank lines first, then
177
+ // split each block into individual statements so each element holds exactly
178
+ // one declaration. The Root type is always the last declaration.
131
179
  const blocks = code.split(/\n\n+/).map((b) => b.trim()).filter(Boolean)
180
+ const rawDecls = blocks.flatMap(splitTopLevelDeclarations)
132
181
 
133
182
  const declarations: string[] = []
134
183
  let body = ''
@@ -139,13 +188,13 @@ export async function jsonSchemaToExtractedTypes(
139
188
  // declarations branch instead of being eaten as the body.
140
189
  const rootDeclPattern = /^export\s+type\s+Root\s*=\s*/
141
190
 
142
- for (const block of blocks) {
143
- if (rootDeclPattern.test(block)) {
191
+ for (const decl of rawDecls) {
192
+ if (rootDeclPattern.test(decl)) {
144
193
  // Strip "export type Root = " prefix and trailing ";"
145
- body = block.replace(rootDeclPattern, '').replace(/;\s*$/, '').trim()
194
+ body = decl.replace(rootDeclPattern, '').replace(/;\s*$/, '').trim()
146
195
  } else {
147
196
  // Sub-type or enum declaration — remove trailing ";" for consistency
148
- declarations.push(block.replace(/;\s*$/, ''))
197
+ declarations.push(decl.replace(/;\s*$/, ''))
149
198
  }
150
199
  }
151
200
 
@@ -188,25 +237,28 @@ export function extractedDeclName(decl: string): string | undefined {
188
237
  * alias is generated (`Params` → `ParamsInner`, then `ParamsInner2`, …) so
189
238
  * the renamed type reads like a real, intentional name (not a placeholder).
190
239
  * 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.
240
+ * occurrence of the old name is substituted in `result.body` AND in every
241
+ * sibling declaration. The sibling patch matters because extracted sub-types
242
+ * reference each other (e.g. `Contact = { address: Address }`): renaming
243
+ * `Address` → `AddressInner` must update that reference too, otherwise the
244
+ * renamed type silently resolves to a different schema's sub-type of the same
245
+ * name (a latent wrong-type bug that only hides when the shapes happen to match).
193
246
  */
194
247
  export function renameExtractedTypes(
195
248
  result: ExtractedTypeOutput,
196
249
  taken: Set<string>,
197
250
  ): ExtractedTypeOutput {
198
- let body = result.body
199
- const declarations: string[] = []
251
+ // Work on a mutable copy so we can patch cross-references after renaming.
252
+ const declarations = [...result.declarations]
253
+ const renames = new Map<string, string>()
200
254
 
201
- for (const decl of result.declarations) {
255
+ // Pass 1: decide renames and rewrite each declaration's leading identifier.
256
+ for (let i = 0; i < declarations.length; i += 1) {
257
+ const decl = declarations[i]!
202
258
  const name = extractedDeclName(decl)
203
- if (name == null) {
204
- declarations.push(decl)
205
- continue
206
- }
259
+ if (name == null) continue
207
260
  if (!taken.has(name)) {
208
261
  taken.add(name)
209
- declarations.push(decl)
210
262
  continue
211
263
  }
212
264
 
@@ -220,16 +272,25 @@ export function renameExtractedTypes(
220
272
  suffix += 1
221
273
  }
222
274
  taken.add(alias)
275
+ renames.set(name, alias)
223
276
 
224
- // Rewrite the declaration's leading identifier.
225
- const renamedDecl = decl.replace(
277
+ declarations[i] = decl.replace(
226
278
  new RegExp(`^(export\\s+(?:type|enum|interface)\\s+)${name}\\b`),
227
279
  `$1${alias}`,
228
280
  )
229
- declarations.push(renamedDecl)
281
+ }
230
282
 
231
- // Patch every word-boundary occurrence of the old name in the body.
232
- body = body.replace(new RegExp(`\\b${name}\\b`, 'g'), alias)
283
+ // Pass 2: patch every reference to a renamed name in the body and in every
284
+ // declaration body so cross-references between sibling sub-types follow the
285
+ // rename. `\bAddress\b` does not match inside `AddressInner` (no word
286
+ // boundary between `s` and `I`), so already-renamed identifiers are untouched.
287
+ let body = result.body
288
+ for (const [name, alias] of renames) {
289
+ const re = new RegExp(`\\b${name}\\b`, 'g')
290
+ body = body.replace(re, alias)
291
+ for (let i = 0; i < declarations.length; i += 1) {
292
+ declarations[i] = declarations[i]!.replace(re, alias)
293
+ }
233
294
  }
234
295
 
235
296
  return { declarations, body }