schema-dsl 2.0.0 → 2.0.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 (145) hide show
  1. package/CHANGELOG.md +130 -113
  2. package/LICENSE +21 -21
  3. package/README.md +628 -628
  4. package/dist/{DslBuilder-DkLaOo9Q.d.ts → DslBuilder-BIgQOAXp.d.ts} +2 -0
  5. package/dist/{DslBuilder-DQDN0ZxZ.d.cts → DslBuilder-CjHTucNQ.d.cts} +2 -0
  6. package/dist/{Validator-hFWKGxir.d.ts → Validator-CllRdrY0.d.ts} +1 -1
  7. package/dist/{Validator-C7GsVQOH.d.cts → Validator-D6okG9tr.d.cts} +1 -1
  8. package/dist/index.cjs +75 -29
  9. package/dist/index.d.cts +10 -4
  10. package/dist/index.d.ts +10 -4
  11. package/dist/index.js +75 -29
  12. package/dist/plugins/custom-format.cjs +33 -17
  13. package/dist/plugins/custom-format.d.cts +1 -1
  14. package/dist/plugins/custom-format.d.ts +1 -1
  15. package/dist/plugins/custom-format.js +33 -17
  16. package/dist/plugins/custom-type-example.cjs +33 -17
  17. package/dist/plugins/custom-type-example.d.cts +1 -1
  18. package/dist/plugins/custom-type-example.d.ts +1 -1
  19. package/dist/plugins/custom-type-example.js +33 -17
  20. package/dist/plugins/custom-validator.cjs +0 -2
  21. package/dist/plugins/custom-validator.d.cts +1 -1
  22. package/dist/plugins/custom-validator.d.ts +1 -1
  23. package/dist/plugins/custom-validator.js +0 -2
  24. package/docs/FEATURE-INDEX.md +553 -553
  25. package/docs/add-custom-locale.md +496 -496
  26. package/docs/add-keyword.md +24 -24
  27. package/docs/api-reference.md +1047 -1047
  28. package/docs/api.md +13 -13
  29. package/docs/best-practices-project-structure.md +417 -417
  30. package/docs/best-practices.md +712 -712
  31. package/docs/cache-manager.md +344 -344
  32. package/docs/compile.md +45 -45
  33. package/docs/conditional-api.md +1307 -1307
  34. package/docs/custom-extensions-guide.md +339 -339
  35. package/docs/design-philosophy.md +606 -606
  36. package/docs/doc-index.md +324 -324
  37. package/docs/dsl-syntax.md +714 -714
  38. package/docs/dynamic-locale.md +608 -608
  39. package/docs/enum.md +482 -482
  40. package/docs/error-handling.md +1975 -1975
  41. package/docs/export-guide.md +501 -501
  42. package/docs/export-limitations.md +567 -567
  43. package/docs/faq.md +596 -596
  44. package/docs/frontend-i18n-guide.md +307 -307
  45. package/docs/i18n-user-guide.md +487 -487
  46. package/docs/i18n.md +476 -476
  47. package/docs/index.md +48 -48
  48. package/docs/json-schema-basics.md +40 -40
  49. package/docs/label-vs-description.md +271 -271
  50. package/docs/markdown-exporter.md +406 -406
  51. package/docs/mongodb-exporter.md +302 -302
  52. package/docs/multi-language.md +26 -26
  53. package/docs/multi-type-support.md +322 -322
  54. package/docs/mysql-exporter.md +280 -280
  55. package/docs/number-operators.md +449 -449
  56. package/docs/optional-marker-guide.md +326 -326
  57. package/docs/performance-guide.md +49 -49
  58. package/docs/plugin-system.md +381 -381
  59. package/docs/plugin-type-registration.md +34 -34
  60. package/docs/postgresql-exporter.md +311 -311
  61. package/docs/public/favicon.svg +4 -4
  62. package/docs/quick-start.md +435 -435
  63. package/docs/runtime-locale-support.md +532 -532
  64. package/docs/schema-helper.md +345 -345
  65. package/docs/schema-utils-advanced-issues.md +23 -23
  66. package/docs/schema-utils-best-practices.md +20 -20
  67. package/docs/schema-utils-chaining.md +150 -150
  68. package/docs/schema-utils.md +524 -524
  69. package/docs/security-checklist.md +20 -20
  70. package/docs/string-extensions.md +488 -488
  71. package/docs/troubleshooting.md +486 -486
  72. package/docs/type-converter.md +310 -310
  73. package/docs/type-reference.md +242 -242
  74. package/docs/typescript-guide.md +584 -584
  75. package/docs/union-type-guide.md +157 -157
  76. package/docs/union-types.md +284 -284
  77. package/docs/validate-async.md +491 -491
  78. package/docs/validate-batch.md +49 -49
  79. package/docs/validate-dsl-object-support.md +578 -578
  80. package/docs/validate.md +506 -506
  81. package/docs/validation-guide.md +502 -502
  82. package/docs/validator.md +39 -39
  83. package/package.json +131 -131
  84. package/plugins/custom-format.cjs +8 -8
  85. package/plugins/custom-type-example.cjs +8 -8
  86. package/plugins/custom-validator.cjs +8 -8
  87. package/src/adapters/DslAdapter.ts +111 -111
  88. package/src/adapters/index.ts +1 -1
  89. package/src/config/constants.ts +83 -83
  90. package/src/config/index.ts +2 -2
  91. package/src/config/patterns.ts +77 -77
  92. package/src/core/CacheManager.ts +169 -159
  93. package/src/core/ConditionalBuilder.ts +382 -382
  94. package/src/core/ConditionalRuntime.ts +27 -27
  95. package/src/core/ConditionalValidator.ts +254 -254
  96. package/src/core/DslBuilder.ts +687 -677
  97. package/src/core/ErrorCodes.ts +38 -38
  98. package/src/core/ErrorFormatter.ts +271 -271
  99. package/src/core/JSONSchemaCore.ts +65 -65
  100. package/src/core/Locale.ts +187 -187
  101. package/src/core/MessageTemplate.ts +42 -42
  102. package/src/core/ObjectDslBuilder.ts +64 -64
  103. package/src/core/PluginManager.ts +326 -326
  104. package/src/core/StringExtensions.ts +140 -140
  105. package/src/core/TemplateEngine.ts +44 -44
  106. package/src/core/Validator.ts +448 -448
  107. package/src/errors/I18nError.ts +159 -159
  108. package/src/errors/ValidationError.ts +105 -105
  109. package/src/exporters/BaseExporter.ts +60 -60
  110. package/src/exporters/MarkdownExporter.ts +305 -305
  111. package/src/exporters/MongoDBExporter.ts +126 -126
  112. package/src/exporters/MySQLExporter.ts +156 -155
  113. package/src/exporters/PostgreSQLExporter.ts +222 -222
  114. package/src/exporters/index.ts +18 -18
  115. package/src/index.ts +651 -633
  116. package/src/locales/en-US.ts +160 -160
  117. package/src/locales/es-ES.ts +160 -160
  118. package/src/locales/fr-FR.ts +160 -160
  119. package/src/locales/index.ts +103 -103
  120. package/src/locales/ja-JP.ts +160 -160
  121. package/src/locales/types.ts +156 -156
  122. package/src/locales/zh-CN.ts +160 -160
  123. package/src/parser/ConstraintParser.ts +101 -101
  124. package/src/parser/DslParser.ts +470 -470
  125. package/src/parser/SchemaCompiler.ts +66 -66
  126. package/src/parser/TypeRegistry.ts +250 -250
  127. package/src/parser/index.ts +6 -6
  128. package/src/plugins/custom-format.ts +124 -126
  129. package/src/plugins/custom-type-example.ts +106 -108
  130. package/src/plugins/custom-validator.ts +138 -140
  131. package/src/types/conditional.ts +28 -28
  132. package/src/types/config.ts +59 -59
  133. package/src/types/dsl.ts +131 -131
  134. package/src/types/error.ts +60 -60
  135. package/src/types/index.ts +17 -17
  136. package/src/types/infer.ts +127 -127
  137. package/src/types/plugin.ts +58 -58
  138. package/src/types/safe-regex.d.ts +9 -9
  139. package/src/types/schema.ts +66 -66
  140. package/src/types/validate.ts +71 -71
  141. package/src/utils/SchemaHelper.ts +196 -196
  142. package/src/utils/SchemaUtils.ts +365 -346
  143. package/src/utils/TypeConverter.ts +215 -215
  144. package/src/utils/index.ts +10 -10
  145. package/src/validators/CustomKeywords.ts +477 -477
