jspurefix 5.2.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKPORT_PLAN.md +135 -39
- package/dist/config/js-fix-config.d.ts +2 -0
- package/dist/config/js-fix-config.js.map +1 -1
- package/dist/dictionary/parser/quickfix/dictionary-validator.d.ts +39 -0
- package/dist/dictionary/parser/quickfix/dictionary-validator.js +321 -0
- package/dist/dictionary/parser/quickfix/dictionary-validator.js.map +1 -0
- package/dist/dictionary/parser/quickfix/index-visitor.d.ts +10 -0
- package/dist/dictionary/parser/quickfix/index-visitor.js +68 -0
- package/dist/dictionary/parser/quickfix/index-visitor.js.map +1 -0
- package/dist/dictionary/parser/quickfix/index.d.ts +7 -0
- package/dist/dictionary/parser/quickfix/index.js +7 -0
- package/dist/dictionary/parser/quickfix/index.js.map +1 -1
- package/dist/dictionary/parser/quickfix/quick-fix-graph-file-parser.d.ts +13 -0
- package/dist/dictionary/parser/quickfix/quick-fix-graph-file-parser.js +65 -0
- package/dist/dictionary/parser/quickfix/quick-fix-graph-file-parser.js.map +1 -0
- package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.d.ts +73 -0
- package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js +363 -0
- package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js.map +1 -0
- package/dist/dictionary/parser/quickfix/sax-tree-builder.d.ts +5 -0
- package/dist/dictionary/parser/quickfix/sax-tree-builder.js +103 -0
- package/dist/dictionary/parser/quickfix/sax-tree-builder.js.map +1 -0
- package/dist/dictionary/parser/quickfix/validation-error.d.ts +17 -0
- package/dist/dictionary/parser/quickfix/validation-error.js +32 -0
- package/dist/dictionary/parser/quickfix/validation-error.js.map +1 -0
- package/dist/dictionary/parser/quickfix/x-element.d.ts +26 -0
- package/dist/dictionary/parser/quickfix/x-element.js +82 -0
- package/dist/dictionary/parser/quickfix/x-element.js.map +1 -0
- package/dist/store/file-session-store.d.ts +42 -0
- package/dist/store/file-session-store.js +256 -0
- package/dist/store/file-session-store.js.map +1 -0
- package/dist/store/file-session-stream-provider.d.ts +25 -0
- package/dist/store/file-session-stream-provider.js +162 -0
- package/dist/store/file-session-stream-provider.js.map +1 -0
- package/dist/store/fix-msg-ascii-store-resend.js +6 -0
- package/dist/store/fix-msg-ascii-store-resend.js.map +1 -1
- package/dist/store/fix-session-store-factory.d.ts +13 -0
- package/dist/store/fix-session-store-factory.js +21 -0
- package/dist/store/fix-session-store-factory.js.map +1 -0
- package/dist/store/fix-session-store.d.ts +19 -0
- package/dist/store/fix-session-store.js +3 -0
- package/dist/store/fix-session-store.js.map +1 -0
- package/dist/store/index.d.ts +9 -0
- package/dist/store/index.js +9 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/memory-session-store.d.ts +27 -0
- package/dist/store/memory-session-store.js +104 -0
- package/dist/store/memory-session-store.js.map +1 -0
- package/dist/store/memory-session-stream-provider.d.ts +26 -0
- package/dist/store/memory-session-stream-provider.js +103 -0
- package/dist/store/memory-session-stream-provider.js.map +1 -0
- package/dist/store/session-id.d.ts +9 -0
- package/dist/store/session-id.js +55 -0
- package/dist/store/session-id.js.map +1 -0
- package/dist/store/session-stream-provider.d.ts +15 -0
- package/dist/store/session-stream-provider.js +3 -0
- package/dist/store/session-stream-provider.js.map +1 -0
- package/dist/store/store-config.d.ts +5 -0
- package/dist/store/store-config.js +3 -0
- package/dist/store/store-config.js.map +1 -0
- package/dist/transport/ascii/ascii-session.d.ts +6 -1
- package/dist/transport/ascii/ascii-session.js +37 -5
- package/dist/transport/ascii/ascii-session.js.map +1 -1
- package/dist/transport/session/session-description.d.ts +2 -0
- package/dist/transport/session/session-description.js.map +1 -1
- package/dist/util/definition-factory.js +1 -1
- package/dist/util/definition-factory.js.map +1 -1
- package/jsfix.test_client.txt +67 -67
- package/jsfix.test_server.txt +64 -64
- package/package.json +6 -6
- package/src/config/js-fix-config.ts +2 -0
- package/src/dictionary/parser/quickfix/dictionary-validator.ts +473 -0
- package/src/dictionary/parser/quickfix/index-visitor.ts +100 -0
- package/src/dictionary/parser/quickfix/index.ts +7 -0
- package/src/dictionary/parser/quickfix/quick-fix-graph-file-parser.ts +63 -0
- package/src/dictionary/parser/quickfix/quick-fix-graph-parser.ts +450 -0
- package/src/dictionary/parser/quickfix/sax-tree-builder.ts +112 -0
- package/src/dictionary/parser/quickfix/validation-error.ts +34 -0
- package/src/dictionary/parser/quickfix/x-element.ts +115 -0
- package/src/store/file-session-store.ts +294 -0
- package/src/store/file-session-stream-provider.ts +123 -0
- package/src/store/fix-msg-ascii-store-resend.ts +8 -0
- package/src/store/fix-session-store-factory.ts +31 -0
- package/src/store/fix-session-store.ts +37 -0
- package/src/store/index.ts +9 -0
- package/src/store/memory-session-store.ts +102 -0
- package/src/store/memory-session-stream-provider.ts +97 -0
- package/src/store/session-id.ts +32 -0
- package/src/store/session-stream-provider.ts +74 -0
- package/src/store/store-config.ts +16 -0
- package/src/transport/ascii/ascii-session.ts +57 -6
- package/src/transport/session/session-description.ts +2 -0
- package/src/util/definition-factory.ts +2 -2
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { XDocument, XNode } from './x-element'
|
|
2
|
+
import {
|
|
3
|
+
DictionaryValidationException,
|
|
4
|
+
ValidationError,
|
|
5
|
+
ValidationSeverity
|
|
6
|
+
} from './validation-error'
|
|
7
|
+
|
|
8
|
+
interface FieldDefinitionInfo {
|
|
9
|
+
readonly name: string
|
|
10
|
+
readonly tag: number
|
|
11
|
+
readonly type: string
|
|
12
|
+
readonly lineNumber?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ComponentDefinitionInfo {
|
|
16
|
+
readonly name: string
|
|
17
|
+
readonly lineNumber?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MessageDefinitionInfo {
|
|
21
|
+
readonly name: string
|
|
22
|
+
readonly msgType: string
|
|
23
|
+
readonly lineNumber?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validates FIX dictionary XML for common errors like duplicates, missing references, etc.
|
|
28
|
+
* Port of C# DictionaryValidator — operates on an XDocument tree built by SaxTreeBuilder.
|
|
29
|
+
*/
|
|
30
|
+
export class DictionaryValidator {
|
|
31
|
+
private readonly _errors: ValidationError[] = []
|
|
32
|
+
|
|
33
|
+
// Track definitions — case-sensitive for exact matching
|
|
34
|
+
private readonly fieldsByName = new Map<string, FieldDefinitionInfo>()
|
|
35
|
+
private readonly fieldsByTag = new Map<number, FieldDefinitionInfo>()
|
|
36
|
+
private readonly componentsByName = new Map<string, ComponentDefinitionInfo>()
|
|
37
|
+
private readonly messagesByName = new Map<string, MessageDefinitionInfo>()
|
|
38
|
+
private readonly messagesByMsgType = new Map<string, MessageDefinitionInfo>()
|
|
39
|
+
|
|
40
|
+
// Case-insensitive lookup for "did you mean" suggestions
|
|
41
|
+
private readonly fieldNamesCaseInsensitive = new Map<string, string>()
|
|
42
|
+
private readonly componentNamesCaseInsensitive = new Map<string, string>()
|
|
43
|
+
|
|
44
|
+
// Track what's referenced (to find unused definitions)
|
|
45
|
+
private readonly referencedFields = new Set<string>()
|
|
46
|
+
private readonly referencedComponents = new Set<string>()
|
|
47
|
+
|
|
48
|
+
// All known names for "did you mean" suggestions
|
|
49
|
+
private readonly allFieldNames: string[] = []
|
|
50
|
+
private readonly allComponentNames: string[] = []
|
|
51
|
+
|
|
52
|
+
get errors (): ReadonlyArray<ValidationError> {
|
|
53
|
+
return this._errors
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get hasErrors (): boolean {
|
|
57
|
+
return this._errors.some(e => e.severity === ValidationSeverity.Error)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get hasWarnings (): boolean {
|
|
61
|
+
return this._errors.some(e => e.severity === ValidationSeverity.Warning)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
validate (doc: XDocument): void {
|
|
65
|
+
// First pass: collect all definitions
|
|
66
|
+
this.collectFieldDefinitions(doc)
|
|
67
|
+
this.collectComponentDefinitions(doc)
|
|
68
|
+
this.collectMessageDefinitions(doc)
|
|
69
|
+
|
|
70
|
+
// Second pass: validate references
|
|
71
|
+
this.validateHeader(doc)
|
|
72
|
+
this.validateTrailer(doc)
|
|
73
|
+
this.validateComponentReferences(doc)
|
|
74
|
+
this.validateMessageReferences(doc)
|
|
75
|
+
|
|
76
|
+
// Third pass: check for unused definitions (warnings only)
|
|
77
|
+
this.checkUnusedDefinitions()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throwIfErrors (): void {
|
|
81
|
+
if (this.hasErrors) {
|
|
82
|
+
throw new DictionaryValidationException(this._errors)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Field Validation ──
|
|
87
|
+
|
|
88
|
+
private collectFieldDefinitions (doc: XDocument): void {
|
|
89
|
+
const fieldsNode = doc.firstDescendant('fields')
|
|
90
|
+
if (!fieldsNode) {
|
|
91
|
+
this.addError('MISSING_FIELDS', 'No <fields> section found in dictionary')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const field of fieldsNode.elements('field')) {
|
|
96
|
+
this.validateFieldDefinition(field)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private validateFieldDefinition (field: XNode): void {
|
|
101
|
+
const name = field.attribute('name')
|
|
102
|
+
const numberStr = field.attribute('number')
|
|
103
|
+
const type = field.attribute('type')
|
|
104
|
+
const lineNumber = field.line
|
|
105
|
+
|
|
106
|
+
if (!name) {
|
|
107
|
+
this.addError('FIELD_NO_NAME', 'Field definition missing \'name\' attribute', undefined, undefined, lineNumber)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const tag = numberStr != null ? parseInt(numberStr, 10) : NaN
|
|
112
|
+
if (!numberStr || isNaN(tag)) {
|
|
113
|
+
this.addError('FIELD_NO_TAG', `Field '${name}' missing or invalid 'number' attribute`, name, 'field', lineNumber)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!type) {
|
|
118
|
+
this.addWarning('FIELD_NO_TYPE', `Field '${name}' missing 'type' attribute, defaulting to STRING`, name, 'field', lineNumber)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.allFieldNames.push(name)
|
|
122
|
+
|
|
123
|
+
// Check for duplicate by name
|
|
124
|
+
const existingByName = this.fieldsByName.get(name)
|
|
125
|
+
if (existingByName) {
|
|
126
|
+
this.addError('DUPLICATE_FIELD_NAME',
|
|
127
|
+
`Duplicate field name '${name}' (tag ${tag}). Previously defined with tag ${existingByName.tag}`,
|
|
128
|
+
name, 'field', lineNumber,
|
|
129
|
+
existingByName.tag === tag ? 'Remove the duplicate definition' : 'Use unique field names')
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check for duplicate by tag
|
|
134
|
+
const existingByTag = this.fieldsByTag.get(tag)
|
|
135
|
+
if (existingByTag) {
|
|
136
|
+
this.addError('DUPLICATE_FIELD_TAG',
|
|
137
|
+
`Duplicate field tag ${tag} for '${name}'. Tag already used by field '${existingByTag.name}'`,
|
|
138
|
+
name, 'field', lineNumber,
|
|
139
|
+
'Each tag number must be unique')
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const info: FieldDefinitionInfo = { name, tag, type: type ?? 'STRING', lineNumber }
|
|
144
|
+
this.fieldsByName.set(name, info)
|
|
145
|
+
this.fieldsByTag.set(tag, info)
|
|
146
|
+
this.fieldNamesCaseInsensitive.set(name.toLowerCase(), name)
|
|
147
|
+
|
|
148
|
+
this.validateFieldEnums(field, name, lineNumber)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private validateFieldEnums (field: XNode, fieldName: string, lineNumber?: number): void {
|
|
152
|
+
const values = field.elements('value')
|
|
153
|
+
if (values.length === 0) return
|
|
154
|
+
|
|
155
|
+
const seenEnumKeys = new Set<string>()
|
|
156
|
+
const seenEnumDescriptions = new Set<string>()
|
|
157
|
+
|
|
158
|
+
for (const value of values) {
|
|
159
|
+
const enumKey = value.attribute('enum')
|
|
160
|
+
const description = value.attribute('description')
|
|
161
|
+
const valueLine = value.line ?? lineNumber
|
|
162
|
+
|
|
163
|
+
if (!enumKey) {
|
|
164
|
+
this.addError('ENUM_NO_KEY', `Field '${fieldName}' has enum value without 'enum' attribute`,
|
|
165
|
+
fieldName, 'field', valueLine)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!description) {
|
|
170
|
+
this.addWarning('ENUM_NO_DESC', `Field '${fieldName}' enum '${enumKey}' has no description`,
|
|
171
|
+
fieldName, 'field', valueLine)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (seenEnumKeys.has(enumKey)) {
|
|
175
|
+
this.addError('DUPLICATE_ENUM_KEY',
|
|
176
|
+
`Field '${fieldName}' has duplicate enum key '${enumKey}'`,
|
|
177
|
+
fieldName, 'field', valueLine)
|
|
178
|
+
}
|
|
179
|
+
seenEnumKeys.add(enumKey)
|
|
180
|
+
|
|
181
|
+
if (description != null) {
|
|
182
|
+
const descLower = description.toLowerCase()
|
|
183
|
+
if (seenEnumDescriptions.has(descLower)) {
|
|
184
|
+
this.addWarning('DUPLICATE_ENUM_DESC',
|
|
185
|
+
`Field '${fieldName}' has duplicate enum description '${description}' which may cause naming conflicts`,
|
|
186
|
+
fieldName, 'field', valueLine)
|
|
187
|
+
}
|
|
188
|
+
seenEnumDescriptions.add(descLower)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Component Validation ──
|
|
194
|
+
|
|
195
|
+
private collectComponentDefinitions (doc: XDocument): void {
|
|
196
|
+
const componentsNode = doc.firstDescendant('components')
|
|
197
|
+
if (!componentsNode) {
|
|
198
|
+
// Components are optional
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const component of componentsNode.elements('component')) {
|
|
203
|
+
this.validateComponentDefinition(component)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private validateComponentDefinition (component: XNode): void {
|
|
208
|
+
const name = component.attribute('name')
|
|
209
|
+
const lineNumber = component.line
|
|
210
|
+
|
|
211
|
+
if (!name) {
|
|
212
|
+
this.addError('COMPONENT_NO_NAME', 'Component definition missing \'name\' attribute', undefined, undefined, lineNumber)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.allComponentNames.push(name)
|
|
217
|
+
|
|
218
|
+
const existing = this.componentsByName.get(name)
|
|
219
|
+
if (existing) {
|
|
220
|
+
this.addError('DUPLICATE_COMPONENT',
|
|
221
|
+
`Duplicate component name '${name}'`,
|
|
222
|
+
name, 'component', lineNumber,
|
|
223
|
+
`Previously defined at line ${existing.lineNumber ?? '?'}`)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.componentsByName.set(name, { name, lineNumber })
|
|
228
|
+
this.componentNamesCaseInsensitive.set(name.toLowerCase(), name)
|
|
229
|
+
|
|
230
|
+
this.validateFieldReferences(component, name, 'component')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Message Validation ──
|
|
234
|
+
|
|
235
|
+
private collectMessageDefinitions (doc: XDocument): void {
|
|
236
|
+
const messagesNode = doc.firstDescendant('messages')
|
|
237
|
+
if (!messagesNode) {
|
|
238
|
+
this.addError('MISSING_MESSAGES', 'No <messages> section found in dictionary')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const message of messagesNode.elements('message')) {
|
|
243
|
+
this.validateMessageDefinition(message)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private validateMessageDefinition (message: XNode): void {
|
|
248
|
+
const name = message.attribute('name')
|
|
249
|
+
const msgType = message.attribute('msgtype')
|
|
250
|
+
const lineNumber = message.line
|
|
251
|
+
|
|
252
|
+
if (!name) {
|
|
253
|
+
this.addError('MESSAGE_NO_NAME', 'Message definition missing \'name\' attribute', undefined, undefined, lineNumber)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!msgType) {
|
|
258
|
+
this.addError('MESSAGE_NO_MSGTYPE', `Message '${name}' missing 'msgtype' attribute`,
|
|
259
|
+
name, 'message', lineNumber)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const existingByName = this.messagesByName.get(name)
|
|
264
|
+
if (existingByName) {
|
|
265
|
+
this.addError('DUPLICATE_MESSAGE_NAME',
|
|
266
|
+
`Duplicate message name '${name}'`,
|
|
267
|
+
name, 'message', lineNumber,
|
|
268
|
+
`Previously defined at line ${existingByName.lineNumber ?? '?'}`)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const existingByType = this.messagesByMsgType.get(msgType)
|
|
273
|
+
if (existingByType) {
|
|
274
|
+
this.addError('DUPLICATE_MESSAGE_TYPE',
|
|
275
|
+
`Duplicate message type '${msgType}' for message '${name}'. Type already used by '${existingByType.name}'`,
|
|
276
|
+
name, 'message', lineNumber)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const info: MessageDefinitionInfo = { name, msgType, lineNumber }
|
|
281
|
+
this.messagesByName.set(name, info)
|
|
282
|
+
this.messagesByMsgType.set(msgType, info)
|
|
283
|
+
|
|
284
|
+
this.validateFieldReferences(message, name, 'message')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Reference Validation ──
|
|
288
|
+
|
|
289
|
+
private validateHeader (doc: XDocument): void {
|
|
290
|
+
const header = doc.firstDescendant('header')
|
|
291
|
+
if (!header) {
|
|
292
|
+
this.addError('MISSING_HEADER', 'No <header> section found in dictionary')
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
this.validateFieldReferences(header, 'StandardHeader', 'header')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private validateTrailer (doc: XDocument): void {
|
|
299
|
+
const trailer = doc.firstDescendant('trailer')
|
|
300
|
+
if (!trailer) {
|
|
301
|
+
this.addError('MISSING_TRAILER', 'No <trailer> section found in dictionary')
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
this.validateFieldReferences(trailer, 'StandardTrailer', 'trailer')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private validateComponentReferences (doc: XDocument): void {
|
|
308
|
+
const componentsNode = doc.firstDescendant('components')
|
|
309
|
+
if (!componentsNode) return
|
|
310
|
+
|
|
311
|
+
for (const component of componentsNode.elements('component')) {
|
|
312
|
+
const name = component.attribute('name')
|
|
313
|
+
if (!name) continue
|
|
314
|
+
|
|
315
|
+
for (const compRef of component.descendants('component')) {
|
|
316
|
+
this.validateComponentReference(compRef, name, 'component')
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private validateMessageReferences (doc: XDocument): void {
|
|
322
|
+
const messagesNode = doc.firstDescendant('messages')
|
|
323
|
+
if (!messagesNode) return
|
|
324
|
+
|
|
325
|
+
for (const message of messagesNode.elements('message')) {
|
|
326
|
+
const name = message.attribute('name')
|
|
327
|
+
if (!name) continue
|
|
328
|
+
|
|
329
|
+
for (const compRef of message.descendants('component')) {
|
|
330
|
+
this.validateComponentReference(compRef, name, 'message')
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private validateFieldReferences (container: XNode, containerName: string, containerType: string): void {
|
|
336
|
+
for (const fieldRef of container.elements('field')) {
|
|
337
|
+
const fieldName = fieldRef.attribute('name')
|
|
338
|
+
const lineNumber = fieldRef.line
|
|
339
|
+
|
|
340
|
+
if (!fieldName) {
|
|
341
|
+
this.addError('FIELD_REF_NO_NAME',
|
|
342
|
+
`Field reference in ${containerType} '${containerName}' missing 'name' attribute`,
|
|
343
|
+
containerName, containerType, lineNumber)
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
this.referencedFields.add(fieldName)
|
|
348
|
+
|
|
349
|
+
if (!this.fieldsByName.has(fieldName)) {
|
|
350
|
+
// Check for case mismatch first
|
|
351
|
+
const correctCase = this.fieldNamesCaseInsensitive.get(fieldName.toLowerCase())
|
|
352
|
+
const suggestion = correctCase ?? DictionaryValidator.findSimilar(fieldName, this.allFieldNames)
|
|
353
|
+
|
|
354
|
+
this.addError('UNDEFINED_FIELD',
|
|
355
|
+
`Field '${fieldName}' referenced in ${containerType} '${containerName}' is not defined`,
|
|
356
|
+
fieldName, 'field reference', lineNumber,
|
|
357
|
+
suggestion != null ? `Did you mean '${suggestion}'?` : 'Add the field to the <fields> section')
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Recursively check groups
|
|
362
|
+
for (const group of container.elements('group')) {
|
|
363
|
+
const groupName = group.attribute('name') ?? 'unknown'
|
|
364
|
+
this.referencedFields.add(groupName)
|
|
365
|
+
|
|
366
|
+
if (groupName !== 'unknown' && !this.fieldsByName.has(groupName)) {
|
|
367
|
+
const lineNumber = group.line
|
|
368
|
+
const suggestion = DictionaryValidator.findSimilar(groupName, this.allFieldNames)
|
|
369
|
+
|
|
370
|
+
this.addError('UNDEFINED_GROUP_FIELD',
|
|
371
|
+
`Group '${groupName}' in ${containerType} '${containerName}' has no corresponding field definition (for the repeating count)`,
|
|
372
|
+
groupName, 'group', lineNumber,
|
|
373
|
+
suggestion != null ? `Did you mean '${suggestion}'?` : 'Add a NUMINGROUP field for this group')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
this.validateFieldReferences(group, `${containerName}.${groupName}`, 'group')
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private validateComponentReference (compRef: XNode, containerName: string, containerType: string): void {
|
|
381
|
+
const compName = compRef.attribute('name')
|
|
382
|
+
const lineNumber = compRef.line
|
|
383
|
+
|
|
384
|
+
if (!compName) {
|
|
385
|
+
this.addError('COMPONENT_REF_NO_NAME',
|
|
386
|
+
`Component reference in ${containerType} '${containerName}' missing 'name' attribute`,
|
|
387
|
+
containerName, containerType, lineNumber)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.referencedComponents.add(compName)
|
|
392
|
+
|
|
393
|
+
if (!this.componentsByName.has(compName) &&
|
|
394
|
+
compName !== 'StandardHeader' && compName !== 'StandardTrailer') {
|
|
395
|
+
const suggestion = DictionaryValidator.findSimilar(compName, this.allComponentNames)
|
|
396
|
+
this.addError('UNDEFINED_COMPONENT',
|
|
397
|
+
`Component '${compName}' referenced in ${containerType} '${containerName}' is not defined`,
|
|
398
|
+
compName, 'component reference', lineNumber,
|
|
399
|
+
suggestion != null ? `Did you mean '${suggestion}'?` : 'Add the component to the <components> section')
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private checkUnusedDefinitions (): void {
|
|
404
|
+
for (const field of this.fieldsByName.values()) {
|
|
405
|
+
if (!this.referencedFields.has(field.name)) {
|
|
406
|
+
this.addWarning('UNUSED_FIELD',
|
|
407
|
+
`Field '${field.name}' (tag ${field.tag}) is defined but never referenced`,
|
|
408
|
+
field.name, 'field', field.lineNumber)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const comp of this.componentsByName.values()) {
|
|
413
|
+
if (!this.referencedComponents.has(comp.name)) {
|
|
414
|
+
this.addWarning('UNUSED_COMPONENT',
|
|
415
|
+
`Component '${comp.name}' is defined but never referenced`,
|
|
416
|
+
comp.name, 'component', comp.lineNumber)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Helpers ──
|
|
422
|
+
|
|
423
|
+
private addError (code: string, message: string, elementName?: string,
|
|
424
|
+
elementType?: string, lineNumber?: number, suggestion?: string): void {
|
|
425
|
+
this._errors.push({ severity: ValidationSeverity.Error, code, message, elementName, elementType, lineNumber, suggestion })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private addWarning (code: string, message: string, elementName?: string,
|
|
429
|
+
elementType?: string, lineNumber?: number, suggestion?: string): void {
|
|
430
|
+
this._errors.push({ severity: ValidationSeverity.Warning, code, message, elementName, elementType, lineNumber, suggestion })
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
static findSimilar (input: string, candidates: string[]): string | null {
|
|
434
|
+
let bestMatch: string | null = null
|
|
435
|
+
let bestDistance = Infinity
|
|
436
|
+
const maxDistance = Math.max(3, Math.floor(input.length / 2))
|
|
437
|
+
const inputLower = input.toLowerCase()
|
|
438
|
+
|
|
439
|
+
for (const candidate of candidates) {
|
|
440
|
+
const distance = DictionaryValidator.levenshteinDistance(inputLower, candidate.toLowerCase())
|
|
441
|
+
if (distance < bestDistance && distance <= maxDistance) {
|
|
442
|
+
bestDistance = distance
|
|
443
|
+
bestMatch = candidate
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return bestMatch
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
static levenshteinDistance (s1: string, s2: string): number {
|
|
451
|
+
const n = s1.length
|
|
452
|
+
const m = s2.length
|
|
453
|
+
if (n === 0) return m
|
|
454
|
+
if (m === 0) return n
|
|
455
|
+
|
|
456
|
+
const d: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
|
|
457
|
+
|
|
458
|
+
for (let i = 0; i <= n; i++) d[i][0] = i
|
|
459
|
+
for (let j = 0; j <= m; j++) d[0][j] = j
|
|
460
|
+
|
|
461
|
+
for (let i = 1; i <= n; i++) {
|
|
462
|
+
for (let j = 1; j <= m; j++) {
|
|
463
|
+
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1
|
|
464
|
+
d[i][j] = Math.min(
|
|
465
|
+
d[i - 1][j] + 1,
|
|
466
|
+
d[i][j - 1] + 1,
|
|
467
|
+
d[i - 1][j - 1] + cost)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return d[n][m]
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processing reindexer for ContainedFieldSet.
|
|
3
|
+
*
|
|
4
|
+
* After the graph parser drains its work queue, parent sets may not yet
|
|
5
|
+
* know about all tags in their nested groups/components — because those
|
|
6
|
+
* children may have been resolved AFTER the parent. This visitor walks
|
|
7
|
+
* every message/component/group definition in post-order (children first),
|
|
8
|
+
* clears the aggregated tag indices, and re-adds direct fields via
|
|
9
|
+
* ContainedSetBuilder, which propagates child tags upward correctly.
|
|
10
|
+
*
|
|
11
|
+
* Equivalent to the C# IndexVisitor + ContainedFieldSet.Index() pair.
|
|
12
|
+
*/
|
|
13
|
+
import { ContainedFieldSet } from '../../contained/contained-field-set'
|
|
14
|
+
import { ContainedField } from '../../contained/contained-field'
|
|
15
|
+
import { ContainedFieldType } from '../../contained/contained-field-type'
|
|
16
|
+
import { ContainedGroupField } from '../../contained/contained-group-field'
|
|
17
|
+
import { ContainedComponentField } from '../../contained/contained-component-field'
|
|
18
|
+
import { ContainedSetBuilder } from '../../contained/contained-set-builder'
|
|
19
|
+
import { FixDefinitions } from '../../definition/fix-definitions'
|
|
20
|
+
import { IContainedSet } from '../../contained/contained-set'
|
|
21
|
+
|
|
22
|
+
export class IndexVisitor {
|
|
23
|
+
private readonly visited = new Set<IContainedSet>()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reindex every message in the definitions. Components and groups are
|
|
27
|
+
* reindexed transitively as they are encountered during message reindexing.
|
|
28
|
+
*/
|
|
29
|
+
compute (definitions: FixDefinitions): void {
|
|
30
|
+
// Use a Set to dedupe — definitions.message contains duplicate entries (by name + msgType + abbreviation)
|
|
31
|
+
const messages = new Set(definitions.message.values())
|
|
32
|
+
for (const msg of messages) {
|
|
33
|
+
this.reindex(msg)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reindex a single set: post-order traversal ensures every nested
|
|
39
|
+
* group/component is fully indexed before its parent is rebuilt.
|
|
40
|
+
*/
|
|
41
|
+
reindex (set: IContainedSet): void {
|
|
42
|
+
if (this.visited.has(set)) return
|
|
43
|
+
this.visited.add(set)
|
|
44
|
+
|
|
45
|
+
// First, recurse into all child groups/components (post-order)
|
|
46
|
+
for (const field of set.fields) {
|
|
47
|
+
if (field.type === ContainedFieldType.Group) {
|
|
48
|
+
const gf = field as ContainedGroupField
|
|
49
|
+
if (gf.definition) this.reindex(gf.definition)
|
|
50
|
+
} else if (field.type === ContainedFieldType.Component) {
|
|
51
|
+
const cf = field as ContainedComponentField
|
|
52
|
+
if (cf.definition) this.reindex(cf.definition)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Save direct fields, clear all aggregated state, re-add via builder
|
|
57
|
+
const level0: ContainedField[] = [...set.fields]
|
|
58
|
+
IndexVisitor.clearAggregated(set as ContainedFieldSet)
|
|
59
|
+
|
|
60
|
+
const builder = new ContainedSetBuilder(set)
|
|
61
|
+
for (const field of level0) {
|
|
62
|
+
builder.add(field)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reset every aggregated index on a set without touching its identity.
|
|
68
|
+
* Direct fields are passed back via the return of clearAggregated for
|
|
69
|
+
* the caller to re-add.
|
|
70
|
+
*/
|
|
71
|
+
static clearAggregated (set: ContainedFieldSet): void {
|
|
72
|
+
set.fields.length = 0
|
|
73
|
+
set.simple.clear()
|
|
74
|
+
set.groups.clear()
|
|
75
|
+
set.components.clear()
|
|
76
|
+
set.localNameToField.clear()
|
|
77
|
+
set.flattenedTag.length = 0
|
|
78
|
+
set.localAttribute.length = 0
|
|
79
|
+
set.nameToLocalAttribute.clear()
|
|
80
|
+
set.firstSimple = null
|
|
81
|
+
set.containsRaw = false
|
|
82
|
+
IndexVisitor.clearObject(set.containedTag)
|
|
83
|
+
IndexVisitor.clearObject(set.localTag)
|
|
84
|
+
IndexVisitor.clearObject(set.localRequired)
|
|
85
|
+
IndexVisitor.clearObject(set.tagToSimple)
|
|
86
|
+
IndexVisitor.clearObject(set.tagToField)
|
|
87
|
+
IndexVisitor.clearObject(set.containedLength)
|
|
88
|
+
// Re-add the noOfField tag for groups (otherwise the group counter is lost)
|
|
89
|
+
const def = set as any
|
|
90
|
+
if (def.noOfField) {
|
|
91
|
+
set.containedTag[def.noOfField.tag] = true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private static clearObject (obj: Record<string, unknown>): void {
|
|
96
|
+
for (const k of Object.keys(obj)) {
|
|
97
|
+
delete obj[k]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -1 +1,8 @@
|
|
|
1
1
|
export * from './quick-fix-xml-file-parser'
|
|
2
|
+
export * from './quick-fix-graph-parser'
|
|
3
|
+
export * from './quick-fix-graph-file-parser'
|
|
4
|
+
export * from './x-element'
|
|
5
|
+
export * from './sax-tree-builder'
|
|
6
|
+
export * from './dictionary-validator'
|
|
7
|
+
export * from './validation-error'
|
|
8
|
+
export * from './index-visitor'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop-in replacement for QuickFixXmlFileParser that uses the graph-based
|
|
3
|
+
* parser internally. Maintains the same constructor signature so it can be
|
|
4
|
+
* swapped in the DefinitionFactory without changes to call sites.
|
|
5
|
+
*/
|
|
6
|
+
import { FixParser } from '../../fix-parser'
|
|
7
|
+
import { FixDefinitions } from '../../definition/fix-definitions'
|
|
8
|
+
import { GetJsFixLogger } from '../../../config'
|
|
9
|
+
import { MakeDuplex } from '../../../transport'
|
|
10
|
+
import { SaxTreeBuilder } from './sax-tree-builder'
|
|
11
|
+
import { QuickFixGraphParser, QuickFixGraphParserOptions } from './quick-fix-graph-parser'
|
|
12
|
+
import { FixDefinitionSource } from '../../fix-definition-source'
|
|
13
|
+
import { VersionUtil } from '../../version-util'
|
|
14
|
+
|
|
15
|
+
export class QuickFixGraphFileParser extends FixParser {
|
|
16
|
+
constructor (
|
|
17
|
+
public readonly make: MakeDuplex,
|
|
18
|
+
public readonly getLogger: GetJsFixLogger,
|
|
19
|
+
public readonly options: QuickFixGraphParserOptions = {}
|
|
20
|
+
) {
|
|
21
|
+
super()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public async parse (): Promise<FixDefinitions> {
|
|
25
|
+
const xml = await this.readAll()
|
|
26
|
+
const doc = SaxTreeBuilder.parse(xml)
|
|
27
|
+
|
|
28
|
+
const fixRoot = doc.firstDescendant('fix')
|
|
29
|
+
if (!fixRoot) throw new Error('no <fix> root element')
|
|
30
|
+
const major = parseInt(fixRoot.attribute('major') ?? '0', 10)
|
|
31
|
+
const minor = parseInt(fixRoot.attribute('minor') ?? '0', 10)
|
|
32
|
+
const servicepack = parseInt(fixRoot.attribute('servicepack') ?? '0', 10)
|
|
33
|
+
const description = (major !== 5 || servicepack === 0)
|
|
34
|
+
? `FIX.${major}.${minor}`
|
|
35
|
+
: `FIX.${major}.${minor}SP${servicepack}`
|
|
36
|
+
|
|
37
|
+
const definitions = new FixDefinitions(FixDefinitionSource.QuickFix, VersionUtil.resolve(description))
|
|
38
|
+
const parser = new QuickFixGraphParser(definitions, this.options)
|
|
39
|
+
return parser.parseDocument(doc)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the entire duplex stream into a UTF-8 string. The graph parser
|
|
44
|
+
* needs the full document in memory because it walks the XDocument tree
|
|
45
|
+
* with random access — unlike the legacy SAX-streaming parser.
|
|
46
|
+
*/
|
|
47
|
+
private async readAll (): Promise<string> {
|
|
48
|
+
const duplex = this.make()
|
|
49
|
+
const readable = duplex.readable
|
|
50
|
+
return await new Promise<string>((resolve, reject) => {
|
|
51
|
+
const chunks: Buffer[] = []
|
|
52
|
+
readable.on('data', (chunk: Buffer | string) => {
|
|
53
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
|
54
|
+
})
|
|
55
|
+
readable.on('end', () => {
|
|
56
|
+
resolve(Buffer.concat(chunks).toString('utf-8'))
|
|
57
|
+
})
|
|
58
|
+
readable.on('error', (err: Error) => {
|
|
59
|
+
reject(err)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|