@amritk/generate-parsers 0.2.1 → 0.3.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.
Files changed (38) hide show
  1. package/dist/generators/build-schema.d.ts +5 -1
  2. package/dist/generators/generate-files.d.ts +15 -2
  3. package/dist/helpers/collect-helpers.d.ts +16 -2
  4. package/dist/index.js +64 -19
  5. package/package.json +3 -2
  6. package/src/generators/build-schema.test.ts +830 -0
  7. package/src/generators/build-schema.ts +280 -0
  8. package/src/generators/generate-default-from-pattern.test.ts +81 -0
  9. package/src/generators/generate-default-from-pattern.ts +39 -0
  10. package/src/generators/generate-discriminated-union-validation.test.ts +84 -0
  11. package/src/generators/generate-discriminated-union-validation.ts +54 -0
  12. package/src/generators/generate-enum-check.test.ts +45 -0
  13. package/src/generators/generate-enum-check.ts +8 -0
  14. package/src/generators/generate-files.test.ts +435 -0
  15. package/src/generators/generate-files.ts +136 -0
  16. package/src/generators/generate-non-discriminated-union-validation.test.ts +74 -0
  17. package/src/generators/generate-non-discriminated-union-validation.ts +40 -0
  18. package/src/generators/generate-parser-function.test.ts +1798 -0
  19. package/src/generators/generate-parser-function.ts +1319 -0
  20. package/src/generators/generate-schema-checks.test.ts +322 -0
  21. package/src/generators/generate-schema-checks.ts +114 -0
  22. package/src/generators/generate-strict-assertion.ts +210 -0
  23. package/src/generators/generate-validation-expression.test.ts +794 -0
  24. package/src/generators/generate-validation-expression.ts +286 -0
  25. package/src/helpers/apply-schema-extensions.test.ts +194 -0
  26. package/src/helpers/apply-schema-extensions.ts +63 -0
  27. package/src/helpers/collect-helpers.test.ts +103 -0
  28. package/src/helpers/collect-helpers.ts +64 -0
  29. package/src/helpers/collect-imports.test.ts +587 -0
  30. package/src/helpers/collect-imports.ts +268 -0
  31. package/src/helpers/find-discriminator.test.ts +88 -0
  32. package/src/helpers/find-discriminator.ts +39 -0
  33. package/src/helpers/get-default-value.test.ts +135 -0
  34. package/src/helpers/get-default-value.ts +89 -0
  35. package/src/helpers/get-discriminator-value.test.ts +83 -0
  36. package/src/helpers/get-discriminator-value.ts +21 -0
  37. package/src/index.ts +1 -0
  38. package/src/types/schema-extensions.ts +35 -0