package/src/index.ts CHANGED
@@ -1,633 +1,651 @@
1
- /**
2
- * schema-dsl v2 — main entry point
3
- *
4
- * Fix IX-01: VERSION is read dynamically from package.json instead of being hard-coded
5
- *
6
- * @module schema-dsl
7
- * @version 2.0.0
8
- */
9
-
10
- // ==================== Version (fix IX-01) ====================
11
- import pkg from '../package.json' with { type: 'json' }
12
- export const VERSION: string = (pkg as { version: string }).version
13
-
14
- // ==================== Core classes ====================
15
- export { Validator } from './core/Validator.js'
16
- export { JSONSchemaCore } from './core/JSONSchemaCore.js'
17
- export { DslBuilder } from './core/DslBuilder.js'
18
- export { ConditionalBuilder } from './core/ConditionalBuilder.js'
19
- export { ObjectDslBuilder } from './core/ObjectDslBuilder.js'
20
- export { Locale } from './core/Locale.js'
21
- export { CacheManager } from './core/CacheManager.js'
22
- export { ErrorFormatter } from './core/ErrorFormatter.js'
23
- export { MessageTemplate } from './core/MessageTemplate.js'
24
- export { renderTemplate } from './core/TemplateEngine.js'
25
- export { PluginManager } from './core/PluginManager.js'
26
-
27
- // ==================== Parser layer ====================
28
- export { TypeRegistry } from './parser/TypeRegistry.js'
29
-
30
- // ==================== Error classes ====================
31
- export { ValidationError } from './errors/ValidationError.js'
32
- export { I18nError } from './errors/I18nError.js'
33
-
34
- // ==================== String extensions ====================
35
- export { uninstallStringExtensions } from './core/StringExtensions.js'
36
-
37
- // ==================== Exporters ====================
38
- export {
39
- BaseExporter,
40
- MongoDBExporter,
41
- MySQLExporter,
42
- PostgreSQLExporter,
43
- MarkdownExporter,
44
- } from './exporters/index.js'
45
-
46
- // ==================== Utilities ====================
47
- export { TypeConverter, SchemaHelper, SchemaUtils } from './utils/index.js'
48
-
49
- // ==================== Validator extensions ====================
50
- export { CustomKeywords } from './validators/CustomKeywords.js'
51
-
52
- // ==================== Constants ====================
53
- export { VALIDATION, CACHE, FORMATS, PATTERN_IPV4, PATTERN_IPV6 } from './config/constants.js'
54
- export { ErrorCodes } from './core/ErrorCodes.js'
55
- export { PATTERNS } from './config/patterns.js'
56
-
57
- // ==================== Type exports ====================
58
- export type { JSONSchema, SchemaIOOptions } from './types/schema.js'
59
-
60
- export type {
61
- IDslBuilder,
62
- DslDefinition,
63
- DslField,
64
- DslInput,
65
- DslFn,
66
- DslIfFn,
67
- DslConditionMarker,
68
- DslErrorNamespace,
69
- } from './types/dsl.js'
70
-
71
- export type {
72
- ValidateOptions,
73
- ValidationResult,
74
- ValidationErrorItem,
75
- } from './types/validate.js'
76
-
77
- export type { DslConfigOptions, I18nConfig, CacheOptions, ValidatorOptions } from './types/config.js'
78
- // v1 BC: CacheConfig was renamed to CacheOptions in v2
79
- export type { CacheOptions as CacheConfig } from './types/config.js'
80
-
81
- export type { IConditionalBuilder } from './types/conditional.js'
82
-
83
- export type {
84
- InferSchema,
85
- InferJsonSchema,
86
- InferDslDefinition,
87
- InferDslString,
88
- } from './types/infer.js'
89
-
90
- export type {
91
- ExporterOptions,
92
- MongoDBExporterOptions,
93
- MySQLExporterOptions,
94
- PostgreSQLExporterOptions,
95
- MarkdownExporterOptions,
96
- } from './exporters/index.js'
97
-
98
- // ==================== dsl function (main API) ====================
99
-
100
- import { DslBuilder as _DslBuilder } from './core/DslBuilder.js'
101
- import { TypeRegistry as _TypeRegistry } from './parser/TypeRegistry.js'
102
- import { DslAdapter as _DslAdapter } from './adapters/DslAdapter.js'
103
- import { ConditionalBuilder as _ConditionalBuilder } from './core/ConditionalBuilder.js'
104
- import { Locale as _Locale } from './core/Locale.js'
105
- import { installStringExtensions as _install } from './core/StringExtensions.js'
106
- import { PATTERNS as _PATTERNS } from './config/patterns.js'
107
- import * as _CONSTANTS from './config/constants.js'
108
- import * as _exporters from './exporters/index.js'
109
- import { Validator as _Validator } from './core/Validator.js'
110
- import { I18nError as _I18nError } from './errors/I18nError.js'
111
- import type { LocaleMessage as _LocaleMessage } from './locales/types.js'
112
- import type { JSONSchema as _JSONSchema } from './types/schema.js'
113
- import type { IDslBuilder as _IDslBuilder, DslDefinition as _DslDefinition, DslConditionMarker as _DslConditionMarker } from './types/dsl.js'
114
- import type { IConditionalBuilder as _IConditionalBuilder } from './types/conditional.js'
115
- import type { DslConfigOptions as _DslConfigOptions } from './types/config.js'
116
- import type { ValidationResult as _ValidationResult } from './types/validate.js'
117
- import JSON5 from 'json5'
118
- import { createRequire } from 'node:module'
119
- import { readdirSync, statSync, readFileSync } from 'node:fs'
120
- import { join, basename, extname } from 'node:path'
121
-
122
- export const CONSTANTS = _CONSTANTS
123
- export const exporters = _exporters
124
-
125
- // Import all default locales for automatic initialization
126
- import * as _locales from './locales/index.js'
127
-
128
- // Initialize default locales at module load time
129
- ; (() => {
130
- for (const [locale, messages] of Object.entries(_locales)) {
131
- _Locale.addLocale(locale, messages as Record<string, string>)
132
- }
133
- })()
134
-
135
- // ==================== smartCoerceTypes ====================
136
-
137
- // Perf O5b: pre-compute the set of coercible field candidates for a schema
138
- // Avoids scanning all keys of `data` on every smartCoerceTypes call.
139
- // Only iterates fields that may need coercion (numbers/arrays/objects).
140
- type _CoerceCandidates = {
141
- numbers: string[] // fields with type: 'number' | 'integer'
142
- booleans: string[] // fields with type: 'boolean'
143
- arrays: Array<{ key: string; itemType: 'number' | 'integer' | 'boolean' }>
144
- objects: Array<{ key: string; schema: _JSONSchema }> // nested objects with properties
145
- } | null // null = no coercible fields
146
-
147
- const _coerceCandidatesCache = new WeakMap<object, _CoerceCandidates>()
148
-
149
- function _getCoerceCandidates(schema: _JSONSchema): _CoerceCandidates {
150
- const schemaObj = schema as object
151
- const cached = _coerceCandidatesCache.get(schemaObj)
152
- if (cached !== undefined) return cached
153
-
154
- const props = schema.properties as Record<string, _JSONSchema> | undefined
155
- if (!props) {
156
- _coerceCandidatesCache.set(schemaObj, null)
157
- return null
158
- }
159
-
160
- const numbers: string[] = []
161
- const booleans: string[] = []
162
- const arrays: Array<{ key: string; itemType: 'number' | 'integer' | 'boolean' }> = []
163
- const objects: Array<{ key: string; schema: _JSONSchema }> = []
164
-
165
- for (const [key, f] of Object.entries(props)) {
166
- if (f.enum) continue
167
- const ft = f.type
168
- if (ft === 'number' || ft === 'integer') {
169
- numbers.push(key)
170
- } else if (ft === 'boolean') {
171
- booleans.push(key)
172
- } else if (ft === 'array' && (f.items as _JSONSchema | undefined)?.type === 'number') {
173
- arrays.push({ key, itemType: 'number' })
174
- } else if (ft === 'array' && (f.items as _JSONSchema | undefined)?.type === 'integer') {
175
- arrays.push({ key, itemType: 'integer' })
176
- } else if (ft === 'array' && (f.items as _JSONSchema | undefined)?.type === 'boolean') {
177
- arrays.push({ key, itemType: 'boolean' })
178
- } else if (ft === 'object' && f.properties) {
179
- objects.push({ key, schema: f })
180
- }
181
- }
182
-
183
- const result: _CoerceCandidates = (numbers.length || booleans.length || arrays.length || objects.length)
184
- ? { numbers, booleans, arrays, objects }
185
- : null
186
- _coerceCandidatesCache.set(schemaObj, result)
187
- return result
188
- }
189
-
190
- function _coerceNumber(value: unknown): unknown {
191
- if (typeof value !== 'string') return value
192
- const trimmed = value.trim()
193
- if (trimmed === '') return value
194
- const num = Number(trimmed)
195
- return !isNaN(num) ? num : value
196
- }
197
-
198
- function _coerceBoolean(value: unknown): unknown {
199
- if (typeof value !== 'string') return value
200
- const trimmed = value.trim().toLowerCase()
201
- if (trimmed === 'true') return true
202
- if (trimmed === 'false') return false
203
- return value
204
- }
205
-
206
- function smartCoerceTypes(data: unknown, schema: _JSONSchema): unknown {
207
- if (!data || typeof data !== 'object') return data
208
-
209
- if (Array.isArray(data)) {
210
- return data.map(item => smartCoerceTypes(item, schema))
211
- }
212
-
213
- // O5b: use pre-computed candidate list instead of Object.keys(data) scan
214
- // Only processes fields known to potentially need coercion
215
- const candidates = _getCoerceCandidates(schema)
216
- if (!candidates) return data // fast path: no coercible fields
217
-
218
- let result: Record<string, unknown> | null = null
219
- const src = data as Record<string, unknown>
220
-
221
- for (const key of candidates.numbers) {
222
- const value = src[key]
223
- const converted = _coerceNumber(value)
224
- if (converted !== value) {
225
- if (!result) result = { ...src }
226
- result[key] = converted
227
- }
228
- }
229
-
230
- for (const key of candidates.booleans) {
231
- const value = src[key]
232
- const converted = _coerceBoolean(value)
233
- if (converted !== value) {
234
- if (!result) result = { ...src }
235
- result[key] = converted
236
- }
237
- }
238
-
239
- for (const { key, itemType } of candidates.arrays) {
240
- const value = src[key]
241
- if (Array.isArray(value)) {
242
- const converted = value.map(item => {
243
- if (itemType === 'boolean') return _coerceBoolean(item)
244
- return _coerceNumber(item)
245
- })
246
- if (!result) result = { ...src }
247
- result[key] = converted
248
- }
249
- }
250
-
251
- for (const { key, schema: nestedSchema } of candidates.objects) {
252
- const value = src[key]
253
- if (value && typeof value === 'object' && !Array.isArray(value)) {
254
- const converted = smartCoerceTypes(value, nestedSchema)
255
- if (converted !== value) {
256
- if (!result) result = { ...src }
257
- result[key] = converted
258
- }
259
- }
260
- }
261
-
262
- return result ?? data // return original when no conversion needed (zero-copy)
263
- }
264
-
265
- // ==================== Top-level schema normalization (raw DSL object support) ====================
266
-
267
- const _JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'])
268
-
269
- function _isRawJsonSchemaLike(obj: Record<string, unknown>): boolean {
270
- if (typeof obj['type'] === 'string' && _JSON_SCHEMA_TYPES.has(obj['type'] as string)) return true
271
- if ('anyOf' in obj || 'oneOf' in obj || 'allOf' in obj || '$ref' in obj || '$defs' in obj || 'definitions' in obj) return true
272
-
273
- const props = obj['properties']
274
- if (props && typeof props === 'object' && !Array.isArray(props)) {
275
- const values = Object.values(props as Record<string, unknown>)
276
- if (values.length === 0) return true
277
- if (values.every(value => value && typeof value === 'object' && !Array.isArray(value) && _isRawJsonSchemaLike(value as Record<string, unknown>))) {
278
- return true
279
- }
280
- }
281
-
282
- const items = obj['items']
283
- if (items && typeof items === 'object' && !Array.isArray(items)) {
284
- return _isRawJsonSchemaLike(items as Record<string, unknown>)
285
- }
286
-
287
- return false
288
- }
289
-
290
- function _isDslObject(schema: unknown): schema is _DslDefinition {
291
- if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false
292
-
293
- const obj = schema as Record<string, unknown>
294
- if (typeof obj['toSchema'] === 'function') return false
295
- if (obj['_isConditional']) return false
296
-
297
- return !_isRawJsonSchemaLike(obj)
298
- }
299
-
300
- // Perf O6: cache _normalizeSchemaInput results for immutable raw JSON Schema objects only.
301
- // Plain DSL definition objects ({ email: 'email!' }) are mutable — skip cache to prevent
302
- // stale results when the caller mutates the object between validate() calls (N-04 fix).
303
- const _normalizeSchemaCache = new WeakMap<object, _JSONSchema>()
304
-
305
- function _normalizeSchemaInput(schema: _JSONSchema | _DslDefinition | _IDslBuilder | _IConditionalBuilder): _JSONSchema {
306
- if (!schema || typeof schema !== 'object') return schema as _JSONSchema
307
-
308
- const obj = schema as Record<string, unknown>
309
- if (typeof obj['toSchema'] === 'function') {
310
- // Mutable builders: never cache — schema changes as chain methods are called
311
- return (obj['toSchema'] as () => _JSONSchema)()
312
- }
313
- if (_isDslObject(schema)) {
314
- // Plain DSL definition objects are mutable — skip cache
315
- return _DslAdapter.parseObject(schema).toSchema()
316
- }
317
- // Raw JSON Schema objects: safe to cache (treated as immutable by convention)
318
- const schemaObj = schema as object
319
- const cached = _normalizeSchemaCache.get(schemaObj)
320
- if (cached !== undefined) return cached
321
- const result = schema as _JSONSchema
322
- _normalizeSchemaCache.set(schemaObj, result)
323
- return result
324
- }
325
-
326
- // ==================== i18n locale directory scan ====================
327
-
328
- const _LOCALE_NAME_RE = /^[a-z]{2,3}(-[A-Z]{2,4})?$/
329
- const _LOCALE_REQUIRE_EXTENSIONS = new Set(['.js', '.cjs', '.json'])
330
- const _LOCALE_TEXT_EXTENSIONS = new Set(['.jsonc', '.json5'])
331
-
332
- function _normalizeLocaleModule(moduleValue: unknown): Record<string, _LocaleMessage> | null {
333
- if (!moduleValue || typeof moduleValue !== 'object' || Array.isArray(moduleValue)) return null
334
-
335
- const raw = moduleValue as Record<string, unknown>
336
- const keys = Object.keys(raw)
337
- const defaultValue = raw['default']
338
- const nonMetaKeys = keys.filter(key => key !== '__esModule' && key !== 'default')
339
-
340
- if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue) && nonMetaKeys.length === 0) {
341
- return defaultValue as Record<string, _LocaleMessage>
342
- }
343
-
344
- return raw as Record<string, _LocaleMessage>
345
- }
346
-
347
- function _loadLocaleFile(fullPath: string, ext: string, _require: NodeRequire): Record<string, _LocaleMessage> | null {
348
- if (_LOCALE_TEXT_EXTENSIONS.has(ext)) {
349
- const rawText = readFileSync(fullPath, 'utf8')
350
- return _normalizeLocaleModule(JSON5.parse(rawText) as Record<string, _LocaleMessage>)
351
- }
352
-
353
- if (_LOCALE_REQUIRE_EXTENSIONS.has(ext)) {
354
- return _normalizeLocaleModule(_require(fullPath) as Record<string, _LocaleMessage>)
355
- }
356
-
357
- return null
358
- }
359
-
360
- function _loadLocalesFromDir(dirPath: string, strict = false): void {
361
- let _require: NodeRequire
362
- try {
363
- // ESM: import.meta.url is defined
364
- _require = createRequire(import.meta.url)
365
- } catch {
366
- // CJS fallback: import.meta.url is undefined
367
- _require = typeof require !== 'undefined' ? require : createRequire(__filename)
368
- }
369
-
370
- // Track registered keys per locale for conflict detection
371
- const registeredKeys = new Map<string, Map<string, string>>() // locale → key → filePath
372
-
373
- function scanDir(dir: string): void {
374
- let entries: string[]
375
- try {
376
- entries = readdirSync(dir)
377
- } catch {
378
- return
379
- }
380
- for (const entry of entries) {
381
- const fullPath = join(dir, entry)
382
- let stat
383
- try {
384
- stat = statSync(fullPath)
385
- } catch {
386
- continue
387
- }
388
- if (stat.isDirectory()) {
389
- scanDir(fullPath)
390
- } else {
391
- const ext = extname(entry).toLowerCase()
392
- if (!_LOCALE_REQUIRE_EXTENSIONS.has(ext) && !_LOCALE_TEXT_EXTENSIONS.has(ext)) continue
393
-
394
- const locale = basename(entry, ext)
395
- // Only load files that look like locale identifiers (e.g., zh-CN, en-US, zh, en)
396
- if (_LOCALE_NAME_RE.test(locale)) {
397
- try {
398
- const messages = _loadLocaleFile(fullPath, ext, _require)
399
- if (messages && typeof messages === 'object') {
400
- // Conflict detection
401
- if (!registeredKeys.has(locale)) registeredKeys.set(locale, new Map())
402
- const localeKeys = registeredKeys.get(locale)!
403
- for (const key of Object.keys(messages)) {
404
- if (localeKeys.has(key)) {
405
- const prevFile = localeKeys.get(key)!
406
- if (strict) {
407
- throw new Error(
408
- `i18n locale "${locale}" key conflict: "${key}" is defined in both "${prevFile}" and "${fullPath}"`
409
- )
410
- } else {
411
- console.warn(
412
- `[schema-dsl] i18n key conflict: "${locale}:${key}" is defined in "${prevFile}" and "${fullPath}" (using latter)`
413
- )
414
- }
415
- }
416
- localeKeys.set(key, fullPath)
417
- }
418
- _Locale.addLocale(locale, messages as Record<string, _LocaleMessage>)
419
- }
420
- } catch (err) {
421
- // Re-throw in strict mode; silently skip in default mode
422
- if (strict && err instanceof Error && err.message.includes('i18n locale')) throw err
423
- }
424
- }
425
- }
426
- }
427
- }
428
-
429
- scanDir(dirPath)
430
- }
431
-
432
- // ==================== dsl.config ====================
433
-
434
- function _dslConfig(options: Partial<_DslConfigOptions> = {}): void {
435
- const strict = (options as Record<string, unknown>)['strict'] === true
436
- _TypeRegistry.setStrict(strict)
437
-
438
- if (options.patterns) {
439
- const p = options.patterns as Record<string, unknown>
440
- if (p['phone']) Object.assign(_PATTERNS.phone, p['phone'])
441
- if (p['idCard']) Object.assign(_PATTERNS.idCard, p['idCard'])
442
- if (p['creditCard']) Object.assign(_PATTERNS.creditCard, p['creditCard'])
443
- }
444
-
445
- // Legacy phone/idCard/creditCard at top level (v1 compat)
446
- const raw = options as Record<string, unknown>
447
- if (raw['phone'] && typeof raw['phone'] === 'object') Object.assign(_PATTERNS.phone, raw['phone'])
448
- if (raw['idCard'] && typeof raw['idCard'] === 'object') Object.assign(_PATTERNS.idCard, raw['idCard'])
449
- if (raw['creditCard'] && typeof raw['creditCard'] === 'object') Object.assign(_PATTERNS.creditCard, raw['creditCard'])
450
-
451
- // Cache configuration — update default validator's cache options
452
- const cacheConfig = (options as Record<string, unknown>)['cache'] as Record<string, unknown> | undefined
453
- if (cacheConfig && typeof cacheConfig === 'object') {
454
- const validator = _getDefaultValidator()
455
- // Merge with existing options to preserve unspecified defaults
456
- validator.cache.options = {
457
- ...validator.cache.options,
458
- ...cacheConfig,
459
- } as Partial<{ maxSize: number; ttl: number; enabled: boolean; statsEnabled: boolean }>
460
- }
461
-
462
- if (options.i18n) {
463
- if (typeof options.i18n === 'string') {
464
- // Directory path: scan recursively for locale files
465
- _loadLocalesFromDir(options.i18n, strict)
466
- } else if (typeof options.i18n === 'object' && 'localesPath' in options.i18n) {
467
- // { localesPath: string } form
468
- _loadLocalesFromDir((options.i18n as { localesPath: string }).localesPath, strict)
469
- } else if (typeof options.i18n === 'object' && 'locales' in options.i18n) {
470
- // v1 / docs compat: { locales: { locale: messages } }
471
- const locales = (options.i18n as { locales: Record<string, Record<string, string>> }).locales
472
- for (const [locale, messages] of Object.entries(locales ?? {})) {
473
- _Locale.addLocale(locale, messages)
474
- }
475
- } else if (typeof options.i18n === 'object' && !Array.isArray(options.i18n)) {
476
- // Inline { locale: messages } mapping
477
- for (const [locale, messages] of Object.entries(options.i18n)) {
478
- _Locale.addLocale(locale, messages as Record<string, string>)
479
- }
480
- }
481
- }
482
- }
483
-
484
- // ==================== Default Validator singleton ====================
485
-
486
- let _defaultValidator: InstanceType<typeof _Validator> | null = null
487
-
488
- function _getDefaultValidator(): InstanceType<typeof _Validator> {
489
- if (!_defaultValidator) _defaultValidator = new _Validator()
490
- return _defaultValidator
491
- }
492
-
493
- export { _getDefaultValidator as getDefaultValidator }
494
-
495
- /**
496
- * Reset the default Validator singleton (useful for cleaning up state in test environments)
497
- */
498
- export function resetDefaultValidator(): void {
499
- _defaultValidator = null
500
- }
501
-
502
- /**
503
- * Reset global runtime state that may leak across tests, workers, or tenants.
504
- */
505
- export function resetRuntimeState(): void {
506
- resetDefaultValidator()
507
- _DslBuilder.clearCustomTypes()
508
- _Locale.reset()
509
- }
510
-
511
- // ==================== Convenience validation functions ====================
512
-
513
- /**
514
- * Convenience validate function (uses the default Validator singleton).
515
- * Automatically coerces string → number when options.coerce !== false.
516
- */
517
- export function validate<T = unknown>(
518
- schema: _JSONSchema | _DslDefinition | _IDslBuilder | _IConditionalBuilder,
519
- data: T,
520
- options: Record<string, unknown> = {},
521
- ): _ValidationResult<T> {
522
- const normalizedSchema = _normalizeSchemaInput(schema)
523
- const shouldCoerce = options['coerce'] !== false
524
- // O5b: use candidate-field cache instead of _hasCoercibleFields + Object.keys scan
525
- const coercedData = shouldCoerce && _getCoerceCandidates(normalizedSchema)
526
- ? smartCoerceTypes(data, normalizedSchema)
527
- : data
528
- return _getDefaultValidator().validate(normalizedSchema, coercedData as T, options)
529
- }
530
-
531
- /**
532
- * Convenience async validate function
533
- */
534
- export async function validateAsync<T = unknown>(
535
- schema: _JSONSchema | _DslDefinition | _IDslBuilder | _IConditionalBuilder,
536
- data: T,
537
- options: Record<string, unknown> = {},
538
- ): Promise<T> {
539
- const normalizedSchema = _normalizeSchemaInput(schema)
540
- const shouldCoerce = options['coerce'] !== false
541
- // O5b: use candidate-field cache instead of _hasCoercibleFields + Object.keys scan
542
- const coercedData = shouldCoerce && _getCoerceCandidates(normalizedSchema)
543
- ? smartCoerceTypes(data, normalizedSchema)
544
- : data
545
- return _getDefaultValidator().validateAsync(normalizedSchema, coercedData as T, options)
546
- }
547
-
548
- // ==================== dsl main function ====================
549
-
550
- // Core dsl function: string → IDslBuilder (chain), object definition → JSONSchema
551
- function _dslFn(def: string): _IDslBuilder
552
- function _dslFn(def: _DslDefinition): _JSONSchema
553
- function _dslFn(def: unknown): _IDslBuilder | _JSONSchema {
554
- if (typeof def === 'string') return new _DslBuilder(def)
555
- if (def === null || def === undefined || typeof def !== 'object' || Array.isArray(def)) {
556
- throw new Error('[schema-dsl] Invalid DSL definition: expected string or object')
557
- }
558
- return _DslAdapter.parseObject(def as _DslDefinition).toSchema() as _JSONSchema
559
- }
560
-
561
- // Namespace shape (mirrors DslFn interface in types/dsl.ts)
562
- const _dslWithNS = _dslFn as {
563
- (def: string): _IDslBuilder
564
- (def: _DslDefinition): _JSONSchema
565
- config: (options?: Partial<_DslConfigOptions>) => void
566
- if: {
567
- (condition: string, thenSchema: unknown, elseSchema?: unknown): _DslConditionMarker
568
- (condition: (data: unknown) => boolean): ReturnType<typeof _ConditionalBuilder.start>
569
- }
570
- _if: {
571
- (condition: string, thenSchema: unknown, elseSchema?: unknown): _DslConditionMarker
572
- (condition: (data: unknown) => boolean): ReturnType<typeof _ConditionalBuilder.start>
573
- }
574
- match: (value: unknown, cases: Record<string, unknown>) => _DslConditionMarker
575
- error: {
576
- create: typeof _I18nError.create
577
- throw: typeof _I18nError.throw
578
- assert: typeof _I18nError.assert
579
- [key: string]: unknown
580
- }
581
- }
582
-
583
- _dslWithNS.config = _dslConfig
584
-
585
- function _dslIf(condition: string, thenSchema: unknown, elseSchema?: unknown): _DslConditionMarker
586
- function _dslIf(condition: (data: unknown) => boolean): ReturnType<typeof _ConditionalBuilder.start>
587
- function _dslIf(condition: string | ((data: unknown) => boolean), thenSchema?: unknown, elseSchema?: unknown): _DslConditionMarker | ReturnType<typeof _ConditionalBuilder.start> {
588
- // When only a string is passed (no thenSchema), it's invalid — condition must be a function
589
- // When a string + thenSchema are passed, the string is a field name reference (v1 compat)
590
- if (typeof condition !== 'function' && thenSchema === undefined) {
591
- throw new Error('Condition must be a function')
592
- }
593
- if (typeof condition === 'string') {
594
- return _DslAdapter.if(condition, thenSchema, elseSchema) as _DslConditionMarker
595
- }
596
- return _ConditionalBuilder.start(condition)
597
- }
598
-
599
- _dslWithNS.if = _dslIf
600
- _dslWithNS._if = _dslIf
601
-
602
- _dslWithNS.match = (field: unknown, cases: Record<string, unknown>): _DslConditionMarker => {
603
- return _DslAdapter.match(String(field), cases) as _DslConditionMarker
604
- }
605
-
606
- _dslWithNS.error = {
607
- create: _I18nError.create.bind(_I18nError),
608
- throw: _I18nError.throw.bind(_I18nError),
609
- assert: _I18nError.assert.bind(_I18nError),
610
- }
611
-
612
- /**
613
- * dsl — main API entry point
614
- *
615
- * @example
616
- * // String DSL → DslBuilder (chainable)
617
- * const builder = dsl('email!').label('Email address')
618
- *
619
- * @example
620
- * // Object DSL JSON Schema
621
- * const schema = dsl({ email: 'email!', name: 'string:2-32!' })
622
- */
623
- export const dsl = _dslWithNS
624
-
625
- export default dsl
626
-
627
- export const config = _dslConfig
628
-
629
- export function installStringExtensions(dslFunction: Parameters<typeof _install>[0] = _dslWithNS as unknown as Parameters<typeof _install>[0]): void {
630
- _install(dslFunction)
631
- }
632
-
633
-
1
+ /**
2
+ * schema-dsl v2 — main entry point
3
+ *
4
+ * Fix IX-01: VERSION is read dynamically from package.json instead of being hard-coded
5
+ *
6
+ * @module schema-dsl
7
+ * @version 2.0.0
8
+ */
9
+
10
+ // ==================== Version (fix IX-01) ====================
11
+ import pkg from '../package.json' with { type: 'json' }
12
+ export const VERSION: string = (pkg as { version: string }).version
13
+
14
+ // ==================== Core classes ====================
15
+ export { Validator } from './core/Validator.js'
16
+ export { JSONSchemaCore } from './core/JSONSchemaCore.js'
17
+ export { DslBuilder } from './core/DslBuilder.js'
18
+ export { ConditionalBuilder } from './core/ConditionalBuilder.js'
19
+ export { ObjectDslBuilder } from './core/ObjectDslBuilder.js'
20
+ export { Locale } from './core/Locale.js'
21
+ export { CacheManager } from './core/CacheManager.js'
22
+ export { ErrorFormatter } from './core/ErrorFormatter.js'
23
+ export { MessageTemplate } from './core/MessageTemplate.js'
24
+ export { renderTemplate } from './core/TemplateEngine.js'
25
+ export { PluginManager } from './core/PluginManager.js'
26
+
27
+ // ==================== Parser layer ====================
28
+ export { TypeRegistry } from './parser/TypeRegistry.js'
29
+
30
+ // ==================== Error classes ====================
31
+ export { ValidationError } from './errors/ValidationError.js'
32
+ export { I18nError } from './errors/I18nError.js'
33
+
34
+ // ==================== String extensions ====================
35
+ export { uninstallStringExtensions } from './core/StringExtensions.js'
36
+
37
+ // ==================== Exporters ====================
38
+ export {
39
+ BaseExporter,
40
+ MongoDBExporter,
41
+ MySQLExporter,
42
+ PostgreSQLExporter,
43
+ MarkdownExporter,
44
+ } from './exporters/index.js'
45
+
46
+ // ==================== Utilities ====================
47
+ export { TypeConverter, SchemaHelper, SchemaUtils } from './utils/index.js'
48
+
49
+ // ==================== Validator extensions ====================
50
+ export { CustomKeywords } from './validators/CustomKeywords.js'
51
+
52
+ // ==================== Constants ====================
53
+ export { VALIDATION, CACHE, FORMATS, PATTERN_IPV4, PATTERN_IPV6 } from './config/constants.js'
54
+ export { ErrorCodes } from './core/ErrorCodes.js'
55
+ export { PATTERNS } from './config/patterns.js'
56
+
57
+ // ==================== Type exports ====================
58
+ export type { JSONSchema, SchemaIOOptions } from './types/schema.js'
59
+
60
+ export type {
61
+ IDslBuilder,
62
+ DslDefinition,
63
+ DslField,
64
+ DslInput,
65
+ DslFn,
66
+ DslIfFn,
67
+ DslConditionMarker,
68
+ DslErrorNamespace,
69
+ } from './types/dsl.js'
70
+
71
+ export type {
72
+ ValidateOptions,
73
+ ValidationResult,
74
+ ValidationErrorItem,
75
+ } from './types/validate.js'
76
+
77
+ export type { DslConfigOptions, I18nConfig, CacheOptions, ValidatorOptions } from './types/config.js'
78
+ // v1 BC: CacheConfig was renamed to CacheOptions in v2
79
+ export type { CacheOptions as CacheConfig } from './types/config.js'
80
+
81
+ export type { IConditionalBuilder } from './types/conditional.js'
82
+
83
+ export type {
84
+ InferSchema,
85
+ InferJsonSchema,
86
+ InferDslDefinition,
87
+ InferDslString,
88
+ } from './types/infer.js'
89
+
90
+ export type {
91
+ ExporterOptions,
92
+ MongoDBExporterOptions,
93
+ MySQLExporterOptions,
94
+ PostgreSQLExporterOptions,
95
+ MarkdownExporterOptions,
96
+ } from './exporters/index.js'
97
+
98
+ // ==================== dsl function (main API) ====================
99
+
100
+ import { DslBuilder as _DslBuilder } from './core/DslBuilder.js'
101
+ import { TypeRegistry as _TypeRegistry } from './parser/TypeRegistry.js'
102
+ import { DslAdapter as _DslAdapter } from './adapters/DslAdapter.js'
103
+ import { ConditionalBuilder as _ConditionalBuilder } from './core/ConditionalBuilder.js'
104
+ import { Locale as _Locale } from './core/Locale.js'
105
+ import { installStringExtensions as _install } from './core/StringExtensions.js'
106
+ import { PATTERNS as _PATTERNS } from './config/patterns.js'
107
+ import * as _CONSTANTS from './config/constants.js'
108
+ import * as _exporters from './exporters/index.js'
109
+ import { Validator as _Validator } from './core/Validator.js'
110
+ import { I18nError as _I18nError } from './errors/I18nError.js'
111
+ import type { LocaleMessage as _LocaleMessage } from './locales/types.js'
112
+ import type { JSONSchema as _JSONSchema } from './types/schema.js'
113
+ import type { IDslBuilder as _IDslBuilder, DslDefinition as _DslDefinition, DslConditionMarker as _DslConditionMarker } from './types/dsl.js'
114
+ import type { IConditionalBuilder as _IConditionalBuilder } from './types/conditional.js'
115
+ import type { DslConfigOptions as _DslConfigOptions } from './types/config.js'
116
+ import type { ValidationResult as _ValidationResult } from './types/validate.js'
117
+ import JSON5 from 'json5'
118
+ import { createRequire } from 'node:module'
119
+ import { readdirSync, statSync, readFileSync } from 'node:fs'
120
+ import { join, basename, extname } from 'node:path'
121
+
122
+ export const CONSTANTS = _CONSTANTS
123
+ export const exporters = _exporters
124
+
125
+ // Import all default locales for automatic initialization
126
+ import * as _locales from './locales/index.js'
127
+
128
+ // Initialize default locales at module load time
129
+ ; (() => {
130
+ for (const [locale, messages] of Object.entries(_locales)) {
131
+ _Locale.addLocale(locale, messages as Record<string, string>)
132
+ }
133
+ })()
134
+
135
+ // ==================== smartCoerceTypes ====================
136
+
137
+ // Perf O5b: pre-compute the set of coercible field candidates for a schema
138
+ // Avoids scanning all keys of `data` on every smartCoerceTypes call.
139
+ // Only iterates fields that may need coercion (numbers/arrays/objects).
140
+ type _CoerceCandidates = {
141
+ numbers: string[] // fields with type: 'number' | 'integer'
142
+ booleans: string[] // fields with type: 'boolean'
143
+ arrays: Array<{ key: string; itemType: 'number' | 'integer' | 'boolean' }>
144
+ objects: Array<{ key: string; schema: _JSONSchema }> // nested objects with properties
145
+ } | null // null = no coercible fields
146
+
147
+ const _coerceCandidatesCache = new WeakMap<object, _CoerceCandidates>()
148
+
149
+ function _getCoerceCandidates(schema: _JSONSchema): _CoerceCandidates {
150
+ const schemaObj = schema as object
151
+ const cached = _coerceCandidatesCache.get(schemaObj)
152
+ if (cached !== undefined) return cached
153
+
154
+ const props = schema.properties as Record<string, _JSONSchema> | undefined
155
+ if (!props) {
156
+ _coerceCandidatesCache.set(schemaObj, null)
157
+ return null
158
+ }
159
+
160
+ const numbers: string[] = []
161
+ const booleans: string[] = []
162
+ const arrays: Array<{ key: string; itemType: 'number' | 'integer' | 'boolean' }> = []
163
+ const objects: Array<{ key: string; schema: _JSONSchema }> = []
164
+
165
+ for (const [key, f] of Object.entries(props)) {
166
+ if (f.enum) continue
167
+ const ft = f.type
168
+ if (ft === 'number' || ft === 'integer') {
169
+ numbers.push(key)
170
+ } else if (ft === 'boolean') {
171
+ booleans.push(key)
172
+ } else if (ft === 'array' && (f.items as _JSONSchema | undefined)?.type === 'number') {
173
+ arrays.push({ key, itemType: 'number' })
174
+ } else if (ft === 'array' && (f.items as _JSONSchema | undefined)?.type === 'integer') {
175
+ arrays.push({ key, itemType: 'integer' })
176
+ } else if (ft === 'array' && (f.items as _JSONSchema | undefined)?.type === 'boolean') {
177
+ arrays.push({ key, itemType: 'boolean' })
178
+ } else if (ft === 'object' && f.properties) {
179
+ objects.push({ key, schema: f })
180
+ }
181
+ }
182
+
183
+ const result: _CoerceCandidates = (numbers.length || booleans.length || arrays.length || objects.length)
184
+ ? { numbers, booleans, arrays, objects }
185
+ : null
186
+ _coerceCandidatesCache.set(schemaObj, result)
187
+ return result
188
+ }
189
+
190
+ function _coerceNumber(value: unknown): unknown {
191
+ if (typeof value !== 'string') return value
192
+ const trimmed = value.trim()
193
+ if (trimmed === '') return value
194
+ const num = Number(trimmed)
195
+ return !isNaN(num) ? num : value
196
+ }
197
+
198
+ function _coerceBoolean(value: unknown): unknown {
199
+ if (typeof value !== 'string') return value
200
+ const trimmed = value.trim().toLowerCase()
201
+ if (trimmed === 'true') return true
202
+ if (trimmed === 'false') return false
203
+ return value
204
+ }
205
+
206
+ function smartCoerceTypes(data: unknown, schema: _JSONSchema): unknown {
207
+ if (!data || typeof data !== 'object') return data
208
+
209
+ if (Array.isArray(data)) {
210
+ return data.map(item => smartCoerceTypes(item, schema))
211
+ }
212
+
213
+ // O5b: use pre-computed candidate list instead of Object.keys(data) scan
214
+ // Only processes fields known to potentially need coercion
215
+ const candidates = _getCoerceCandidates(schema)
216
+ if (!candidates) return data // fast path: no coercible fields
217
+
218
+ let result: Record<string, unknown> | null = null
219
+ const src = data as Record<string, unknown>
220
+
221
+ for (const key of candidates.numbers) {
222
+ const value = src[key]
223
+ const converted = _coerceNumber(value)
224
+ if (converted !== value) {
225
+ if (!result) result = { ...src }
226
+ result[key] = converted
227
+ }
228
+ }
229
+
230
+ for (const key of candidates.booleans) {
231
+ const value = src[key]
232
+ const converted = _coerceBoolean(value)
233
+ if (converted !== value) {
234
+ if (!result) result = { ...src }
235
+ result[key] = converted
236
+ }
237
+ }
238
+
239
+ for (const { key, itemType } of candidates.arrays) {
240
+ const value = src[key]
241
+ if (Array.isArray(value)) {
242
+ const converted = value.map(item => {
243
+ if (itemType === 'boolean') return _coerceBoolean(item)
244
+ return _coerceNumber(item)
245
+ })
246
+ if (!result) result = { ...src }
247
+ result[key] = converted
248
+ }
249
+ }
250
+
251
+ for (const { key, schema: nestedSchema } of candidates.objects) {
252
+ const value = src[key]
253
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
254
+ const converted = smartCoerceTypes(value, nestedSchema)
255
+ if (converted !== value) {
256
+ if (!result) result = { ...src }
257
+ result[key] = converted
258
+ }
259
+ }
260
+ }
261
+
262
+ return result ?? data // return original when no conversion needed (zero-copy)
263
+ }
264
+
265
+ // ==================== Top-level schema normalization (raw DSL object support) ====================
266
+
267
+ const _JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'])
268
+
269
+ function _isRawJsonSchemaLike(obj: Record<string, unknown>): boolean {
270
+ if (typeof obj['type'] === 'string' && _JSON_SCHEMA_TYPES.has(obj['type'] as string)) return true
271
+ if ('anyOf' in obj || 'oneOf' in obj || 'allOf' in obj || '$ref' in obj || '$defs' in obj || 'definitions' in obj) return true
272
+
273
+ const props = obj['properties']
274
+ if (props && typeof props === 'object' && !Array.isArray(props)) {
275
+ const values = Object.values(props as Record<string, unknown>)
276
+ if (values.length === 0) return true
277
+ if (values.every(value => value && typeof value === 'object' && !Array.isArray(value) && _isRawJsonSchemaLike(value as Record<string, unknown>))) {
278
+ return true
279
+ }
280
+ }
281
+
282
+ const items = obj['items']
283
+ if (items && typeof items === 'object' && !Array.isArray(items)) {
284
+ return _isRawJsonSchemaLike(items as Record<string, unknown>)
285
+ }
286
+
287
+ return false
288
+ }
289
+
290
+ function _isDslObject(schema: unknown): schema is _DslDefinition {
291
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false
292
+
293
+ const obj = schema as Record<string, unknown>
294
+ if (typeof obj['toSchema'] === 'function') return false
295
+ if (obj['_isConditional']) return false
296
+
297
+ return !_isRawJsonSchemaLike(obj)
298
+ }
299
+
300
+ // Perf O6: cache _normalizeSchemaInput results for immutable raw JSON Schema objects only.
301
+ // Plain DSL definition objects ({ email: 'email!' }) are mutable — skip cache to prevent
302
+ // stale results when the caller mutates the object between validate() calls (N-04 fix).
303
+ const _normalizeSchemaCache = new WeakMap<object, _JSONSchema>()
304
+
305
+ function _normalizeSchemaInput(schema: _JSONSchema | _DslDefinition | _IDslBuilder | _IConditionalBuilder): _JSONSchema {
306
+ if (!schema || typeof schema !== 'object') return schema as _JSONSchema
307
+
308
+ const obj = schema as Record<string, unknown>
309
+ if (typeof obj['toSchema'] === 'function') {
310
+ // Mutable builders: never cache — schema changes as chain methods are called
311
+ return (obj['toSchema'] as () => _JSONSchema)()
312
+ }
313
+ if (_isDslObject(schema)) {
314
+ // Plain DSL definition objects are mutable — skip cache
315
+ return _DslAdapter.parseObject(schema).toSchema()
316
+ }
317
+ // Raw JSON Schema objects: safe to cache (treated as immutable by convention)
318
+ const schemaObj = schema as object
319
+ const cached = _normalizeSchemaCache.get(schemaObj)
320
+ if (cached !== undefined) return cached
321
+ const result = schema as _JSONSchema
322
+ _normalizeSchemaCache.set(schemaObj, result)
323
+ return result
324
+ }
325
+
326
+ // ==================== i18n locale directory scan ====================
327
+
328
+ const _LOCALE_NAME_RE = /^[a-z]{2,3}(-[A-Z]{2,4})?$/
329
+ const _LOCALE_REQUIRE_EXTENSIONS = new Set(['.js', '.cjs', '.json'])
330
+ const _LOCALE_TEXT_EXTENSIONS = new Set(['.jsonc', '.json5'])
331
+
332
+ function _normalizeLocaleModule(moduleValue: unknown): Record<string, _LocaleMessage> | null {
333
+ if (!moduleValue || typeof moduleValue !== 'object' || Array.isArray(moduleValue)) return null
334
+
335
+ const raw = moduleValue as Record<string, unknown>
336
+ const keys = Object.keys(raw)
337
+ const defaultValue = raw['default']
338
+ const nonMetaKeys = keys.filter(key => key !== '__esModule' && key !== 'default')
339
+
340
+ if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue) && nonMetaKeys.length === 0) {
341
+ return defaultValue as Record<string, _LocaleMessage>
342
+ }
343
+
344
+ return raw as Record<string, _LocaleMessage>
345
+ }
346
+
347
+ function _loadLocaleFile(fullPath: string, ext: string, _require: NodeRequire): Record<string, _LocaleMessage> | null {
348
+ if (_LOCALE_TEXT_EXTENSIONS.has(ext)) {
349
+ const rawText = readFileSync(fullPath, 'utf8')
350
+ return _normalizeLocaleModule(JSON5.parse(rawText) as Record<string, _LocaleMessage>)
351
+ }
352
+
353
+ if (_LOCALE_REQUIRE_EXTENSIONS.has(ext)) {
354
+ return _normalizeLocaleModule(_require(fullPath) as Record<string, _LocaleMessage>)
355
+ }
356
+
357
+ return null
358
+ }
359
+
360
+ function _loadLocalesFromDir(dirPath: string, strict = false): void {
361
+ let _require: NodeRequire
362
+ try {
363
+ // ESM: import.meta.url is defined
364
+ _require = createRequire(import.meta.url)
365
+ } catch {
366
+ // CJS fallback: import.meta.url is undefined
367
+ _require = typeof require !== 'undefined' ? require : createRequire(__filename)
368
+ }
369
+
370
+ // Track registered keys per locale for conflict detection
371
+ const registeredKeys = new Map<string, Map<string, string>>() // locale → key → filePath
372
+
373
+ function scanDir(dir: string): void {
374
+ let entries: string[]
375
+ try {
376
+ entries = readdirSync(dir)
377
+ } catch {
378
+ return
379
+ }
380
+ for (const entry of entries) {
381
+ const fullPath = join(dir, entry)
382
+ let stat
383
+ try {
384
+ stat = statSync(fullPath)
385
+ } catch {
386
+ continue
387
+ }
388
+ if (stat.isDirectory()) {
389
+ scanDir(fullPath)
390
+ } else {
391
+ const ext = extname(entry).toLowerCase()
392
+ if (!_LOCALE_REQUIRE_EXTENSIONS.has(ext) && !_LOCALE_TEXT_EXTENSIONS.has(ext)) continue
393
+
394
+ const locale = basename(entry, ext)
395
+ // Only load files that look like locale identifiers (e.g., zh-CN, en-US, zh, en)
396
+ if (_LOCALE_NAME_RE.test(locale)) {
397
+ try {
398
+ const messages = _loadLocaleFile(fullPath, ext, _require)
399
+ if (messages && typeof messages === 'object') {
400
+ // Conflict detection
401
+ if (!registeredKeys.has(locale)) registeredKeys.set(locale, new Map())
402
+ const localeKeys = registeredKeys.get(locale)!
403
+ for (const key of Object.keys(messages)) {
404
+ if (localeKeys.has(key)) {
405
+ const prevFile = localeKeys.get(key)!
406
+ if (strict) {
407
+ throw new Error(
408
+ `i18n locale "${locale}" key conflict: "${key}" is defined in both "${prevFile}" and "${fullPath}"`
409
+ )
410
+ } else {
411
+ console.warn(
412
+ `[schema-dsl] i18n key conflict: "${locale}:${key}" is defined in "${prevFile}" and "${fullPath}" (using latter)`
413
+ )
414
+ }
415
+ }
416
+ localeKeys.set(key, fullPath)
417
+ }
418
+ _Locale.addLocale(locale, messages as Record<string, _LocaleMessage>)
419
+ }
420
+ } catch (err) {
421
+ // Re-throw in strict mode; silently skip in default mode
422
+ if (strict && err instanceof Error && err.message.includes('i18n locale')) throw err
423
+ }
424
+ }
425
+ }
426
+ }
427
+ }
428
+
429
+ scanDir(dirPath)
430
+ }
431
+
432
+ // ==================== dsl.config ====================
433
+
434
+ function _dslConfig(options: Partial<_DslConfigOptions> = {}): void {
435
+ const strict = (options as Record<string, unknown>)['strict'] === true
436
+ _TypeRegistry.setStrict(strict)
437
+
438
+ if (options.patterns) {
439
+ const p = options.patterns as Record<string, unknown>
440
+ if (p['phone']) Object.assign(_PATTERNS.phone, p['phone'])
441
+ if (p['idCard']) Object.assign(_PATTERNS.idCard, p['idCard'])
442
+ if (p['creditCard']) Object.assign(_PATTERNS.creditCard, p['creditCard'])
443
+ }
444
+
445
+ // Legacy phone/idCard/creditCard at top level (v1 compat)
446
+ const raw = options as Record<string, unknown>
447
+ if (raw['phone'] && typeof raw['phone'] === 'object') Object.assign(_PATTERNS.phone, raw['phone'])
448
+ if (raw['idCard'] && typeof raw['idCard'] === 'object') Object.assign(_PATTERNS.idCard, raw['idCard'])
449
+ if (raw['creditCard'] && typeof raw['creditCard'] === 'object') Object.assign(_PATTERNS.creditCard, raw['creditCard'])
450
+
451
+ // Cache configuration — update default validator's cache options
452
+ const cacheConfig = (options as Record<string, unknown>)['cache'] as Record<string, unknown> | undefined
453
+ if (cacheConfig && typeof cacheConfig === 'object') {
454
+ const validator = _getDefaultValidator()
455
+ // Merge with existing options to preserve unspecified defaults
456
+ validator.cache.options = {
457
+ ...validator.cache.options,
458
+ ...cacheConfig,
459
+ } as Partial<{ maxSize: number; ttl: number; enabled: boolean; statsEnabled: boolean }>
460
+ }
461
+
462
+ if (options.i18n) {
463
+ if (typeof options.i18n === 'string') {
464
+ // Directory path: scan recursively for locale files
465
+ _loadLocalesFromDir(options.i18n, strict)
466
+ } else if (typeof options.i18n === 'object' && 'localesPath' in options.i18n) {
467
+ // { localesPath: string } form
468
+ _loadLocalesFromDir((options.i18n as { localesPath: string }).localesPath, strict)
469
+ } else if (typeof options.i18n === 'object' && 'locales' in options.i18n) {
470
+ // v1 / docs compat: { locales: { locale: messages } }
471
+ const locales = (options.i18n as { locales: Record<string, Record<string, string>> }).locales
472
+ for (const [locale, messages] of Object.entries(locales ?? {})) {
473
+ _Locale.addLocale(locale, messages)
474
+ }
475
+ } else if (typeof options.i18n === 'object' && !Array.isArray(options.i18n)) {
476
+ // Inline { locale: messages } mapping
477
+ for (const [locale, messages] of Object.entries(options.i18n)) {
478
+ _Locale.addLocale(locale, messages as Record<string, string>)
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ // ==================== Default Validator singleton ====================
485
+
486
+ let _defaultValidator: InstanceType<typeof _Validator> | null = null
487
+
488
+ function _getDefaultValidator(): InstanceType<typeof _Validator> {
489
+ if (!_defaultValidator) _defaultValidator = new _Validator()
490
+ return _defaultValidator
491
+ }
492
+
493
+ export { _getDefaultValidator as getDefaultValidator }
494
+
495
+ /**
496
+ * Reset the default Validator singleton (useful for cleaning up state in test environments)
497
+ */
498
+ export function resetDefaultValidator(): void {
499
+ _defaultValidator = null
500
+ }
501
+
502
+ // Initial PATTERNS keys snapshot — used by resetRuntimeState() to prune user-added patterns
503
+ const _INITIAL_PATTERN_KEYS = {
504
+ phone: new Set(Object.keys(_PATTERNS.phone)),
505
+ idCard: new Set(Object.keys(_PATTERNS.idCard)),
506
+ creditCard: new Set(Object.keys(_PATTERNS.creditCard)),
507
+ }
508
+
509
+ /**
510
+ * Reset global runtime state that may leak across tests, workers, or tenants.
511
+ */
512
+ export function resetRuntimeState(): void {
513
+ resetDefaultValidator()
514
+ _DslBuilder.clearCustomTypes()
515
+ _Locale.reset()
516
+ _TypeRegistry.setStrict(false)
517
+ // Remove any keys added to PATTERNS via dsl.config({ patterns })
518
+ for (const key of Object.keys(_PATTERNS.phone)) {
519
+ if (!_INITIAL_PATTERN_KEYS.phone.has(key)) delete _PATTERNS.phone[key]
520
+ }
521
+ for (const key of Object.keys(_PATTERNS.idCard)) {
522
+ if (!_INITIAL_PATTERN_KEYS.idCard.has(key)) delete _PATTERNS.idCard[key]
523
+ }
524
+ for (const key of Object.keys(_PATTERNS.creditCard)) {
525
+ if (!_INITIAL_PATTERN_KEYS.creditCard.has(key)) delete _PATTERNS.creditCard[key]
526
+ }
527
+ }
528
+
529
+ // ==================== Convenience validation functions ====================
530
+
531
+ /**
532
+ * Convenience validate function (uses the default Validator singleton).
533
+ * Automatically coerces string → number when options.coerce !== false.
534
+ */
535
+ export function validate<T = unknown>(
536
+ schema: _JSONSchema | _DslDefinition | _IDslBuilder | _IConditionalBuilder,
537
+ data: T,
538
+ options: Record<string, unknown> = {},
539
+ ): _ValidationResult<T> {
540
+ const normalizedSchema = _normalizeSchemaInput(schema)
541
+ const shouldCoerce = options['coerce'] !== false
542
+ // O5b: use candidate-field cache instead of _hasCoercibleFields + Object.keys scan
543
+ const coercedData = shouldCoerce && _getCoerceCandidates(normalizedSchema)
544
+ ? smartCoerceTypes(data, normalizedSchema)
545
+ : data
546
+ return _getDefaultValidator().validate(normalizedSchema, coercedData as T, options)
547
+ }
548
+
549
+ /**
550
+ * Convenience async validate function
551
+ */
552
+ export async function validateAsync<T = unknown>(
553
+ schema: _JSONSchema | _DslDefinition | _IDslBuilder | _IConditionalBuilder,
554
+ data: T,
555
+ options: Record<string, unknown> = {},
556
+ ): Promise<T> {
557
+ const normalizedSchema = _normalizeSchemaInput(schema)
558
+ const shouldCoerce = options['coerce'] !== false
559
+ // O5b: use candidate-field cache instead of _hasCoercibleFields + Object.keys scan
560
+ const coercedData = shouldCoerce && _getCoerceCandidates(normalizedSchema)
561
+ ? smartCoerceTypes(data, normalizedSchema)
562
+ : data
563
+ return _getDefaultValidator().validateAsync(normalizedSchema, coercedData as T, options)
564
+ }
565
+
566
+ // ==================== dsl main function ====================
567
+
568
+ // Core dsl function: string IDslBuilder (chain), object definition → JSONSchema
569
+ function _dslFn(def: string): _IDslBuilder
570
+ function _dslFn(def: _DslDefinition): _JSONSchema
571
+ function _dslFn(def: unknown): _IDslBuilder | _JSONSchema {
572
+ if (typeof def === 'string') return new _DslBuilder(def)
573
+ if (def === null || def === undefined || typeof def !== 'object' || Array.isArray(def)) {
574
+ throw new Error('[schema-dsl] Invalid DSL definition: expected string or object')
575
+ }
576
+ return _DslAdapter.parseObject(def as _DslDefinition).toSchema() as _JSONSchema
577
+ }
578
+
579
+ // Namespace shape (mirrors DslFn interface in types/dsl.ts)
580
+ const _dslWithNS = _dslFn as {
581
+ (def: string): _IDslBuilder
582
+ (def: _DslDefinition): _JSONSchema
583
+ config: (options?: Partial<_DslConfigOptions>) => void
584
+ if: {
585
+ (condition: string, thenSchema: unknown, elseSchema?: unknown): _DslConditionMarker
586
+ (condition: (data: unknown) => boolean): ReturnType<typeof _ConditionalBuilder.start>
587
+ }
588
+ _if: {
589
+ (condition: string, thenSchema: unknown, elseSchema?: unknown): _DslConditionMarker
590
+ (condition: (data: unknown) => boolean): ReturnType<typeof _ConditionalBuilder.start>
591
+ }
592
+ match: (value: unknown, cases: Record<string, unknown>) => _DslConditionMarker
593
+ error: {
594
+ create: typeof _I18nError.create
595
+ throw: typeof _I18nError.throw
596
+ assert: typeof _I18nError.assert
597
+ [key: string]: unknown
598
+ }
599
+ }
600
+
601
+ _dslWithNS.config = _dslConfig
602
+
603
+ function _dslIf(condition: string, thenSchema: unknown, elseSchema?: unknown): _DslConditionMarker
604
+ function _dslIf(condition: (data: unknown) => boolean): ReturnType<typeof _ConditionalBuilder.start>
605
+ function _dslIf(condition: string | ((data: unknown) => boolean), thenSchema?: unknown, elseSchema?: unknown): _DslConditionMarker | ReturnType<typeof _ConditionalBuilder.start> {
606
+ // When only a string is passed (no thenSchema), it's invalid — condition must be a function
607
+ // When a string + thenSchema are passed, the string is a field name reference (v1 compat)
608
+ if (typeof condition !== 'function' && thenSchema === undefined) {
609
+ throw new Error('Condition must be a function')
610
+ }
611
+ if (typeof condition === 'string') {
612
+ return _DslAdapter.if(condition, thenSchema, elseSchema) as _DslConditionMarker
613
+ }
614
+ return _ConditionalBuilder.start(condition)
615
+ }
616
+
617
+ _dslWithNS.if = _dslIf
618
+ _dslWithNS._if = _dslIf
619
+
620
+ _dslWithNS.match = (field: unknown, cases: Record<string, unknown>): _DslConditionMarker => {
621
+ return _DslAdapter.match(String(field), cases) as _DslConditionMarker
622
+ }
623
+
624
+ _dslWithNS.error = {
625
+ create: _I18nError.create.bind(_I18nError),
626
+ throw: _I18nError.throw.bind(_I18nError),
627
+ assert: _I18nError.assert.bind(_I18nError),
628
+ }
629
+
630
+ /**
631
+ * dsl — main API entry point
632
+ *
633
+ * @example
634
+ * // String DSL → DslBuilder (chainable)
635
+ * const builder = dsl('email!').label('Email address')
636
+ *
637
+ * @example
638
+ * // Object DSL → JSON Schema
639
+ * const schema = dsl({ email: 'email!', name: 'string:2-32!' })
640
+ */
641
+ export const dsl = _dslWithNS
642
+
643
+ export default dsl
644
+
645
+ export const config = _dslConfig
646
+
647
+ export function installStringExtensions(dslFunction: Parameters<typeof _install>[0] = _dslWithNS as unknown as Parameters<typeof _install>[0]): void {
648
+ _install(dslFunction)
649
+ }
650
+
651
+