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.
- package/api/helpers/ensure_secure_access.ts +53 -2
- package/api/index.ts +30 -1
- package/api/loader/generateAddressSpaceRaw.ts +44 -139
- package/api/loader/load_nodeset2.ts +5 -0
- package/api/loader/narrow_instance_datatypes.ts +264 -0
- package/api/loader/nodeset_builtin_formats.ts +143 -0
- package/api/loader/nodeset_format.ts +153 -0
- package/api/loader/nodeset_image.ts +37 -4
- package/api/loader/nodeset_image_codec.ts +153 -34
- package/api/loader/nodeset_record.ts +26 -2
- package/api/loader/nodeset_record_applier.ts +54 -3
- package/api/loader/nodeset_records_to_xml.ts +620 -0
- package/api/loader/nodeset_source.ts +185 -26
- package/api/loader/nodeset_to_image.ts +16 -5
- package/api/loader/nodeset_xml_header.ts +119 -0
- package/api/loader/nodeset_xml_primitives.ts +209 -0
- package/api/loader/nodeset_xml_producer.ts +61 -2
- package/api/loader/parsers/variant_parser.ts +9 -0
- package/bin/nodeset_equivalence.js +750 -0
- package/bin/nodeset_tool_common.js +72 -0
- package/bin/opcua-nodeset-image.js +93 -103
- package/bin/opcua-nodeset-live.js +139 -0
- package/dist/api/helpers/ensure_secure_access.d.ts +20 -0
- package/dist/api/helpers/ensure_secure_access.js +39 -0
- package/dist/api/helpers/ensure_secure_access.js.map +1 -1
- package/dist/api/index.d.ts +7 -2
- package/dist/api/index.js +21 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/loader/generateAddressSpaceRaw.d.ts +1 -7
- package/dist/api/loader/generateAddressSpaceRaw.js +32 -125
- package/dist/api/loader/generateAddressSpaceRaw.js.map +1 -1
- package/dist/api/loader/load_nodeset2.js +4 -0
- package/dist/api/loader/load_nodeset2.js.map +1 -1
- package/dist/api/loader/narrow_instance_datatypes.d.ts +30 -0
- package/dist/api/loader/narrow_instance_datatypes.js +217 -0
- package/dist/api/loader/narrow_instance_datatypes.js.map +1 -0
- package/dist/api/loader/nodeset_builtin_formats.d.ts +27 -0
- package/dist/api/loader/nodeset_builtin_formats.js +133 -0
- package/dist/api/loader/nodeset_builtin_formats.js.map +1 -0
- package/dist/api/loader/nodeset_format.d.ts +123 -0
- package/dist/api/loader/nodeset_format.js +63 -0
- package/dist/api/loader/nodeset_format.js.map +1 -0
- package/dist/api/loader/nodeset_image.d.ts +17 -1
- package/dist/api/loader/nodeset_image.js +32 -4
- package/dist/api/loader/nodeset_image.js.map +1 -1
- package/dist/api/loader/nodeset_image_codec.d.ts +4 -0
- package/dist/api/loader/nodeset_image_codec.js +142 -53
- package/dist/api/loader/nodeset_image_codec.js.map +1 -1
- package/dist/api/loader/nodeset_record.d.ts +26 -2
- package/dist/api/loader/nodeset_record.js +11 -2
- package/dist/api/loader/nodeset_record.js.map +1 -1
- package/dist/api/loader/nodeset_record_applier.js +52 -3
- package/dist/api/loader/nodeset_record_applier.js.map +1 -1
- package/dist/api/loader/nodeset_records_to_xml.d.ts +16 -0
- package/dist/api/loader/nodeset_records_to_xml.js +536 -0
- package/dist/api/loader/nodeset_records_to_xml.js.map +1 -0
- package/dist/api/loader/nodeset_source.d.ts +68 -7
- package/dist/api/loader/nodeset_source.js +157 -20
- package/dist/api/loader/nodeset_source.js.map +1 -1
- package/dist/api/loader/nodeset_to_image.d.ts +4 -4
- package/dist/api/loader/nodeset_to_image.js +14 -4
- package/dist/api/loader/nodeset_to_image.js.map +1 -1
- package/dist/api/loader/nodeset_xml_header.d.ts +22 -0
- package/dist/api/loader/nodeset_xml_header.js +113 -0
- package/dist/api/loader/nodeset_xml_header.js.map +1 -0
- package/dist/api/loader/nodeset_xml_primitives.d.ts +37 -0
- package/dist/api/loader/nodeset_xml_primitives.js +196 -0
- package/dist/api/loader/nodeset_xml_primitives.js.map +1 -0
- package/dist/api/loader/nodeset_xml_producer.js +56 -1
- package/dist/api/loader/nodeset_xml_producer.js.map +1 -1
- package/dist/api/loader/parsers/variant_parser.js +7 -0
- package/dist/api/loader/parsers/variant_parser.js.map +1 -1
- package/dist/impl/_instantiate_helpers.js +1 -0
- package/dist/impl/_instantiate_helpers.js.map +1 -1
- package/dist/impl/alarms_and_conditions/ua_alarm_condition_impl.js +4 -0
- package/dist/impl/alarms_and_conditions/ua_alarm_condition_impl.js.map +1 -1
- package/dist/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.js +14 -0
- package/dist/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.js.map +1 -1
- package/dist/impl/base_node_impl.d.ts +9 -0
- package/dist/impl/base_node_impl.js +11 -0
- package/dist/impl/base_node_impl.js.map +1 -1
- package/dist/impl/base_node_private.js +67 -97
- package/dist/impl/base_node_private.js.map +1 -1
- package/dist/impl/nodeset_tools/construct_namespace_dependency.js +5 -1
- package/dist/impl/nodeset_tools/construct_namespace_dependency.js.map +1 -1
- package/dist/impl/nodeset_tools/nodeset_to_records.js +61 -15
- package/dist/impl/nodeset_tools/nodeset_to_records.js.map +1 -1
- package/dist/impl/nodeset_tools/nodeset_to_xml.d.ts +1 -8
- package/dist/impl/nodeset_tools/nodeset_to_xml.js +174 -190
- package/dist/impl/nodeset_tools/nodeset_to_xml.js.map +1 -1
- package/dist/impl/ua_data_type_impl.d.ts +21 -0
- package/dist/impl/ua_data_type_impl.js +27 -2
- package/dist/impl/ua_data_type_impl.js.map +1 -1
- package/dist/impl/ua_object_type_impl.d.ts +9 -0
- package/dist/impl/ua_object_type_impl.js +12 -1
- package/dist/impl/ua_object_type_impl.js.map +1 -1
- package/dist/impl/ua_variable_impl.js +27 -10
- package/dist/impl/ua_variable_impl.js.map +1 -1
- package/dist/impl/ua_variable_impl_ext_obj.js +19 -2
- package/dist/impl/ua_variable_impl_ext_obj.js.map +1 -1
- package/dist/impl/ua_variable_type_impl.d.ts +8 -1
- package/dist/impl/ua_variable_type_impl.js +39 -2
- package/dist/impl/ua_variable_type_impl.js.map +1 -1
- package/dist/tsconfig_base.tsbuildinfo +1 -1
- package/distNodeJS/generate_address_space.js +4 -0
- package/distNodeJS/generate_address_space.js.map +1 -1
- package/impl/_instantiate_helpers.ts +3 -0
- package/impl/alarms_and_conditions/ua_acknowledgeable_condition_impl.ts +4 -4
- package/impl/alarms_and_conditions/ua_alarm_condition_impl.ts +19 -9
- package/impl/alarms_and_conditions/ua_base_event_impl.ts +9 -9
- package/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.ts +17 -0
- package/impl/alarms_and_conditions/ua_condition_impl.ts +18 -18
- package/impl/alarms_and_conditions/ua_exclusive_deviation_alarm_impl.ts +2 -2
- package/impl/alarms_and_conditions/ua_exclusive_limit_alarm_impl.ts +1 -1
- package/impl/alarms_and_conditions/ua_limit_alarm_impl.ts +16 -16
- package/impl/alarms_and_conditions/ua_non_exclusive_deviation_alarm_impl.ts +2 -2
- package/impl/alarms_and_conditions/ua_non_exclusive_limit_alarm_impl.ts +4 -4
- package/impl/alarms_and_conditions/ua_off_normal_alarm_impl.ts +1 -1
- package/impl/base_node_impl.ts +15 -0
- package/impl/base_node_private.ts +97 -146
- package/impl/data_access/ua_multistate_discrete_impl.ts +1 -1
- package/impl/data_access/ua_multistate_value_discrete_impl.ts +2 -2
- package/impl/data_access/ua_two_state_discrete_impl.ts +2 -2
- package/impl/nodeset_tools/construct_namespace_dependency.ts +5 -1
- package/impl/nodeset_tools/nodeset_to_records.ts +66 -16
- package/impl/nodeset_tools/nodeset_to_xml.ts +127 -160
- package/impl/state_machine/finite_state_machine.ts +3 -3
- package/impl/state_machine/ua_shelving_state_machine_ex.ts +20 -20
- package/impl/ua_data_type_impl.ts +42 -4
- package/impl/ua_method_impl.ts +1 -1
- package/impl/ua_object_type_impl.ts +16 -3
- package/impl/ua_variable_impl.ts +27 -10
- package/impl/ua_variable_impl_ext_obj.ts +20 -2
- package/impl/ua_variable_type_impl.ts +49 -3
- package/package.json +29 -27
- 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 };
|