node-opcua-address-space 2.181.0 → 2.182.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.
Files changed (136) hide show
  1. package/api/helpers/ensure_secure_access.ts +53 -2
  2. package/api/index.ts +30 -1
  3. package/api/loader/generateAddressSpaceRaw.ts +44 -139
  4. package/api/loader/load_nodeset2.ts +5 -0
  5. package/api/loader/narrow_instance_datatypes.ts +264 -0
  6. package/api/loader/nodeset_builtin_formats.ts +143 -0
  7. package/api/loader/nodeset_format.ts +153 -0
  8. package/api/loader/nodeset_image.ts +37 -4
  9. package/api/loader/nodeset_image_codec.ts +153 -34
  10. package/api/loader/nodeset_record.ts +26 -2
  11. package/api/loader/nodeset_record_applier.ts +54 -3
  12. package/api/loader/nodeset_records_to_xml.ts +620 -0
  13. package/api/loader/nodeset_source.ts +185 -26
  14. package/api/loader/nodeset_to_image.ts +16 -5
  15. package/api/loader/nodeset_xml_header.ts +119 -0
  16. package/api/loader/nodeset_xml_primitives.ts +209 -0
  17. package/api/loader/nodeset_xml_producer.ts +61 -2
  18. package/api/loader/parsers/variant_parser.ts +9 -0
  19. package/bin/nodeset_equivalence.js +750 -0
  20. package/bin/nodeset_tool_common.js +72 -0
  21. package/bin/opcua-nodeset-image.js +93 -103
  22. package/bin/opcua-nodeset-live.js +139 -0
  23. package/dist/api/helpers/ensure_secure_access.d.ts +20 -0
  24. package/dist/api/helpers/ensure_secure_access.js +39 -0
  25. package/dist/api/helpers/ensure_secure_access.js.map +1 -1
  26. package/dist/api/index.d.ts +7 -2
  27. package/dist/api/index.js +21 -1
  28. package/dist/api/index.js.map +1 -1
  29. package/dist/api/loader/generateAddressSpaceRaw.d.ts +1 -7
  30. package/dist/api/loader/generateAddressSpaceRaw.js +32 -125
  31. package/dist/api/loader/generateAddressSpaceRaw.js.map +1 -1
  32. package/dist/api/loader/load_nodeset2.js +4 -0
  33. package/dist/api/loader/load_nodeset2.js.map +1 -1
  34. package/dist/api/loader/narrow_instance_datatypes.d.ts +30 -0
  35. package/dist/api/loader/narrow_instance_datatypes.js +217 -0
  36. package/dist/api/loader/narrow_instance_datatypes.js.map +1 -0
  37. package/dist/api/loader/nodeset_builtin_formats.d.ts +27 -0
  38. package/dist/api/loader/nodeset_builtin_formats.js +133 -0
  39. package/dist/api/loader/nodeset_builtin_formats.js.map +1 -0
  40. package/dist/api/loader/nodeset_format.d.ts +123 -0
  41. package/dist/api/loader/nodeset_format.js +63 -0
  42. package/dist/api/loader/nodeset_format.js.map +1 -0
  43. package/dist/api/loader/nodeset_image.d.ts +17 -1
  44. package/dist/api/loader/nodeset_image.js +32 -4
  45. package/dist/api/loader/nodeset_image.js.map +1 -1
  46. package/dist/api/loader/nodeset_image_codec.d.ts +4 -0
  47. package/dist/api/loader/nodeset_image_codec.js +142 -53
  48. package/dist/api/loader/nodeset_image_codec.js.map +1 -1
  49. package/dist/api/loader/nodeset_record.d.ts +26 -2
  50. package/dist/api/loader/nodeset_record.js +11 -2
  51. package/dist/api/loader/nodeset_record.js.map +1 -1
  52. package/dist/api/loader/nodeset_record_applier.js +52 -3
  53. package/dist/api/loader/nodeset_record_applier.js.map +1 -1
  54. package/dist/api/loader/nodeset_records_to_xml.d.ts +16 -0
  55. package/dist/api/loader/nodeset_records_to_xml.js +536 -0
  56. package/dist/api/loader/nodeset_records_to_xml.js.map +1 -0
  57. package/dist/api/loader/nodeset_source.d.ts +68 -7
  58. package/dist/api/loader/nodeset_source.js +157 -20
  59. package/dist/api/loader/nodeset_source.js.map +1 -1
  60. package/dist/api/loader/nodeset_to_image.d.ts +4 -4
  61. package/dist/api/loader/nodeset_to_image.js +14 -4
  62. package/dist/api/loader/nodeset_to_image.js.map +1 -1
  63. package/dist/api/loader/nodeset_xml_header.d.ts +22 -0
  64. package/dist/api/loader/nodeset_xml_header.js +113 -0
  65. package/dist/api/loader/nodeset_xml_header.js.map +1 -0
  66. package/dist/api/loader/nodeset_xml_primitives.d.ts +37 -0
  67. package/dist/api/loader/nodeset_xml_primitives.js +196 -0
  68. package/dist/api/loader/nodeset_xml_primitives.js.map +1 -0
  69. package/dist/api/loader/nodeset_xml_producer.js +56 -1
  70. package/dist/api/loader/nodeset_xml_producer.js.map +1 -1
  71. package/dist/api/loader/parsers/variant_parser.js +7 -0
  72. package/dist/api/loader/parsers/variant_parser.js.map +1 -1
  73. package/dist/impl/_instantiate_helpers.js +1 -0
  74. package/dist/impl/_instantiate_helpers.js.map +1 -1
  75. package/dist/impl/alarms_and_conditions/ua_alarm_condition_impl.js +4 -0
  76. package/dist/impl/alarms_and_conditions/ua_alarm_condition_impl.js.map +1 -1
  77. package/dist/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.js +14 -0
  78. package/dist/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.js.map +1 -1
  79. package/dist/impl/base_node_impl.d.ts +9 -0
  80. package/dist/impl/base_node_impl.js +11 -0
  81. package/dist/impl/base_node_impl.js.map +1 -1
  82. package/dist/impl/base_node_private.js +67 -97
  83. package/dist/impl/base_node_private.js.map +1 -1
  84. package/dist/impl/nodeset_tools/construct_namespace_dependency.js +5 -1
  85. package/dist/impl/nodeset_tools/construct_namespace_dependency.js.map +1 -1
  86. package/dist/impl/nodeset_tools/nodeset_to_records.js +61 -15
  87. package/dist/impl/nodeset_tools/nodeset_to_records.js.map +1 -1
  88. package/dist/impl/nodeset_tools/nodeset_to_xml.d.ts +1 -8
  89. package/dist/impl/nodeset_tools/nodeset_to_xml.js +174 -190
  90. package/dist/impl/nodeset_tools/nodeset_to_xml.js.map +1 -1
  91. package/dist/impl/ua_data_type_impl.d.ts +21 -0
  92. package/dist/impl/ua_data_type_impl.js +27 -2
  93. package/dist/impl/ua_data_type_impl.js.map +1 -1
  94. package/dist/impl/ua_object_type_impl.d.ts +9 -0
  95. package/dist/impl/ua_object_type_impl.js +12 -1
  96. package/dist/impl/ua_object_type_impl.js.map +1 -1
  97. package/dist/impl/ua_variable_impl.js +27 -10
  98. package/dist/impl/ua_variable_impl.js.map +1 -1
  99. package/dist/impl/ua_variable_impl_ext_obj.js +19 -2
  100. package/dist/impl/ua_variable_impl_ext_obj.js.map +1 -1
  101. package/dist/impl/ua_variable_type_impl.d.ts +8 -1
  102. package/dist/impl/ua_variable_type_impl.js +39 -2
  103. package/dist/impl/ua_variable_type_impl.js.map +1 -1
  104. package/dist/tsconfig_base.tsbuildinfo +1 -1
  105. package/distNodeJS/generate_address_space.js +4 -0
  106. package/distNodeJS/generate_address_space.js.map +1 -1
  107. package/impl/_instantiate_helpers.ts +3 -0
  108. package/impl/alarms_and_conditions/ua_acknowledgeable_condition_impl.ts +4 -4
  109. package/impl/alarms_and_conditions/ua_alarm_condition_impl.ts +19 -9
  110. package/impl/alarms_and_conditions/ua_base_event_impl.ts +9 -9
  111. package/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.ts +17 -0
  112. package/impl/alarms_and_conditions/ua_condition_impl.ts +18 -18
  113. package/impl/alarms_and_conditions/ua_exclusive_deviation_alarm_impl.ts +2 -2
  114. package/impl/alarms_and_conditions/ua_exclusive_limit_alarm_impl.ts +1 -1
  115. package/impl/alarms_and_conditions/ua_limit_alarm_impl.ts +16 -16
  116. package/impl/alarms_and_conditions/ua_non_exclusive_deviation_alarm_impl.ts +2 -2
  117. package/impl/alarms_and_conditions/ua_non_exclusive_limit_alarm_impl.ts +4 -4
  118. package/impl/alarms_and_conditions/ua_off_normal_alarm_impl.ts +1 -1
  119. package/impl/base_node_impl.ts +15 -0
  120. package/impl/base_node_private.ts +97 -146
  121. package/impl/data_access/ua_multistate_discrete_impl.ts +1 -1
  122. package/impl/data_access/ua_multistate_value_discrete_impl.ts +2 -2
  123. package/impl/data_access/ua_two_state_discrete_impl.ts +2 -2
  124. package/impl/nodeset_tools/construct_namespace_dependency.ts +5 -1
  125. package/impl/nodeset_tools/nodeset_to_records.ts +66 -16
  126. package/impl/nodeset_tools/nodeset_to_xml.ts +127 -160
  127. package/impl/state_machine/finite_state_machine.ts +3 -3
  128. package/impl/state_machine/ua_shelving_state_machine_ex.ts +20 -20
  129. package/impl/ua_data_type_impl.ts +42 -4
  130. package/impl/ua_method_impl.ts +1 -1
  131. package/impl/ua_object_type_impl.ts +16 -3
  132. package/impl/ua_variable_impl.ts +27 -10
  133. package/impl/ua_variable_impl_ext_obj.ts +20 -2
  134. package/impl/ua_variable_type_impl.ts +49 -3
  135. package/package.json +29 -27
  136. package/source_nodejs/generate_address_space.ts +7 -1