@@ -0,0 +1,286 @@
1
+ import { resolveRef } from '@amritk/helpers/resolve-ref'
2
+ import { safeAccessor } from '@amritk/helpers/safe-accessor'
3
+ import {
4
+ hasAdditionalProperties,
5
+ hasAllOf,
6
+ hasAnyOf,
7
+ hasConst,
8
+ hasEnum,
9
+ hasExclusiveMaximum,
10
+ hasExclusiveMinimum,
11
+ hasMaxItems,
12
+ hasMaximum,
13
+ hasMaxLength,
14
+ hasMaxProperties,
15
+ hasMinItems,
16
+ hasMinimum,
17
+ hasMinLength,
18
+ hasMinProperties,
19
+ hasMultipleOf,
20
+ hasOneOf,
21
+ hasPattern,
22
+ hasProperties,
23
+ hasRef,
24
+ hasRequired,
25
+ hasType,
26
+ hasUniqueItems,
27
+ isSchemaObject,
28
+ } from '@amritk/helpers/schema-guards'
29
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
30
+ import { findDiscriminator } from '#helpers/find-discriminator'
31
+
32
+ import { generateDiscriminatedUnionValidation } from './generate-discriminated-union-validation'
33
+ import { generateEnumCheck } from './generate-enum-check'
34
+ import { generateNonDiscriminatedUnionValidation } from './generate-non-discriminated-union-validation'
35
+ import { generateSchemaChecks } from './generate-schema-checks'
36
+
37
+ /**
38
+ * Generates a type coercion expression for converting a value to the expected type.
39
+ */
40
+ const getTypeCoercion = (accessor: string, schema: JSONSchema, defaultValue: string): string | null => {
41
+ if (!hasType(schema)) {
42
+ return null
43
+ }
44
+
45
+ switch (schema.type) {
46
+ case 'string':
47
+ return `String(${accessor})`
48
+ case 'number':
49
+ case 'integer': {
50
+ // Number() of a non-numeric string produces NaN, which silently poisons
51
+ // arithmetic. Guard with Number.isFinite and fall back to the default.
52
+ const n = `Number(${accessor})`
53
+ return `(Number.isFinite(${n}) ? ${n} : ${defaultValue})`
54
+ }
55
+ case 'boolean':
56
+ return `Boolean(${accessor})`
57
+ case 'array':
58
+ return `[]`
59
+ case 'object':
60
+ return `typeof ${accessor} === "object" && ${accessor} !== null ? ${accessor} : {}`
61
+ default:
62
+ return null
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Generates a validation expression that checks if a value matches the expected type.
68
+ * Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
69
+ * Supports type checking, pattern validation, enum validation, union types, and $ref resolution.
70
+ */
71
+ export const generateValidationExpression = (
72
+ key: string,
73
+ schema: JSONSchema,
74
+ defaultValue: string,
75
+ isRequired: boolean,
76
+ rootSchema?: Record<string, unknown>,
77
+ visitedRefs?: Set<string>,
78
+ accessorOverride?: string,
79
+ knownNotUndefined?: boolean,
80
+ ): string => {
81
+ const accessor = accessorOverride ?? safeAccessor('input?', key)
82
+ const checks: string[] = []
83
+
84
+ if (!isSchemaObject(schema)) {
85
+ return isRequired ? `${accessor} ?? ${defaultValue}` : accessor
86
+ }
87
+
88
+ // Handle $ref - resolve and validate against the referenced schema
89
+ if (hasRef(schema) && rootSchema) {
90
+ const ref = schema.$ref
91
+
92
+ // If we have already visited this ref, we have a circular reference - return accessor to break cycle
93
+ if (visitedRefs?.has(ref)) {
94
+ return isRequired ? `${accessor} ?? ${defaultValue}` : accessor
95
+ }
96
+
97
+ const resolvedSchema = resolveRef(ref, rootSchema)
98
+ if (resolvedSchema) {
99
+ const visited = new Set(visitedRefs)
100
+ visited.add(ref)
101
+
102
+ return generateValidationExpression(key, resolvedSchema, defaultValue, isRequired, rootSchema, visited)
103
+ }
104
+ }
105
+
106
+ // Handle union types (oneOf, anyOf)
107
+ if (hasOneOf(schema) && schema.oneOf.length > 0) {
108
+ const discriminator = findDiscriminator(schema.oneOf)
109
+ if (discriminator) {
110
+ return generateDiscriminatedUnionValidation(accessor, schema.oneOf, discriminator, defaultValue, isRequired)
111
+ }
112
+ return generateNonDiscriminatedUnionValidation(accessor, schema.oneOf, defaultValue, isRequired)
113
+ }
114
+
115
+ if (hasAnyOf(schema) && schema.anyOf.length > 0) {
116
+ const discriminator = findDiscriminator(schema.anyOf)
117
+ if (discriminator) {
118
+ return generateDiscriminatedUnionValidation(accessor, schema.anyOf, discriminator, defaultValue, isRequired)
119
+ }
120
+ return generateNonDiscriminatedUnionValidation(accessor, schema.anyOf, defaultValue, isRequired)
121
+ }
122
+
123
+ // Handle allOf (intersection - all schemas must match)
124
+ if (hasAllOf(schema) && schema.allOf.length > 0) {
125
+ const allChecks: string[] = []
126
+ for (const subSchema of schema.allOf) {
127
+ const subChecks = generateSchemaChecks(accessor, subSchema)
128
+ allChecks.push(...subChecks)
129
+ }
130
+
131
+ if (allChecks.length > 0) {
132
+ let combinedCheck = allChecks[0]
133
+ for (let i = 1; i < allChecks.length; i++) {
134
+ combinedCheck += ' && ' + allChecks[i]
135
+ }
136
+ return `${combinedCheck} ? ${accessor} : ${defaultValue}`
137
+ }
138
+ return `${accessor} ?? ${defaultValue}`
139
+ }
140
+
141
+ // Handle not (negation - value must NOT match this schema)
142
+ if ('not' in schema && schema.not) {
143
+ const notChecks = generateSchemaChecks(accessor, schema.not)
144
+ if (notChecks.length > 0) {
145
+ let combinedNotCheck = notChecks[0]
146
+ for (let i = 1; i < notChecks.length; i++) {
147
+ combinedNotCheck += ' && ' + notChecks[i]
148
+ }
149
+ return `!(${combinedNotCheck}) ? ${accessor} : ${defaultValue}`
150
+ }
151
+ }
152
+
153
+ // Handle const — single exact value that must match
154
+ if (hasConst(schema)) {
155
+ const constLiteral = JSON.stringify(schema.const)
156
+ return isRequired
157
+ ? `${accessor} === ${constLiteral} ? ${accessor} : ${constLiteral}`
158
+ : `${accessor} === ${constLiteral} ? ${accessor} : (${accessor} !== undefined ? ${constLiteral} : undefined)`
159
+ }
160
+
161
+ if (!hasType(schema) && !hasEnum(schema)) {
162
+ return `${accessor} ?? ${defaultValue}`
163
+ }
164
+
165
+ // Build type-specific checks
166
+ if (hasType(schema)) {
167
+ switch (schema.type) {
168
+ case 'string': {
169
+ checks.push(`typeof ${accessor} === "string"`)
170
+ if (hasPattern(schema)) {
171
+ checks.push(`/${schema.pattern.replace(/\//g, '\\/')}/.test(${accessor})`)
172
+ }
173
+ if (hasMinLength(schema)) {
174
+ checks.push(`${accessor}.length >= ${schema.minLength}`)
175
+ }
176
+ if (hasMaxLength(schema)) {
177
+ checks.push(`${accessor}.length <= ${schema.maxLength}`)
178
+ }
179
+ break
180
+ }
181
+ case 'number':
182
+ case 'integer': {
183
+ checks.push(`typeof ${accessor} === "number"`)
184
+ if (hasMinimum(schema)) {
185
+ checks.push(`${accessor} >= ${schema.minimum}`)
186
+ }
187
+ if (hasMaximum(schema)) {
188
+ checks.push(`${accessor} <= ${schema.maximum}`)
189
+ }
190
+ if (hasExclusiveMinimum(schema)) {
191
+ checks.push(`${accessor} > ${schema.exclusiveMinimum}`)
192
+ }
193
+ if (hasExclusiveMaximum(schema)) {
194
+ checks.push(`${accessor} < ${schema.exclusiveMaximum}`)
195
+ }
196
+ if (hasMultipleOf(schema)) {
197
+ checks.push(`${accessor} % ${schema.multipleOf} === 0`)
198
+ }
199
+ break
200
+ }
201
+ case 'boolean':
202
+ checks.push(`typeof ${accessor} === "boolean"`)
203
+ break
204
+ case 'array': {
205
+ checks.push(`Array.isArray(${accessor})`)
206
+ if (hasMinItems(schema)) {
207
+ checks.push(`${accessor}.length >= ${schema.minItems}`)
208
+ }
209
+ if (hasMaxItems(schema)) {
210
+ checks.push(`${accessor}.length <= ${schema.maxItems}`)
211
+ }
212
+ if (hasUniqueItems(schema) && schema.uniqueItems === true) {
213
+ checks.push(`new Set(${accessor}).size === ${accessor}.length`)
214
+ }
215
+ break
216
+ }
217
+ case 'object': {
218
+ checks.push(`isObject(${accessor})`)
219
+ // Check required properties exist
220
+ if (hasRequired(schema) && schema.required.length > 0) {
221
+ for (const requiredKey of schema.required) {
222
+ checks.push(`"${requiredKey}" in ${accessor}`)
223
+ }
224
+ }
225
+ // Check minProperties
226
+ if (hasMinProperties(schema)) {
227
+ checks.push(`Object.keys(${accessor}).length >= ${schema.minProperties}`)
228
+ }
229
+ // Check maxProperties
230
+ if (hasMaxProperties(schema)) {
231
+ checks.push(`Object.keys(${accessor}).length <= ${schema.maxProperties}`)
232
+ }
233
+ // Check additionalProperties
234
+ if (hasAdditionalProperties(schema) && schema.additionalProperties === false && hasProperties(schema)) {
235
+ const allowedKeys = Object.keys(schema.properties)
236
+ const allowedKeysStr = JSON.stringify(allowedKeys)
237
+ checks.push(`Object.keys(${accessor}).every(k => ${allowedKeysStr}.includes(k))`)
238
+ }
239
+ break
240
+ }
241
+ }
242
+ }
243
+
244
+ // Add enum check if present
245
+ if (hasEnum(schema) && schema.enum.length > 0) {
246
+ checks.push(generateEnumCheck(accessor, schema.enum))
247
+ }
248
+
249
+ // Combine all checks
250
+ if (checks.length === 0) {
251
+ return isRequired ? `${accessor} ?? ${defaultValue}` : accessor
252
+ }
253
+
254
+ let combinedCheck = checks[0]
255
+ for (let i = 1; i < checks.length; i++) {
256
+ combinedCheck += ' && ' + checks[i]
257
+ }
258
+
259
+ // Generate the fallback value
260
+ // If the value exists but fails validation, try to coerce it
261
+ // If the value is missing and required, use the default
262
+ const typeCoercion = getTypeCoercion(accessor, schema, defaultValue)
263
+
264
+ if (isRequired) {
265
+ // For required fields: valid ? use_value : (exists ? coerce : default)
266
+ if (typeCoercion) {
267
+ // If we know the value is not undefined (e.g., we're inside an undefined check),
268
+ // we can skip the redundant undefined check
269
+ if (knownNotUndefined) {
270
+ return `${combinedCheck} ? ${accessor} : ${typeCoercion}`
271
+ }
272
+ return `${combinedCheck} ? ${accessor} : (${accessor} !== undefined ? ${typeCoercion} : ${defaultValue})`
273
+ }
274
+ return `${combinedCheck} ? ${accessor} : ${defaultValue}`
275
+ } else {
276
+ // For optional fields: valid ? use_value : (exists ? coerce : undefined)
277
+ if (typeCoercion) {
278
+ // If we know the value is not undefined, we can skip the check and just coerce
279
+ if (knownNotUndefined) {
280
+ return `${combinedCheck} ? ${accessor} : ${typeCoercion}`
281
+ }
282
+ return `${combinedCheck} ? ${accessor} : (${accessor} !== undefined ? ${typeCoercion} : undefined)`
283
+ }
284
+ return `${combinedCheck} ? ${accessor} : undefined`
285
+ }
286
+ }
@@ -0,0 +1,194 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { applySchemaExtensions } from './apply-schema-extensions'
5
+
6
+ describe('apply-schema-extensions', () => {
7
+ it('merges extension properties into schema properties', () => {
8
+ const schema: JSONSchema = {
9
+ type: 'object',
10
+ properties: {
11
+ name: { type: 'string' },
12
+ },
13
+ required: ['name'],
14
+ }
15
+
16
+ const result = applySchemaExtensions(schema, 'parameter', {
17
+ parameter: {
18
+ 'x-enabled': { type: 'boolean' },
19
+ },
20
+ })
21
+
22
+ expect(result).toEqual({
23
+ type: 'object',
24
+ properties: {
25
+ name: { type: 'string' },
26
+ 'x-enabled': { type: 'boolean' },
27
+ },
28
+ required: ['name'],
29
+ })
30
+ })
31
+
32
+ it('returns original schema when no extensions match the definition name', () => {
33
+ const schema: JSONSchema = {
34
+ type: 'object',
35
+ properties: {
36
+ name: { type: 'string' },
37
+ },
38
+ }
39
+
40
+ const result = applySchemaExtensions(schema, 'parameter', {
41
+ operation: {
42
+ 'x-codegen': { type: 'string' },
43
+ },
44
+ })
45
+
46
+ expect(result).toBe(schema)
47
+ })
48
+
49
+ it('returns original schema when extensions record is empty', () => {
50
+ const schema: JSONSchema = {
51
+ type: 'object',
52
+ properties: {
53
+ name: { type: 'string' },
54
+ },
55
+ }
56
+
57
+ const result = applySchemaExtensions(schema, 'parameter', {})
58
+
59
+ expect(result).toBe(schema)
60
+ })
61
+
62
+ it('returns original schema when matching extensions have no properties', () => {
63
+ const schema: JSONSchema = {
64
+ type: 'object',
65
+ properties: {
66
+ name: { type: 'string' },
67
+ },
68
+ }
69
+
70
+ const result = applySchemaExtensions(schema, 'parameter', {
71
+ parameter: {},
72
+ })
73
+
74
+ expect(result).toBe(schema)
75
+ })
76
+
77
+ it('returns boolean schema unchanged', () => {
78
+ const result = applySchemaExtensions(true, 'parameter', {
79
+ parameter: {
80
+ 'x-enabled': { type: 'boolean' },
81
+ },
82
+ })
83
+
84
+ expect(result).toBe(true)
85
+ })
86
+
87
+ it('adds properties to a schema that has no existing properties', () => {
88
+ const schema: JSONSchema = {
89
+ type: 'object',
90
+ }
91
+
92
+ const result = applySchemaExtensions(schema, 'parameter', {
93
+ parameter: {
94
+ 'x-enabled': { type: 'boolean' },
95
+ },
96
+ })
97
+
98
+ expect(result).toEqual({
99
+ type: 'object',
100
+ properties: {
101
+ 'x-enabled': { type: 'boolean' },
102
+ },
103
+ })
104
+ })
105
+
106
+ it('merges multiple extension properties', () => {
107
+ const schema: JSONSchema = {
108
+ type: 'object',
109
+ properties: {
110
+ name: { type: 'string' },
111
+ },
112
+ }
113
+
114
+ const result = applySchemaExtensions(schema, 'parameter', {
115
+ parameter: {
116
+ 'x-enabled': { type: 'boolean' },
117
+ 'x-internal': { type: 'boolean', default: false },
118
+ 'x-display-name': { type: 'string' },
119
+ },
120
+ })
121
+
122
+ const properties = (result as Record<string, unknown>)['properties'] as Record<string, unknown>
123
+ expect(Object.keys(properties)).toEqual(['name', 'x-enabled', 'x-internal', 'x-display-name'])
124
+ })
125
+
126
+ it('does not modify the required array', () => {
127
+ const schema: JSONSchema = {
128
+ type: 'object',
129
+ properties: {
130
+ name: { type: 'string' },
131
+ },
132
+ required: ['name'],
133
+ }
134
+
135
+ const result = applySchemaExtensions(schema, 'parameter', {
136
+ parameter: {
137
+ 'x-enabled': { type: 'boolean' },
138
+ },
139
+ })
140
+
141
+ expect((result as Record<string, unknown>)['required']).toEqual(['name'])
142
+ })
143
+
144
+ it('handles complex extension schemas', () => {
145
+ const schema: JSONSchema = {
146
+ type: 'object',
147
+ properties: {
148
+ name: { type: 'string' },
149
+ },
150
+ }
151
+
152
+ const result = applySchemaExtensions(schema, 'operation', {
153
+ operation: {
154
+ 'x-codegen': {
155
+ type: 'object',
156
+ properties: {
157
+ methodName: { type: 'string' },
158
+ deprecated: { type: 'boolean' },
159
+ },
160
+ required: ['methodName'],
161
+ },
162
+ },
163
+ })
164
+
165
+ const properties = (result as Record<string, unknown>)['properties'] as Record<string, JSONSchema>
166
+ expect(properties['x-codegen']).toEqual({
167
+ type: 'object',
168
+ properties: {
169
+ methodName: { type: 'string' },
170
+ deprecated: { type: 'boolean' },
171
+ },
172
+ required: ['methodName'],
173
+ })
174
+ })
175
+
176
+ it('does not mutate the original schema', () => {
177
+ const schema: JSONSchema = {
178
+ type: 'object',
179
+ properties: {
180
+ name: { type: 'string' },
181
+ },
182
+ }
183
+
184
+ const originalProperties = { ...((schema as Record<string, unknown>)['properties'] as Record<string, unknown>) }
185
+
186
+ applySchemaExtensions(schema, 'parameter', {
187
+ parameter: {
188
+ 'x-enabled': { type: 'boolean' },
189
+ },
190
+ })
191
+
192
+ expect((schema as Record<string, unknown>)['properties']).toEqual(originalProperties)
193
+ })
194
+ })
@@ -0,0 +1,63 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
2
+ import type { SchemaExtensions } from '#types/schema-extensions'
3
+
4
+ /**
5
+ * Merges custom extension properties into a schema's properties based on the
6
+ * definition name. Extension properties are added as optional (not included
7
+ * in the required array) since they are custom additions that may not always
8
+ * be present.
9
+ *
10
+ * If the schema is not an object, has no matching extensions, or the
11
+ * extensions record is empty, the original schema is returned unchanged.
12
+ *
13
+ * @param schema - The JSON Schema to extend
14
+ * @param definitionName - The name of the definition (e.g., "parameter")
15
+ * @param extensions - The extensions configuration mapping definition names to properties
16
+ * @returns A new schema with extension properties merged in, or the original if unchanged
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const schema = {
21
+ * type: 'object',
22
+ * properties: { name: { type: 'string' } },
23
+ * required: ['name'],
24
+ * }
25
+ *
26
+ * const extensions = {
27
+ * parameter: {
28
+ * 'x-enabled': { type: 'boolean' },
29
+ * },
30
+ * }
31
+ *
32
+ * const extended = applySchemaExtensions(schema, 'parameter', extensions)
33
+ * // extended.properties now includes 'x-enabled' alongside 'name'
34
+ * ```
35
+ */
36
+ export const applySchemaExtensions = (
37
+ schema: JSONSchema,
38
+ definitionName: string,
39
+ extensions: SchemaExtensions,
40
+ ): JSONSchema => {
41
+ const extensionProperties = extensions[definitionName]
42
+
43
+ if (!extensionProperties || Object.keys(extensionProperties).length === 0) {
44
+ return schema
45
+ }
46
+
47
+ if (typeof schema !== 'object' || schema === null) {
48
+ return schema
49
+ }
50
+
51
+ const existingProperties =
52
+ 'properties' in schema && typeof schema.properties === 'object' && schema.properties !== null
53
+ ? (schema.properties as Record<string, JSONSchema>)
54
+ : {}
55
+
56
+ return {
57
+ ...schema,
58
+ properties: {
59
+ ...existingProperties,
60
+ ...extensionProperties,
61
+ },
62
+ }
63
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { collectHelpers } from './collect-helpers'
4
+
5
+ describe('collect-helpers', () => {
6
+ describe('package mode', () => {
7
+ it('returns validateArray import when parser contains validateArray', () => {
8
+ const result = collectHelpers('const items = validateArray(input, parseItem)', 'package')
9
+ expect(result.imports).toEqual(["import { validateArray } from '@amritk/helpers/validate-array';"])
10
+ expect(result.used).toEqual(new Set(['validate-array']))
11
+ })
12
+
13
+ it('returns validateRecord import (and transitively flags is-object) for embedded shipping', () => {
14
+ const result = collectHelpers('const record = validateRecord(input, parseValue)', 'package')
15
+ expect(result.imports).toEqual(["import { validateRecord } from '@amritk/helpers/validate-record';"])
16
+ expect(result.used).toEqual(new Set(['validate-record', 'is-object']))
17
+ })
18
+
19
+ it('returns isObject import when parser contains isObject', () => {
20
+ const result = collectHelpers('if (isObject(input)) {', 'package')
21
+ expect(result.imports).toEqual(["import { isObject } from '@amritk/helpers/is-object';"])
22
+ expect(result.used).toEqual(new Set(['is-object']))
23
+ })
24
+
25
+ it('returns hasRef import from schema-guards when parser contains hasRef(', () => {
26
+ const result = collectHelpers('if (hasRef(s)) { return parseFoo(input) }', 'package')
27
+ expect(result.imports).toEqual(["import { hasRef } from '@amritk/helpers/schema-guards';"])
28
+ expect(result.used).toEqual(new Set(['has-ref']))
29
+ })
30
+
31
+ it('returns all imports when parser uses every helper', () => {
32
+ const parser = `
33
+ const arr = validateArray(input.items, parseItem)
34
+ const rec = validateRecord(input.map, parseValue)
35
+ if (isObject(input)) { return input }
36
+ if (hasRef(schema)) { /* */ }
37
+ `
38
+ const result = collectHelpers(parser, 'package')
39
+ expect(result.imports).toHaveLength(4)
40
+ expect(result.imports).toContain("import { validateArray } from '@amritk/helpers/validate-array';")
41
+ expect(result.imports).toContain("import { validateRecord } from '@amritk/helpers/validate-record';")
42
+ expect(result.imports).toContain("import { isObject } from '@amritk/helpers/is-object';")
43
+ expect(result.imports).toContain("import { hasRef } from '@amritk/helpers/schema-guards';")
44
+ expect(result.used).toEqual(new Set(['validate-array', 'validate-record', 'is-object', 'has-ref']))
45
+ })
46
+
47
+ it('returns empty result when parser uses no helpers', () => {
48
+ const result = collectHelpers('const x = input.name ?? ""', 'package')
49
+ expect(result.imports).toEqual([])
50
+ expect(result.used.size).toBe(0)
51
+ })
52
+
53
+ it('handles empty parser string', () => {
54
+ const result = collectHelpers('', 'package')
55
+ expect(result.imports).toEqual([])
56
+ expect(result.used.size).toBe(0)
57
+ })
58
+
59
+ it('matches helpers as substrings in longer identifiers (existing .includes() behaviour)', () => {
60
+ const result = collectHelpers('const validateArrayItems = true', 'package')
61
+ expect(result.imports).toHaveLength(1)
62
+ expect(result.used.has('validate-array')).toBe(true)
63
+ })
64
+ })
65
+
66
+ describe('embedded mode', () => {
67
+ it('emits relative imports targeting ./_helpers/', () => {
68
+ const parser = `
69
+ const arr = validateArray(input.items, parseItem)
70
+ const rec = validateRecord(input.map, parseValue)
71
+ if (isObject(input)) { return input }
72
+ if (hasRef(schema)) { /* */ }
73
+ `
74
+ const result = collectHelpers(parser, 'embedded')
75
+ expect(result.imports).toContain("import { validateArray } from './_helpers/validate-array';")
76
+ expect(result.imports).toContain("import { validateRecord } from './_helpers/validate-record';")
77
+ expect(result.imports).toContain("import { isObject } from './_helpers/is-object';")
78
+ expect(result.imports).toContain("import { hasRef } from './_helpers/has-ref';")
79
+ })
80
+
81
+ it('marks is-object as used when validateRecord is referenced (transitive dep)', () => {
82
+ // validate-record.ts imports isObject, so the embedded build must ship is-object too,
83
+ // even if the generated parser body itself never mentions isObject directly.
84
+ const result = collectHelpers('const record = validateRecord(input, parseValue)', 'embedded')
85
+ expect(result.used.has('validate-record')).toBe(true)
86
+ expect(result.used.has('is-object')).toBe(true)
87
+ })
88
+
89
+ it('does not duplicate the isObject import line when only validateRecord is referenced', () => {
90
+ const result = collectHelpers('const record = validateRecord(input, parseValue)', 'embedded')
91
+ expect(result.imports).toEqual(["import { validateRecord } from './_helpers/validate-record';"])
92
+ })
93
+
94
+ it('emits both validateRecord and isObject import lines when both names appear', () => {
95
+ const result = collectHelpers(
96
+ 'if (!isObject(input)) return {}; const r = validateRecord(input, parse)',
97
+ 'embedded',
98
+ )
99
+ expect(result.imports).toContain("import { validateRecord } from './_helpers/validate-record';")
100
+ expect(result.imports).toContain("import { isObject } from './_helpers/is-object';")
101
+ })
102
+ })
103
+ })