@@ -0,0 +1,750 @@
1
+ /**
2
+ * The equivalence checks, run by `opcua-nodeset-live equivalence`: does the NDJSON form carry
3
+ * everything the NodeSet2 XML form carries, and nothing of its own? The two forms are equipotent
4
+ * when the answer to both halves is yes, and this answers them by construction rather than by
5
+ * inspection.
6
+ *
7
+ * opcua-nodeset-live equivalence <file.xml>... [--require <file.xml>]... [--dump <dir>] [--quiet]
8
+ *
9
+ * Every intermediate is kept: A, B and the two regenerated XML documents are written to --dump, or
10
+ * to a directory under the system temp when none is given, and the path is printed. A failing
11
+ * check is a thing to look at, and looking at it means diffing two files.
12
+ *
13
+ * The whole round trip, on a document from anywhere:
14
+ *
15
+ * <third party>/NodeSet2.xml -> NDJSON (A) -> address space
16
+ * |
17
+ * NDJSON (B) <- NodeSet2.xml written back by node-opcua
18
+ *
19
+ * A == B
20
+ *
21
+ * A is what the third party's document says; B is what our own document says after the model has
22
+ * been all the way round. If the two agree, nothing was lost anywhere in the loop, and the NDJSON
23
+ * is the place to compare because it is the only one of the four forms with a canonical spelling.
24
+ *
25
+ * "==" is not byte equality, and could not be: two documents are free to order their namespace
26
+ * tables and their node lines differently, so A and B are compared after every id has been
27
+ * resolved through its own document's table into `<namespace uri>;i=<n>`, which is the only
28
+ * spelling of an id two documents can be held to. The comparison is then exact -- same namespaces,
29
+ * same set of nodes, same set of references, same value for every attribute of every node.
30
+ *
31
+ * Three things are reported apart from the verdict, because each is a difference of spelling and
32
+ * not of model, and each is provably harmless once the ids are fully qualified: the *declared*
33
+ * <NamespaceUris> table, whose order is a local choice and which may list a namespace no id refers
34
+ * to (what is compared is the namespaces actually referred to); ParentNodeId, derivable from the
35
+ * aggregating reference, and ambiguous for a node reachable from two parents; and the
36
+ * inverse-declaration hint on a reference.
37
+ *
38
+ * Eight checks per file:
39
+ *
40
+ * 1 records XML -> records -> NDJSON -> records: the two record streams must agree, field
41
+ * by field, once the defaults of the format are applied to both. This is the
42
+ * check that a key the writer leaves out is a key the reader puts back.
43
+ * 2 lines those records re-encoded must give back the very bytes of the NDJSON: the
44
+ * encoding is canonical, so a writer and a reader cannot drift apart silently.
45
+ * 3 addressspace the address space built from the XML and the one built from the NDJSON must
46
+ * have the same digest: same nodes, same references, same values.
47
+ * 4 xml the NodeSet2 XML written from each of those two address spaces must be the
48
+ * same bytes: what the XML says can be said again after a trip through NDJSON.
49
+ * 5 A==B nodes A against B: same namespaces, same set of nodes, same set of references.
50
+ * 6 A==B attrs A against B again, this time every attribute of every node.
51
+ * 7 fixpoint a second pass over the exporter's own XML, which reaches constructs a
52
+ * hand-written file may not, must again say the same thing either way.
53
+ * 8 xml-idempotent information, not a verdict: whether the XML pipeline alone is idempotent.
54
+ *
55
+ * What no check here claims is that the regenerated XML is byte-identical to the file on disk.
56
+ * It cannot be, and neither can any other tool's: a NodeSet2 document carries comments, attribute
57
+ * order, entity spellings, indentation and element order that the OPC UA information model does
58
+ * not define, so two files may say exactly the same thing and differ in bytes. Checks 5 and 6
59
+ * compare what the two documents say; check 4 compares their bytes at the first point where a
60
+ * canonical spelling exists -- the exporter's own -- and check 7 shows that spelling is a
61
+ * fixpoint. Together they say: no information is lost in either direction; only the freedom the
62
+ * XML syntax leaves is.
63
+ */
64
+ const fs = require("node:fs");
65
+ const os = require("node:os");
66
+ const path = require("node:path");
67
+ const { performance } = require("node:perf_hooks");
68
+ const {
69
+ AddressSpace,
70
+ generateAddressSpaceRaw,
71
+ imageNodesetRecords,
72
+ NodesetImageWriter,
73
+ xmlNodesetRecords
74
+ } = require("../dist/api/index.js");
75
+ const { digestAddressSpace } = require("../distNodeJS/index.js");
76
+ const { asFile, dependencyChain } = require("./nodeset_tool_common.js");
77
+
78
+ const usage = () => {
79
+ console.error("usage: opcua-nodeset-live equivalence <file.xml>... [--require <file.xml>]... [--dump <dir>] [--quiet]");
80
+ process.exit(2);
81
+ };
82
+
83
+ /** what a throw says when it has nothing to say: an assert with no message leaves `message` empty */
84
+ const reasonOf = (err) => {
85
+ if (err && err.message) return err.message;
86
+ const frame = String((err && err.stack) || "").split("\n")[1] || "";
87
+ const at = frame.trim().replace(/^at /, "").replace(/ \(.*$/, "");
88
+ return `${(err && err.constructor && err.constructor.name) || "Error"} thrown with no message${at ? ` in ${at}` : ""}`;
89
+ };
90
+
91
+ function parseArgs(argv) {
92
+ const files = [];
93
+ const options = { require: [], dump: undefined, quiet: false };
94
+ for (let i = 0; i < argv.length; i++) {
95
+ const arg = argv[i];
96
+ if (arg === "--require") options.require.push(argv[++i]);
97
+ else if (arg === "--dump") options.dump = argv[++i];
98
+ else if (arg === "--quiet") options.quiet = true;
99
+ else if (arg.startsWith("--")) usage();
100
+ else files.push(arg);
101
+ }
102
+ return { files, options };
103
+ }
104
+
105
+ // #region comparing records
106
+
107
+ /**
108
+ * the defaults of the NDJSON format, spelled out again here rather than imported from the codec:
109
+ * this tool is the thing that would catch a codec whose writer and reader agree with each other
110
+ * and with nothing else, so it must not take its notion of a default from the codec.
111
+ */
112
+ const NODE_DEFAULTS = {
113
+ valueRank: -1,
114
+ arrayDimensions: null,
115
+ minimumSamplingInterval: 0,
116
+ historizing: false,
117
+ isAbstract: false,
118
+ symmetric: false,
119
+ containsNoLoops: false,
120
+ hasNoPermissions: false,
121
+ eventNotifier: 0,
122
+ parentNodeId: null,
123
+ dataType: null,
124
+ methodDeclarationId: null
125
+ };
126
+ const FIELD_DEFAULTS = { valueRank: -1, allowSubTypes: false, dataType: "ns=0;i=24" };
127
+
128
+ /** a value of a record as a comparable form: ids, names, dates and buffers all get one spelling */
129
+ function normalize(value) {
130
+ if (value === undefined || value === null) return null;
131
+ if (typeof value !== "object") return value;
132
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "invalid-date" : value.toISOString();
133
+ if (ArrayBuffer.isView(value)) return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("base64");
134
+ if (Array.isArray(value)) return value.map(normalize);
135
+ // a NodeId, an ExpandedNodeId or a QualifiedName: its own spelling is canonical and unambiguous
136
+ if (typeof value.identifierType === "number" || typeof value.namespaceIndex === "number") return value.toString();
137
+ const out = {};
138
+ for (const key of Object.keys(value).sort()) {
139
+ if (value[key] === undefined) continue;
140
+ out[key] = normalize(value[key]);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** a record with the defaults filled in, so that one that states a default and one that omits it agree */
146
+ function canonicalRecord(record) {
147
+ if (record.kind === "header") return normalize(record);
148
+ const out = { ...record };
149
+ for (const [key, fallback] of Object.entries(NODE_DEFAULTS)) {
150
+ if (out[key] === undefined) out[key] = fallback;
151
+ }
152
+ // displayName defaults to the browse name; description and the rest have no default
153
+ if (out.displayName === undefined) out.displayName = record.browseName ? (record.browseName.name ?? "") : "";
154
+ // a value with no arrayType is a scalar (VariantArrayType.Scalar === 0)
155
+ if (out.value && out.value.arrayType === undefined) out.value = { ...out.value, arrayType: 0 };
156
+ // inverseDeclared is not in the document: it is a hint a producer that has seen the whole of it
157
+ // may leave on a reference, and the streaming XML reader, which has not, leaves undefined. The
158
+ // NDJSON knowing more than the XML does is not the NDJSON losing anything, so it is not compared
159
+ out.references = out.references.map((r) => ({ isForward: r.isForward, referenceType: r.referenceType, nodeId: r.nodeId }));
160
+ if (out.definition) {
161
+ out.definition = {
162
+ ...out.definition,
163
+ fields: out.definition.fields.map((f) => {
164
+ const field = { ...f };
165
+ for (const [key, fallback] of Object.entries(FIELD_DEFAULTS)) {
166
+ if (field[key] === undefined) field[key] = fallback;
167
+ }
168
+ if (typeof field.dataType === "object" && field.dataType !== null) field.dataType = field.dataType.toString();
169
+ return field;
170
+ })
171
+ };
172
+ }
173
+ return normalize(out);
174
+ }
175
+
176
+ /** the paths at which two canonical records differ, at most `limit` of them */
177
+ function differences(a, b, limit, prefix = "") {
178
+ const out = [];
179
+ const push = (p, x, y) => {
180
+ if (out.length < limit) out.push(`${p}: ${JSON.stringify(x)} != ${JSON.stringify(y)}`);
181
+ };
182
+ if (a === b) return out;
183
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
184
+ push(prefix || "(value)", a, b);
185
+ return out;
186
+ }
187
+ if (Array.isArray(a) !== Array.isArray(b) || (Array.isArray(a) && a.length !== b.length)) {
188
+ push(`${prefix}.length`, Array.isArray(a) ? a.length : "not-an-array", Array.isArray(b) ? b.length : "not-an-array");
189
+ return out;
190
+ }
191
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
192
+ if (out.length >= limit) break;
193
+ out.push(...differences(a[key], b[key], limit - out.length, prefix ? `${prefix}.${key}` : key));
194
+ }
195
+ return out;
196
+ }
197
+ // #endregion
198
+
199
+ // #region comparing two documents as models
200
+
201
+ /**
202
+ * The local namespace indices of two documents mean nothing to each other: a document is free to
203
+ * list its <NamespaceUris> in any order, and index 1 of the original need not be index 1 of the
204
+ * document written back out. Everything compared below is therefore first resolved through its own
205
+ * document's table into a fully qualified form, `<namespace uri>;<kind>=<identifier>`, which is the
206
+ * only spelling of an id that two documents can be held to.
207
+ */
208
+ const NODE_ID_KIND = { 1: "i", 2: "s", 3: "g", 4: "b" };
209
+
210
+ function tableOf(header) {
211
+ // index 0 of every document's table is the UA namespace, and is not listed in namespaceUris
212
+ return ["http://opcfoundation.org/UA/", ...(header.namespaceUris || [])];
213
+ }
214
+
215
+ const qualify = (nodeId, uris) => {
216
+ if (!nodeId) return null;
217
+ const uri = uris[nodeId.namespace] ?? `<unknown namespace ${nodeId.namespace}>`;
218
+ const value = Buffer.isBuffer(nodeId.value) ? nodeId.value.toString("base64") : String(nodeId.value);
219
+ return `${uri};${NODE_ID_KIND[nodeId.identifierType] ?? "?"}=${value}`;
220
+ };
221
+ const qualifyName = (name, uris) => (name ? `${uris[name.namespaceIndex] ?? name.namespaceIndex}#${name.name ?? ""}` : null);
222
+
223
+ /**
224
+ * a `<Definition Name="3:Foo">` and a `<Field Name="...">` carry the document's own namespace index
225
+ * as a text prefix, so the very same definition reads "3:Foo" in one document and "1:Foo" in
226
+ * another. Resolved like every other index, or two spellings of one name look like two names.
227
+ */
228
+ function qualifyPrefixedName(name, uris) {
229
+ if (typeof name !== "string") return name ?? null;
230
+ const m = /^(\d+):(.*)$/.exec(name);
231
+ if (!m) return name;
232
+ return `${uris[Number(m[1])] ?? m[1]}#${m[2]}`;
233
+ }
234
+
235
+ /** anything a record can hold, with every id and name inside it qualified the same way */
236
+ function deepQualify(value, uris) {
237
+ if (value === undefined || value === null) return null;
238
+ if (typeof value !== "object") return value;
239
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "invalid-date" : value.toISOString();
240
+ if (ArrayBuffer.isView(value)) return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("base64");
241
+ if (Array.isArray(value)) return value.map((e) => deepQualify(e, uris));
242
+ if (typeof value.identifierType === "number") return qualify(value, uris);
243
+ if (typeof value.namespaceIndex === "number") return qualifyName(value, uris);
244
+ const out = {};
245
+ for (const key of Object.keys(value).sort()) {
246
+ if (value[key] === undefined) continue;
247
+ out[key] = deepQualify(value[key], uris);
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /**
253
+ * the attributes of a node that the information model defines, with the defaults of the format
254
+ * applied so that a document that states one and a document that omits it agree.
255
+ *
256
+ * `parentNodeId` is not among them, and that is deliberate: it is an authoring convenience of the
257
+ * NodeSet2 syntax, wholly derivable from the HasComponent or HasProperty reference, and a node
258
+ * reachable from two parents has more than one correct answer -- so two documents may name
259
+ * different parents and describe the same model. It is reported separately (§ parentNodeId below).
260
+ */
261
+ const COMPARED_ATTRIBUTES = [
262
+ "nodeClass",
263
+ "displayName",
264
+ "description",
265
+ "symbolicName",
266
+ "inverseName",
267
+ "releaseStatus",
268
+ "accessRestrictions",
269
+ "accessLevel",
270
+ "userAccessLevel",
271
+ "hasNoPermissions",
272
+ "isAbstract",
273
+ "symmetric",
274
+ "containsNoLoops",
275
+ "eventNotifier",
276
+ "valueRank",
277
+ "arrayDimensions",
278
+ "minimumSamplingInterval",
279
+ "historizing",
280
+ "dataType",
281
+ "methodDeclarationId",
282
+ "rolePermissions",
283
+ "value",
284
+ "definition"
285
+ ];
286
+ const ATTRIBUTE_DEFAULTS = {
287
+ valueRank: -1,
288
+ arrayDimensions: null,
289
+ minimumSamplingInterval: 0,
290
+ historizing: false,
291
+ isAbstract: false,
292
+ symmetric: false,
293
+ containsNoLoops: false,
294
+ hasNoPermissions: false,
295
+ eventNotifier: 0,
296
+ dataType: null,
297
+ methodDeclarationId: null,
298
+ // an absent AccessLevel is CurrentRead; an absent UserAccessLevel is the AccessLevel
299
+ accessLevel: "1"
300
+ };
301
+
302
+ /** a document's records as a model: its namespace table, its nodes by qualified id, its references */
303
+ function modelOf(records) {
304
+ const header = records.find((r) => r.kind === "header");
305
+ if (!header) throw new Error("the document has no header record");
306
+ const uris = tableOf(header);
307
+ const nodes = new Map();
308
+ const parents = new Map();
309
+ const references = new Set();
310
+ // the namespaces the document actually draws on, as opposed to the ones it declares: a
311
+ // <NamespaceUris> entry that no id in the document refers to carries no model information
312
+ const used = new Set();
313
+ const note = (nodeId) => {
314
+ if (nodeId) used.add(uris[nodeId.namespace] ?? `<unknown namespace ${nodeId.namespace}>`);
315
+ };
316
+ for (const record of records) {
317
+ if (record.kind === "header") continue;
318
+ const id = qualify(record.nodeId, uris);
319
+ note(record.nodeId);
320
+ if (record.browseName) used.add(uris[record.browseName.namespaceIndex] ?? String(record.browseName.namespaceIndex));
321
+ const node = { browseName: qualifyName(record.browseName, uris) };
322
+ for (const key of COMPARED_ATTRIBUTES) {
323
+ const value = record[key] !== undefined ? deepQualify(record[key], uris) : (ATTRIBUTE_DEFAULTS[key] ?? undefined);
324
+ if (value !== undefined) node[key] = value;
325
+ }
326
+ if (node.displayName === undefined) node.displayName = record.browseName?.name ?? "";
327
+ if (node.userAccessLevel === undefined && node.accessLevel !== undefined) node.userAccessLevel = node.accessLevel;
328
+ if (node.value && node.value.arrayType === undefined) node.value = { ...node.value, arrayType: 0 };
329
+ if (node.definition) {
330
+ node.definition = { ...node.definition, name: qualifyPrefixedName(node.definition.name, uris) };
331
+ }
332
+ nodes.set(id, node);
333
+ parents.set(id, qualify(record.parentNodeId, uris));
334
+ for (const r of record.references) {
335
+ // a NodeSet2 document may declare a reference from either end; normalised to the
336
+ // forward direction, the two spellings collapse onto the same entry
337
+ const other = qualify(r.nodeId, uris);
338
+ const [source, target] = r.isForward ? [id, other] : [other, id];
339
+ references.add(`${source} -[${qualify(r.referenceType, uris)}]-> ${target}`);
340
+ note(r.nodeId);
341
+ note(r.referenceType);
342
+ }
343
+ }
344
+ return { uris, used, nodes, parents, references };
345
+ }
346
+
347
+ /** the entries of `set` that touch a node of `domain`, at either end */
348
+ const inDomain = (references, domain) =>
349
+ new Set([...references].filter((r) => domain.has(r.slice(0, r.indexOf(" -["))) || domain.has(r.slice(r.indexOf("]-> ") + 4))));
350
+
351
+ const sample = (items, n = 3) => [...items].slice(0, n).map((i) => JSON.stringify(i)).join(", ");
352
+ // #endregion
353
+
354
+ /**
355
+ * where the intermediates go. A check that fails is a thing to diff, so A, B and the two
356
+ * regenerated documents are always written somewhere and the place is always printed -- --dump
357
+ * chooses it, and without one they land under the system temp directory
358
+ */
359
+ function makeDump(dir) {
360
+ const target = dir ?? path.join(os.tmpdir(), "opcua-nodeset-live-equivalence");
361
+ let made = false;
362
+ const written = new Set();
363
+ return {
364
+ dir: target,
365
+ get used() {
366
+ return written.size > 0;
367
+ },
368
+ write(base, suffix, content) {
369
+ if (!made) {
370
+ fs.mkdirSync(target, { recursive: true });
371
+ made = true;
372
+ }
373
+ const file = path.join(target, `${base}.${suffix}`);
374
+ fs.writeFileSync(file, content);
375
+ written.add(file);
376
+ }
377
+ };
378
+ }
379
+
380
+ const collect = async (iterable) => {
381
+ const out = [];
382
+ for await (const item of iterable) out.push(item);
383
+ return out;
384
+ };
385
+
386
+ /** the header carries the moment it was written; pinned here so two writes compare as bytes */
387
+ const WRITER_OPTIONS = { addressSpaceVersion: "equivalence", createdAt: new Date(0) };
388
+
389
+ /** XML text -> the record stream -> the uncompressed NDJSON text, in one pass */
390
+ async function xmlToImageText(xml) {
391
+ const writer = new NodesetImageWriter(WRITER_OPTIONS);
392
+ const records = await collect(xmlNodesetRecords([xml]));
393
+ for (const record of records) writer.apply(record);
394
+ return { records, text: writer.text("equivalence", xml.length) };
395
+ }
396
+
397
+ /** the node lines of an NDJSON text, by node id: the document order is not part of what it says */
398
+ function nodeLinesById(text) {
399
+ const byId = new Map();
400
+ for (const line of text.split("\n")) {
401
+ if (!line || line.startsWith('{"kind":')) continue;
402
+ // the encoded id is a number or a tuple; its JSON is the key, so a tuple keys by value
403
+ byId.set(JSON.stringify(JSON.parse(line).nodeId), line);
404
+ }
405
+ return byId;
406
+ }
407
+
408
+ const gzipText = async (text) =>
409
+ new Uint8Array(await new Response(new Blob([text]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer());
410
+
411
+ /** the digest alone of an address space built over `sources` */
412
+ async function digestOf(sources) {
413
+ const addressSpace = AddressSpace.create();
414
+ try {
415
+ await generateAddressSpaceRaw(addressSpace, sources, {});
416
+ return digestAddressSpace(addressSpace);
417
+ } finally {
418
+ addressSpace.dispose();
419
+ }
420
+ }
421
+
422
+ /** the digest of an address space built over `sources`, and the NodeSet2 XML of `modelUri` in it */
423
+ async function xmlOf(sources, modelUri) {
424
+ const addressSpace = AddressSpace.create();
425
+ try {
426
+ await generateAddressSpaceRaw(addressSpace, sources, {});
427
+ const digest = digestAddressSpace(addressSpace);
428
+ const namespace = addressSpace.getNamespaceArray().find((n) => n.namespaceUri === modelUri);
429
+ if (!namespace) throw new Error(`namespace ${modelUri} not found after loading`);
430
+ return { digest, xml: namespace.toNodeset2XML() };
431
+ } finally {
432
+ addressSpace.dispose();
433
+ }
434
+ }
435
+
436
+ /** where two texts first part company, as a line number and the two lines */
437
+ function firstDifference(a, b, labelA, labelB) {
438
+ const left = a.split("\n");
439
+ const right = b.split("\n");
440
+ const at = left.findIndex((line, i) => line !== right[i]);
441
+ const index = at === -1 ? Math.min(left.length, right.length) : at;
442
+ return (
443
+ `line ${index + 1} differs:\n ${labelA} ${String(left[index]).slice(0, 300)}` +
444
+ `\n ${labelB} ${String(right[index]).slice(0, 300)}`
445
+ );
446
+ }
447
+
448
+ async function checkFile(file, options) {
449
+ const report = [];
450
+ const fail = (check, detail) => report.push({ check, ok: false, detail });
451
+ const pass = (check, detail) => report.push({ check, ok: true, detail });
452
+ // a check the XML side cannot run is not the NDJSON failing: the exporter refuses the UA
453
+ // namespace itself, and throws on a handful of values it cannot spell. Those are reported as
454
+ // skipped, with the reason, so that the checks that did run still stand on their own
455
+ const skip = (check, detail) => report.push({ check, ok: true, skipped: true, detail });
456
+
457
+ const xml = fs.readFileSync(file, "utf8");
458
+ const base = path.basename(file).replace(/\.xml$/i, "");
459
+ const chain = await dependencyChain(file, options.require);
460
+
461
+ // 1 - records: XML -> records -> NDJSON -> records
462
+ const { records: fromXml, text } = await xmlToImageText(xml);
463
+ // A goes to disk as soon as it exists, so that it is there to look at even when a later check
464
+ // cannot run at all
465
+ options.dump.write(base, "A.ndjson", text);
466
+ const image = await gzipText(text);
467
+ const fromImage = await collect(imageNodesetRecords(image, { expectedDigest: "equivalence" }));
468
+ if (fromXml.length !== fromImage.length) {
469
+ fail("records", `${fromXml.length} records from the XML, ${fromImage.length} from the NDJSON`);
470
+ } else {
471
+ const problems = [];
472
+ for (let i = 0; i < fromXml.length && problems.length < 10; i++) {
473
+ const diff = differences(canonicalRecord(fromXml[i]), canonicalRecord(fromImage[i]), 10 - problems.length);
474
+ const where = fromXml[i].kind === "header" ? "header" : fromXml[i].nodeId.toString();
475
+ for (const d of diff) problems.push(`${where} ${d}`);
476
+ }
477
+ if (problems.length) fail("records", problems.join("; "));
478
+ else pass("records", `${fromXml.length} records identical`);
479
+ }
480
+
481
+ // 2 - lines: those records re-encoded must be the same bytes
482
+ const rewriter = new NodesetImageWriter(WRITER_OPTIONS);
483
+ for (const record of fromImage) rewriter.apply(record);
484
+ const again = rewriter.text("equivalence", xml.length);
485
+ if (again !== text) fail("lines", firstDifference(text, again, "wrote", "again"));
486
+ else pass("lines", `${text.length} bytes reproduced`);
487
+
488
+ // 3 and 4 - the address space, and the XML written from it, either way round
489
+ const header = fromXml.find((r) => r.kind === "header");
490
+ const modelUri = header && header.models[0] && header.models[0].modelUri;
491
+ if (!modelUri) {
492
+ fail("addressspace", "the document declares no model");
493
+ return report;
494
+ }
495
+ const sourcesFromImage = chain.map((f) => (f === file ? { name: `${f} (ndjson)`, source: image } : asFile(f)));
496
+ let viaXml;
497
+ let viaImage;
498
+ try {
499
+ viaXml = await xmlOf(chain.map(asFile), modelUri);
500
+ viaImage = await xmlOf(sourcesFromImage, modelUri);
501
+ } catch (err) {
502
+ // the address space still loaded from both, or `digestOf` would not have got this far; it
503
+ // is writing the XML back out that failed, on both paths alike
504
+ const digests = await Promise.all([digestOf(chain.map(asFile)), digestOf(sourcesFromImage)]);
505
+ if (digests[0].hash !== digests[1].hash) {
506
+ fail("addressspace", `XML ${digests[0].hash.slice(0, 12)} != NDJSON ${digests[1].hash.slice(0, 12)}`);
507
+ } else {
508
+ pass("addressspace", `${digests[0].nodes} nodes, ${digests[0].references} references, same digest`);
509
+ }
510
+ skip("xml", `the XML exporter cannot write this namespace back out: ${reasonOf(err)}`);
511
+ skip("A==B nodes", "not run: it needs the exporter");
512
+ skip("A==B attrs", "not run: it needs the exporter");
513
+ skip("fixpoint", "not run: it needs the exporter");
514
+ return report;
515
+ }
516
+
517
+ if (viaXml.digest.hash !== viaImage.digest.hash) {
518
+ fail(
519
+ "addressspace",
520
+ `XML ${viaXml.digest.nodes} nodes/${viaXml.digest.references} refs ${viaXml.digest.hash.slice(0, 12)}` +
521
+ ` != NDJSON ${viaImage.digest.nodes}/${viaImage.digest.references} ${viaImage.digest.hash.slice(0, 12)}`
522
+ );
523
+ } else {
524
+ pass("addressspace", `${viaXml.digest.nodes} nodes, ${viaXml.digest.references} references, same digest`);
525
+ }
526
+
527
+ options.dump.write(base, "via-xml.xml", viaXml.xml);
528
+ options.dump.write(base, "via-ndjson.xml", viaImage.xml);
529
+
530
+ if (viaXml.xml !== viaImage.xml) {
531
+ fail("xml", firstDifference(viaXml.xml, viaImage.xml, "via XML ", "via NDJSON"));
532
+ return report;
533
+ }
534
+ pass("xml", `${viaXml.xml.length} bytes, identical either way`);
535
+
536
+ // 5 and 6 - A == B. A is the NDJSON of the document that came in, B the NDJSON of the one
537
+ // node-opcua wrote back out after the model had been all the way round. Everything is resolved
538
+ // through each document's own namespace table first: the two are free to order their
539
+ // <NamespaceUris> differently, so a local index is not something they can be held to
540
+ const before = modelOf(fromXml);
541
+ const { records: writtenBack, text: textB } = await xmlToImageText(viaImage.xml);
542
+ const after = modelOf(writtenBack);
543
+ options.dump.write(base, "B.ndjson", textB);
544
+
545
+ // the export writes one namespace; a node of the original outside it was never a candidate
546
+ const domain = new Set([...before.nodes.keys()].filter((id) => id.startsWith(`${modelUri};`)));
547
+ const outside = before.nodes.size - domain.size;
548
+ const producedOutside = [...after.nodes.keys()].filter((id) => !id.startsWith(`${modelUri};`));
549
+
550
+ // What the two documents must agree on is the namespaces they actually draw on. The declared
551
+ // <NamespaceUris> table is not that: it may list a namespace no id refers to, and its order is
552
+ // a local choice exactly as document order is -- every id here has already been resolved
553
+ // through its own table, so neither can change what the document says. Both are still worth
554
+ // knowing, and are reported below rather than swallowed
555
+ const lostUris = [...before.used].filter((u) => !after.used.has(u));
556
+ const gainedUris = [...after.used].filter((u) => !before.used.has(u));
557
+ const declaredDiffers =
558
+ before.uris.length !== after.uris.length || before.uris.some((uri, i) => uri !== after.uris[i]);
559
+ const lostNodes = [...domain].filter((id) => !after.nodes.has(id));
560
+ const gainedNodes = [...after.nodes.keys()].filter((id) => domain.has(id) === false && id.startsWith(`${modelUri};`));
561
+ const beforeRefs = inDomain(before.references, domain);
562
+ const afterRefs = inDomain(after.references, domain);
563
+ const lostRefs = [...beforeRefs].filter((r) => !afterRefs.has(r));
564
+ const gainedRefs = [...afterRefs].filter((r) => !beforeRefs.has(r));
565
+
566
+ const modelProblems = [];
567
+ if (lostUris.length) modelProblems.push(`${lostUris.length} namespaces no longer referred to (${sample(lostUris)})`);
568
+ if (gainedUris.length) modelProblems.push(`${gainedUris.length} namespaces newly referred to (${sample(gainedUris)})`);
569
+ if (lostNodes.length) modelProblems.push(`${lostNodes.length} nodes lost (${sample(lostNodes)})`);
570
+ if (gainedNodes.length) modelProblems.push(`${gainedNodes.length} nodes appeared (${sample(gainedNodes)})`);
571
+ if (producedOutside.length) {
572
+ modelProblems.push(`${producedOutside.length} nodes written outside ${modelUri} (${sample(producedOutside)})`);
573
+ }
574
+ if (lostRefs.length) modelProblems.push(`${lostRefs.length} references lost (${sample(lostRefs)})`);
575
+ if (gainedRefs.length) modelProblems.push(`${gainedRefs.length} references appeared (${sample(gainedRefs)})`);
576
+ if (modelProblems.length) {
577
+ fail("A==B nodes", modelProblems.join("; "));
578
+ } else {
579
+ pass(
580
+ "A==B nodes",
581
+ `${before.used.size} namespaces, ${domain.size} nodes, ${beforeRefs.size} references, all the same` +
582
+ `${outside ? ` (${outside} nodes of the original lie outside ${modelUri} and were never exported)` : ""}`
583
+ );
584
+ }
585
+
586
+ // 6 - and the attributes of each of those nodes
587
+ const byAttribute = new Map();
588
+ const examples = [];
589
+ for (const id of domain) {
590
+ const a = before.nodes.get(id);
591
+ const b = after.nodes.get(id);
592
+ if (!b) continue; // already reported as lost
593
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
594
+ if (JSON.stringify(a[key]) === JSON.stringify(b[key])) continue;
595
+ byAttribute.set(key, (byAttribute.get(key) ?? 0) + 1);
596
+ if (examples.length < 3) {
597
+ // for a nested value, the path of the first difference rather than 180 characters
598
+ // of two JSON blobs whose first 179 are identical
599
+ const inner = differences(a[key], b[key], 1, key);
600
+ examples.push(
601
+ inner.length
602
+ ? `\n ${id} ${inner[0].slice(0, 260)}`
603
+ : `\n ${id} ${key}` +
604
+ `\n A ${JSON.stringify(a[key] ?? null).slice(0, 180)}` +
605
+ `\n B ${JSON.stringify(b[key] ?? null).slice(0, 180)}`
606
+ );
607
+ }
608
+ }
609
+ }
610
+ if (byAttribute.size) {
611
+ const tally = [...byAttribute.entries()].sort((x, y) => y[1] - x[1]).map(([k, n]) => `${k}×${n}`);
612
+ // checks 1 to 4 have already shown the NDJSON reproduces this document exactly, so anything
613
+ // that shows up only here is the XML writer dropping something, not the format losing it
614
+ fail("A==B attrs", `${tally.join(", ")} differ — the NDJSON side is exact, so this is the XML export${examples.join("")}`);
615
+ } else {
616
+ pass("A==B attrs", `${COMPARED_ATTRIBUTES.length + 1} attributes agree on each of ${domain.size} nodes`);
617
+ }
618
+
619
+ // reported apart, for the same reason document order is: the two documents draw on the same
620
+ // namespaces and every id was resolved through its own table, so the order they list them in
621
+ // changes nothing about the model
622
+ if (declaredDiffers) {
623
+ const declaredExtra = after.uris.filter((u) => !before.uris.includes(u));
624
+ const declaredGone = before.uris.filter((u) => !after.uris.includes(u));
625
+ const how =
626
+ declaredExtra.length || declaredGone.length
627
+ ? `B declares ${after.uris.length - 1} namespaces where A declared ${before.uris.length - 1}` +
628
+ `${declaredExtra.length ? `, extra ${sample(declaredExtra)}` : ""}` +
629
+ `${declaredGone.length ? `, dropped ${sample(declaredGone)}` : ""}`
630
+ : `the same ${after.uris.length - 1} namespaces, listed in another order`;
631
+ report.push({
632
+ check: "ns-table",
633
+ ok: true,
634
+ detail: `${how}; the ${before.used.size} actually referred to are the same, which is what the ids resolve through`
635
+ });
636
+ }
637
+
638
+ // parentNodeId is reported apart: it is derivable from the aggregating reference, and a node
639
+ // with more than one aggregating parent has more than one correct answer, so a difference here
640
+ // is a difference of spelling and not of model
641
+ const reparented = [...domain].filter((id) => after.nodes.has(id) && before.parents.get(id) !== after.parents.get(id));
642
+ if (reparented.length) {
643
+ report.push({
644
+ check: "parentNodeId",
645
+ ok: true,
646
+ detail:
647
+ `${reparented.length} of ${domain.size} nodes are written under another of their aggregating parents` +
648
+ ` (first ${JSON.stringify(reparented[0])}); derivable from the references, which match`
649
+ });
650
+ }
651
+
652
+ // 5 - a second pass, over the exporter's own XML this time, which reaches constructs a
653
+ // hand-written file may not have. The question is still "does the trip through NDJSON change
654
+ // anything", so the two second passes are compared with each other: whatever the XML pipeline
655
+ // does to a document on its own, the one that went through NDJSON must come out the same.
656
+ // Compared as node records rather than as bytes: the one freedom the exporter takes on the
657
+ // second pass is the order it writes the nodes in, and neither form defines a document order.
658
+ const canonical = viaXml.xml;
659
+ const { text: canonicalText } = await xmlToImageText(canonical);
660
+ const canonicalImage = await gzipText(canonicalText);
661
+ const canonicalFile = { name: `${file} (canonical xml)`, source: () => [canonical] };
662
+ let secondViaXml;
663
+ let secondViaImage;
664
+ try {
665
+ secondViaXml = await xmlOf(
666
+ chain.map((f) => (f === file ? canonicalFile : asFile(f))),
667
+ modelUri
668
+ );
669
+ secondViaImage = await xmlOf(
670
+ chain.map((f) => (f === file ? { name: `${file} (canonical ndjson)`, source: canonicalImage } : asFile(f))),
671
+ modelUri
672
+ );
673
+ } catch (err) {
674
+ skip("fixpoint", `the second pass could not be written back out: ${reasonOf(err)}`);
675
+ return report;
676
+ }
677
+ const { text: afterXml } = await xmlToImageText(secondViaXml.xml);
678
+ const { text: afterImage } = await xmlToImageText(secondViaImage.xml);
679
+ const first = nodeLinesById(afterXml);
680
+ const second = nodeLinesById(afterImage);
681
+ const missing = [...first.keys()].filter((id) => !second.has(id));
682
+ const added = [...second.keys()].filter((id) => !first.has(id));
683
+ const changed = [...first.entries()].filter(([id, line]) => second.has(id) && second.get(id) !== line);
684
+ if (missing.length || added.length || changed.length) {
685
+ const detail = [];
686
+ if (missing.length) detail.push(`${missing.length} nodes lost (${JSON.stringify(missing.slice(0, 3))})`);
687
+ if (added.length) detail.push(`${added.length} nodes appeared (${JSON.stringify(added.slice(0, 3))})`);
688
+ for (const [id, line] of changed.slice(0, 3)) {
689
+ detail.push(`node ${JSON.stringify(id)}
690
+ via XML ${line.slice(0, 260)}
691
+ via NDJSON ${second.get(id).slice(0, 260)}`);
692
+ }
693
+ fail("fixpoint", detail.join("; "));
694
+ } else {
695
+ pass("fixpoint", `${first.size} nodes, the second pass says the same either way`);
696
+ }
697
+
698
+ // and, as information rather than as a verdict: whether the XML pipeline is idempotent at all.
699
+ // A node the exporter writes and the loader will not take back is a defect of the XML side, and
700
+ // is not the NDJSON losing anything -- but it is worth knowing, so it is printed
701
+ const firstPass = nodeLinesById(canonicalText);
702
+ const drifted = [...firstPass.entries()].filter(([id, line]) => !first.has(id) || first.get(id) !== line);
703
+ report.push({
704
+ check: "xml-idempotent",
705
+ ok: true,
706
+ detail: drifted.length
707
+ ? `no: ${drifted.length} of ${firstPass.size} nodes come back changed from the XML pipeline alone` +
708
+ ` (first ${JSON.stringify(drifted[0][0])}); the NDJSON tracks it exactly`
709
+ : `yes: all ${firstPass.size} nodes survive a second XML pass unchanged`
710
+ });
711
+ return report;
712
+ }
713
+
714
+ async function main(argv) {
715
+ const { files, options } = parseArgs(argv);
716
+ if (files.length === 0) usage();
717
+ const dump = makeDump(options.dump);
718
+ options.dump = dump;
719
+ let failed = 0;
720
+ for (const file of files) {
721
+ const t0 = performance.now();
722
+ let report;
723
+ try {
724
+ report = await checkFile(file, options);
725
+ } catch (err) {
726
+ report = [{ check: "load", ok: false, detail: process.env.EQUIVALENCE_STACK ? err.stack : reasonOf(err) }];
727
+ }
728
+ const bad = report.filter((r) => !r.ok);
729
+ const skipped = report.filter((r) => r.skipped);
730
+ failed += bad.length ? 1 : 0;
731
+ // a check that could not run is not a check that passed: saying "equivalent" over a file
732
+ // whose two strongest checks were skipped is exactly the reassurance this tool must not give
733
+ const verdict = bad.length
734
+ ? `${bad.length} FAILED`
735
+ : skipped.length
736
+ ? `${report.length - skipped.length} checks passed, ${skipped.length} COULD NOT RUN`
737
+ : "equivalent";
738
+ console.log(`${path.basename(file)}: ${verdict} (${(performance.now() - t0).toFixed(0)} ms)`);
739
+ for (const line of report) {
740
+ if (line.ok && options.quiet) continue;
741
+ console.log(` ${line.skipped ? "skip" : line.ok ? "ok " : "FAIL"} ${line.check.padEnd(13)} ${line.detail}`);
742
+ }
743
+ }
744
+ if (dump.used) {
745
+ console.log(`\nA, B and the regenerated documents: ${dump.dir}`);
746
+ }
747
+ return failed > 0 ? 1 : 0;
748
+ }
749
+
750
+ module.exports = { equivalence: main };