@techspokes/typescript-wsdl-client 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/checkedPipeline.d.ts +73 -2
- package/dist/checkedPipeline.js +118 -8
- package/dist/checkedPlanning.d.ts +5 -2
- package/dist/checkedPlanning.js +30 -5
- package/dist/checkedResponseInspection.d.ts +100 -0
- package/dist/checkedResponseInspection.js +499 -0
- package/dist/cli.js +55 -1
- package/dist/client/checkedDirectRuntime.d.ts +8 -0
- package/dist/client/checkedDirectRuntime.js +22 -14
- package/dist/client/directClientRuntime.d.ts +10 -3
- package/dist/client/directClientRuntime.js +14 -8
- package/dist/client/directClientVersion.d.ts +2 -0
- package/dist/client/directClientVersion.js +2 -0
- package/dist/client/generateDirectClient.js +7 -7
- package/dist/compiler/componentRecordProjection.d.ts +1 -0
- package/dist/compiler/componentRecordProjection.js +15 -4
- package/dist/compiler/directCodec.js +3 -11
- package/dist/compiler/directSoap.d.ts +50 -5
- package/dist/compiler/directSoap.js +204 -98
- package/dist/compiler/sourceCorrections.d.ts +118 -0
- package/dist/compiler/sourceCorrections.js +794 -0
- package/dist/gateway/checkedDocumentation.d.ts +14 -0
- package/dist/gateway/checkedDocumentation.js +106 -0
- package/dist/gateway/generateCheckedGateway.js +30 -53
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/util/readBoundedFile.d.ts +9 -0
- package/dist/util/readBoundedFile.js +36 -0
- package/dist/util/xmlBytes.d.ts +9 -0
- package/dist/util/xmlBytes.js +24 -0
- package/docs/api-reference.md +30 -1
- package/docs/checked-gateway.md +48 -0
- package/docs/cli-reference.md +16 -1
- package/docs/migration.md +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
/** Explicit, source-pinned XML corrections for loaded schema inputs. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { copyOriginalSchemaResource } from "../loader/schemaInput.js";
|
|
4
|
+
import { decodeMetadataXml, isXmlNCName, syntaxElements, XSD_NAMESPACE } from "../loader/orderedSyntax.js";
|
|
5
|
+
import { DEFAULT_GRAPH_NODES, GraphError, isBuiltinType } from "./canonicalGraph.js";
|
|
6
|
+
import { buildCanonicalGraph } from "./buildCanonicalGraph.js";
|
|
7
|
+
import { namespaceAllows, wildcardNamespaces } from './wildcardAlgebra.js';
|
|
8
|
+
const RECIPE_VERSION = 1;
|
|
9
|
+
const MAX_RECIPE_BYTES = 256 * 1024;
|
|
10
|
+
const MAX_RECIPE_DEPTH = 32;
|
|
11
|
+
const MAX_CORRECTIONS = 256;
|
|
12
|
+
const MAX_OUTPUT_GROWTH = 2 * 1024 * 1024;
|
|
13
|
+
const MAX_SYNTAX_INDEX_NODES = DEFAULT_GRAPH_NODES;
|
|
14
|
+
const MAX_LEXICAL_SCAN = 64 * 1024 * 1024;
|
|
15
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
16
|
+
export class SourceCorrectionError extends Error {
|
|
17
|
+
category;
|
|
18
|
+
constructor(category, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.category = category;
|
|
21
|
+
this.name = "SourceCorrectionError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function fail(category, message) {
|
|
25
|
+
throw new SourceCorrectionError(category, message);
|
|
26
|
+
}
|
|
27
|
+
const checkAbort = (signal) => {
|
|
28
|
+
if (signal?.aborted)
|
|
29
|
+
fail("cancelled", "Source correction cancelled");
|
|
30
|
+
};
|
|
31
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
32
|
+
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
33
|
+
const sourceKey = (uri, digest, path) => JSON.stringify([uri, digest, path]);
|
|
34
|
+
const selectorKey = (selector) => JSON.stringify([selector.id, selector.uri, selector.digest]);
|
|
35
|
+
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
36
|
+
function scanCheckpoint(budget, signal) {
|
|
37
|
+
checkAbort(signal);
|
|
38
|
+
budget.remaining -= 1024;
|
|
39
|
+
if (budget.remaining < 0)
|
|
40
|
+
fail("resource-limit", "Source correction lexical scan exceeds its work limit");
|
|
41
|
+
}
|
|
42
|
+
function boundedJsonSize(value, signal) {
|
|
43
|
+
const active = new Set();
|
|
44
|
+
let size = 0, steps = 0;
|
|
45
|
+
const visit = (current, depth) => {
|
|
46
|
+
if ((steps++ & 255) === 0)
|
|
47
|
+
checkAbort(signal);
|
|
48
|
+
if (depth > MAX_RECIPE_DEPTH)
|
|
49
|
+
fail("invalid-recipe", "Source correction recipe JSON nesting is too deep");
|
|
50
|
+
if (current === null) {
|
|
51
|
+
size += 4;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (typeof current === "string") {
|
|
55
|
+
size += current.length * 6 + 2;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (typeof current === "number" || typeof current === "boolean") {
|
|
59
|
+
size += 16;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (typeof current !== "object")
|
|
63
|
+
fail("invalid-recipe", "Source correction recipe must contain JSON data only");
|
|
64
|
+
if (active.has(current))
|
|
65
|
+
fail("invalid-recipe", "Source correction recipe must not contain cycles");
|
|
66
|
+
active.add(current);
|
|
67
|
+
if (Array.isArray(current)) {
|
|
68
|
+
size += 2;
|
|
69
|
+
for (const item of current) {
|
|
70
|
+
visit(item, depth + 1);
|
|
71
|
+
size += 1;
|
|
72
|
+
if (size > MAX_RECIPE_BYTES)
|
|
73
|
+
fail("resource-limit", "Source correction recipe exceeds the size limit");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const prototype = Object.getPrototypeOf(current);
|
|
78
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
79
|
+
fail("invalid-recipe", "Source correction recipe objects must be plain JSON objects");
|
|
80
|
+
size += 2;
|
|
81
|
+
for (const [key, child] of Object.entries(current)) {
|
|
82
|
+
size += key.length * 6 + 4;
|
|
83
|
+
visit(child, depth + 1);
|
|
84
|
+
if (size > MAX_RECIPE_BYTES)
|
|
85
|
+
fail("resource-limit", "Source correction recipe exceeds the size limit");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
active.delete(current);
|
|
89
|
+
if (size > MAX_RECIPE_BYTES)
|
|
90
|
+
fail("resource-limit", "Source correction recipe exceeds the size limit");
|
|
91
|
+
};
|
|
92
|
+
visit(value, 0);
|
|
93
|
+
return size;
|
|
94
|
+
}
|
|
95
|
+
function record(value, fields, label) {
|
|
96
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
97
|
+
fail("invalid-recipe", `${label} must be an object`);
|
|
98
|
+
const object = value, keys = Object.keys(object);
|
|
99
|
+
if (keys.length !== fields.length || keys.some(key => !fields.includes(key)))
|
|
100
|
+
fail("invalid-recipe", `${label} has missing or unknown fields`);
|
|
101
|
+
return object;
|
|
102
|
+
}
|
|
103
|
+
function text(value, label, allowEmpty = false) {
|
|
104
|
+
if (typeof value !== "string" || (!allowEmpty && value.length === 0))
|
|
105
|
+
fail("invalid-recipe", `${label} must be ${allowEmpty ? "a string" : "a nonempty string"}`);
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
function digest(value, label) {
|
|
109
|
+
const valueText = text(value, label);
|
|
110
|
+
if (!HASH.test(valueText))
|
|
111
|
+
fail("invalid-recipe", `${label} must be a lowercase SHA-256 digest`);
|
|
112
|
+
return valueText;
|
|
113
|
+
}
|
|
114
|
+
function selector(value, label) {
|
|
115
|
+
const item = record(value, ["id", "uri", "digest"], label);
|
|
116
|
+
return Object.freeze({ id: text(item.id, `${label}.id`), uri: text(item.uri, `${label}.uri`), digest: digest(item.digest, `${label}.digest`) });
|
|
117
|
+
}
|
|
118
|
+
function occurrence(value, label, allowUnbounded) {
|
|
119
|
+
const result = text(value, label);
|
|
120
|
+
if (allowUnbounded && result === "unbounded")
|
|
121
|
+
return result;
|
|
122
|
+
if (!/^(0|[1-9][0-9]*)$/.test(result))
|
|
123
|
+
fail("invalid-recipe", `${label} must be a canonical nonnegative integer${allowUnbounded ? " or unbounded" : ""}`);
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
function checkOccurs(min, max, label) {
|
|
127
|
+
if (max !== "unbounded" && (min.length > max.length || (min.length === max.length && min > max)))
|
|
128
|
+
fail("invalid-recipe", `${label} minimum exceeds maximum`);
|
|
129
|
+
}
|
|
130
|
+
function parseRecipe(value, signal) {
|
|
131
|
+
boundedJsonSize(value, signal);
|
|
132
|
+
const root = record(value, ["version", "resources", "corrections"], "recipe");
|
|
133
|
+
if (root.version !== RECIPE_VERSION)
|
|
134
|
+
fail("invalid-recipe", "Unsupported source correction recipe version");
|
|
135
|
+
if (!Array.isArray(root.resources) || !Array.isArray(root.corrections))
|
|
136
|
+
fail("invalid-recipe", "Recipe resources and corrections must be arrays");
|
|
137
|
+
if (root.corrections.length > MAX_CORRECTIONS)
|
|
138
|
+
fail("resource-limit", "Source correction count exceeds the limit");
|
|
139
|
+
const resources = root.resources.map((raw, index) => {
|
|
140
|
+
const item = record(raw, ["uri", "digest"], `resources[${index}]`);
|
|
141
|
+
return Object.freeze({ uri: text(item.uri, `resources[${index}].uri`), digest: digest(item.digest, `resources[${index}].digest`) });
|
|
142
|
+
});
|
|
143
|
+
const corrections = root.corrections.map((raw, index) => {
|
|
144
|
+
checkAbort(signal);
|
|
145
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
146
|
+
fail("invalid-recipe", `corrections[${index}] must be an object`);
|
|
147
|
+
const kind = raw.kind;
|
|
148
|
+
if (kind === "set-occurs") {
|
|
149
|
+
const item = record(raw, ["kind", "target", "min", "max"], `corrections[${index}]`);
|
|
150
|
+
const min = occurrence(item.min, `corrections[${index}].min`, false), max = occurrence(item.max, `corrections[${index}].max`, true);
|
|
151
|
+
checkOccurs(min, max, `corrections[${index}]`);
|
|
152
|
+
return Object.freeze({ kind, target: selector(item.target, `corrections[${index}].target`), min, max });
|
|
153
|
+
}
|
|
154
|
+
if (kind === "set-wildcard-namespaces") {
|
|
155
|
+
const item = record(raw, ["kind", "target", "namespaces"], `corrections[${index}]`);
|
|
156
|
+
if (!Array.isArray(item.namespaces) || item.namespaces.length === 0)
|
|
157
|
+
fail("invalid-recipe", `corrections[${index}].namespaces must be a nonempty array`);
|
|
158
|
+
const namespaces = item.namespaces.map((entry, offset) => {
|
|
159
|
+
const value = text(entry, `corrections[${index}].namespaces[${offset}]`, true);
|
|
160
|
+
if (/\s/.test(value) || value.startsWith("##"))
|
|
161
|
+
fail("invalid-recipe", "Wildcard namespace entries must be namespace names, not tokens or whitespace-separated values");
|
|
162
|
+
return value;
|
|
163
|
+
});
|
|
164
|
+
if (new Set(namespaces).size !== namespaces.length)
|
|
165
|
+
fail("invalid-recipe", "Wildcard namespace entries must be unique");
|
|
166
|
+
return Object.freeze({ kind, target: selector(item.target, `corrections[${index}].target`), namespaces: Object.freeze(namespaces) });
|
|
167
|
+
}
|
|
168
|
+
if (kind === "insert-element") {
|
|
169
|
+
const item = record(raw, ["kind", "target", "position", "element"], `corrections[${index}]`);
|
|
170
|
+
let position;
|
|
171
|
+
if (item.position === "append")
|
|
172
|
+
position = "append";
|
|
173
|
+
else {
|
|
174
|
+
const p = record(item.position, ["kind", "target"], `corrections[${index}].position`);
|
|
175
|
+
if (p.kind !== "before" && p.kind !== "after")
|
|
176
|
+
fail("invalid-recipe", `corrections[${index}].position.kind must be before or after`);
|
|
177
|
+
position = Object.freeze({ kind: p.kind, target: selector(p.target, `corrections[${index}].position.target`) });
|
|
178
|
+
}
|
|
179
|
+
const elementRaw = item.element;
|
|
180
|
+
if (!elementRaw || typeof elementRaw !== "object" || Array.isArray(elementRaw))
|
|
181
|
+
fail("invalid-recipe", `corrections[${index}].element must be an object`);
|
|
182
|
+
const elementKind = elementRaw.kind;
|
|
183
|
+
let element;
|
|
184
|
+
if (elementKind === "declaration") {
|
|
185
|
+
const e = record(elementRaw, ["kind", "name", "type", "min", "max"], `corrections[${index}].element`);
|
|
186
|
+
const typeValue = record(e.type, ["namespace", "local"], `corrections[${index}].element.type`);
|
|
187
|
+
const name = text(e.name, `corrections[${index}].element.name`);
|
|
188
|
+
if (!isXmlNCName(name))
|
|
189
|
+
fail("invalid-recipe", "Inserted element name must be an XML NCName");
|
|
190
|
+
const typeNamespace = text(typeValue.namespace, "type.namespace", true), typeLocal = text(typeValue.local, "type.local");
|
|
191
|
+
if (!isXmlNCName(typeLocal))
|
|
192
|
+
fail("invalid-recipe", "Inserted element type local name must be an XML NCName");
|
|
193
|
+
const min = occurrence(e.min, `corrections[${index}].element.min`, false), max = occurrence(e.max, `corrections[${index}].element.max`, true);
|
|
194
|
+
checkOccurs(min, max, `corrections[${index}].element`);
|
|
195
|
+
element = Object.freeze({ kind: elementKind, name, type: Object.freeze({ namespace: typeNamespace, local: typeLocal }), min, max });
|
|
196
|
+
}
|
|
197
|
+
else if (elementKind === "reference") {
|
|
198
|
+
const e = record(elementRaw, ["kind", "target", "min", "max"], `corrections[${index}].element`);
|
|
199
|
+
const min = occurrence(e.min, `corrections[${index}].element.min`, false), max = occurrence(e.max, `corrections[${index}].element.max`, true);
|
|
200
|
+
checkOccurs(min, max, `corrections[${index}].element`);
|
|
201
|
+
element = Object.freeze({ kind: elementKind, target: selector(e.target, `corrections[${index}].element.target`), min, max });
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
fail("invalid-recipe", `corrections[${index}].element.kind must be declaration or reference`);
|
|
205
|
+
return Object.freeze({ kind, target: selector(item.target, `corrections[${index}].target`), position, element });
|
|
206
|
+
}
|
|
207
|
+
return fail("invalid-recipe", `Unknown correction kind at corrections[${index}]`);
|
|
208
|
+
});
|
|
209
|
+
return Object.freeze({ version: RECIPE_VERSION, resources: Object.freeze(resources), corrections: Object.freeze(corrections) });
|
|
210
|
+
}
|
|
211
|
+
function syntaxIndex(input, signal) {
|
|
212
|
+
const index = new Map();
|
|
213
|
+
let count = 0;
|
|
214
|
+
const visit = (node) => {
|
|
215
|
+
if ((count++ & 127) === 0)
|
|
216
|
+
checkAbort(signal);
|
|
217
|
+
if (count > MAX_SYNTAX_INDEX_NODES)
|
|
218
|
+
fail("resource-limit", "Source correction XML span index exceeds the node limit");
|
|
219
|
+
index.set(sourceKey(node.source.uri, node.source.digest, node.source.path), node);
|
|
220
|
+
for (const child of syntaxElements(node))
|
|
221
|
+
visit(child);
|
|
222
|
+
};
|
|
223
|
+
for (const doc of input.documents)
|
|
224
|
+
visit(doc.root);
|
|
225
|
+
return index;
|
|
226
|
+
}
|
|
227
|
+
function nodeRole(identity) { return identity.role; }
|
|
228
|
+
function nodeName(node) {
|
|
229
|
+
if (node.kind === "element" || node.kind === "attribute" || node.kind === "wsdl")
|
|
230
|
+
return node.name;
|
|
231
|
+
return node.identity.kind === "global" ? node.identity.name : undefined;
|
|
232
|
+
}
|
|
233
|
+
function targetMap(inputs, signal) {
|
|
234
|
+
if (!Array.isArray(inputs) || inputs.length === 0)
|
|
235
|
+
fail("invalid-recipe", "At least one authenticated source input is required");
|
|
236
|
+
const sources = new Map(), aliases = new Map(), targets = new Map();
|
|
237
|
+
const contextNamespaces = new Map(), contextChameleon = new Map();
|
|
238
|
+
let nodeCount = 0, byteCount = 0;
|
|
239
|
+
const resourceCountLimit = Math.min(...inputs.map(input => input.limits.resources));
|
|
240
|
+
const totalLimit = Math.min(...inputs.map(input => input.limits.totalBytes));
|
|
241
|
+
const resourceLimit = Math.min(...inputs.map(input => input.limits.resourceBytes));
|
|
242
|
+
let syntaxNodeCount = 0;
|
|
243
|
+
for (const input of inputs) {
|
|
244
|
+
checkAbort(signal);
|
|
245
|
+
const index = syntaxIndex(input, signal);
|
|
246
|
+
syntaxNodeCount += index.size;
|
|
247
|
+
if (syntaxNodeCount > MAX_SYNTAX_INDEX_NODES)
|
|
248
|
+
fail("resource-limit", "Combined source correction XML span index exceeds the node limit");
|
|
249
|
+
let graph;
|
|
250
|
+
try {
|
|
251
|
+
graph = buildCanonicalGraph(input);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error instanceof GraphError)
|
|
255
|
+
fail(error.category === "resource-limit" ? "resource-limit" : "unsupported-target", "Source correction input could not be represented in the canonical graph");
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
nodeCount += graph.nodes.length;
|
|
259
|
+
if (nodeCount > DEFAULT_GRAPH_NODES)
|
|
260
|
+
fail("resource-limit", "Combined source correction graph exceeds the node limit");
|
|
261
|
+
for (const resource of input.resources) {
|
|
262
|
+
checkAbort(signal);
|
|
263
|
+
const bytes = copyOriginalSchemaResource(input, resource.uri, () => { });
|
|
264
|
+
checkAbort(signal);
|
|
265
|
+
if (bytes.byteLength !== resource.byteLength || sha256(bytes) !== resource.digest)
|
|
266
|
+
fail("stale-source", "Loaded source resource bytes do not match their pinned digest");
|
|
267
|
+
const prior = sources.get(resource.uri);
|
|
268
|
+
if (prior && (prior.digest !== resource.digest || !Buffer.from(prior.bytes).equals(Buffer.from(bytes))))
|
|
269
|
+
fail("stale-source", "Inputs disagree about bytes for a shared schema resource");
|
|
270
|
+
if (!prior) {
|
|
271
|
+
if (sources.size >= resourceCountLimit)
|
|
272
|
+
fail("resource-limit", "Combined source correction resource count exceeds the loading limit");
|
|
273
|
+
byteCount += bytes.byteLength;
|
|
274
|
+
if (byteCount > totalLimit || bytes.byteLength > resourceLimit)
|
|
275
|
+
fail("resource-limit", "Combined source correction resource bytes exceed the loading limit");
|
|
276
|
+
sources.set(resource.uri, { uri: resource.uri, digest: resource.digest, bytes, aliases: new Set() });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
for (const [alias, uri] of input.resourceAliases) {
|
|
280
|
+
checkAbort(signal);
|
|
281
|
+
const canonical = sources.get(uri);
|
|
282
|
+
if (!canonical)
|
|
283
|
+
fail("stale-source", "Schema alias points to an absent canonical resource");
|
|
284
|
+
const existing = aliases.get(alias);
|
|
285
|
+
if (existing && existing !== uri)
|
|
286
|
+
fail("ambiguous-target", "Inputs assign a schema alias to different resources");
|
|
287
|
+
aliases.set(alias, uri);
|
|
288
|
+
canonical.aliases.add(alias);
|
|
289
|
+
}
|
|
290
|
+
let graphScanned = 0;
|
|
291
|
+
for (const node of graph.nodes) {
|
|
292
|
+
if ((graphScanned++ & 127) === 0)
|
|
293
|
+
checkAbort(signal);
|
|
294
|
+
const source = node.context.source, syntax = index.get(sourceKey(source.uri, source.digest, source.path));
|
|
295
|
+
if (!syntax)
|
|
296
|
+
fail("stale-source", "Canonical graph target has no matching loaded XML span");
|
|
297
|
+
const key = selectorKey({ id: node.id, uri: source.uri, digest: source.digest });
|
|
298
|
+
const location = sourceKey(source.uri, source.digest, source.path);
|
|
299
|
+
const namespaces = contextNamespaces.get(location) ?? new Set();
|
|
300
|
+
namespaces.add(node.context.effectiveNamespace);
|
|
301
|
+
contextNamespaces.set(location, namespaces);
|
|
302
|
+
contextChameleon.set(location, Boolean(contextChameleon.get(location) || node.context.chameleon));
|
|
303
|
+
for (const origin of graph.origins[node.id] ?? []) {
|
|
304
|
+
const originSource = origin.context.source;
|
|
305
|
+
if (sourceKey(originSource.uri, originSource.digest, originSource.path) === location) {
|
|
306
|
+
namespaces.add(origin.context.effectiveNamespace);
|
|
307
|
+
contextChameleon.set(location, Boolean(contextChameleon.get(location) || origin.context.chameleon));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const old = targets.get(key);
|
|
311
|
+
if (old) {
|
|
312
|
+
if (old.node.context.effectiveNamespace !== node.context.effectiveNamespace || old.node.context.source.path !== source.path) {
|
|
313
|
+
fail("ambiguous-target", "One canonical identity has conflicting schema contexts");
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
targets.set(key, { node, syntax, ambiguousContext: false });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
for (const target of targets.values()) {
|
|
321
|
+
const source = target.node.context.source;
|
|
322
|
+
target.ambiguousContext = (contextNamespaces.get(sourceKey(source.uri, source.digest, source.path))?.size ?? 0) > 1;
|
|
323
|
+
}
|
|
324
|
+
const inventoryResources = Object.freeze([...sources.values()].map(({ uri, digest, bytes }) => Object.freeze({ uri, digest, byteLength: bytes.byteLength })).sort((a, b) => compareText(a.uri, b.uri)));
|
|
325
|
+
const nodes = [];
|
|
326
|
+
const parents = new Map();
|
|
327
|
+
for (const { node } of targets.values()) {
|
|
328
|
+
if (node.identity.kind === "scoped")
|
|
329
|
+
parents.set(node.id, node.identity.owner);
|
|
330
|
+
if (node.kind === "complexType" && node.content)
|
|
331
|
+
parents.set(node.content, node.id);
|
|
332
|
+
if (node.kind === "complexType")
|
|
333
|
+
for (const id of node.attributes)
|
|
334
|
+
parents.set(id, node.id);
|
|
335
|
+
if (node.kind === "particle" && (node.term.kind === "sequence" || node.term.kind === "choice" || node.term.kind === "all")) {
|
|
336
|
+
for (const id of node.term.children)
|
|
337
|
+
parents.set(id, node.id);
|
|
338
|
+
}
|
|
339
|
+
if (node.kind === "particle" && node.term.kind === "element" && node.term.declaration.kind === "local")
|
|
340
|
+
parents.set(node.term.declaration.target, node.id);
|
|
341
|
+
}
|
|
342
|
+
for (const { node, syntax, ambiguousContext } of targets.values()) {
|
|
343
|
+
checkAbort(signal);
|
|
344
|
+
const source = node.context.source;
|
|
345
|
+
const rawAttributes = Object.freeze(syntax.attributes.map(attribute => Object.freeze({ name: attribute.name.local, namespace: attribute.name.namespace, lexicalName: attribute.lexicalName, value: attribute.value })));
|
|
346
|
+
const wildcard = node.kind === "particle" && node.term.kind === "any" ? Object.freeze({ namespace: node.term.wildcard.namespace.value, processContents: node.term.wildcard.processContents })
|
|
347
|
+
: node.kind === "attributeWildcard" ? Object.freeze({ namespace: node.wildcard.namespace.value, processContents: node.wildcard.processContents }) : undefined;
|
|
348
|
+
const children = node.kind === "particle" && (node.term.kind === "sequence" || node.term.kind === "choice" || node.term.kind === "all") ? node.term.children : undefined;
|
|
349
|
+
const entry = Object.freeze({ id: node.id, kind: node.kind, role: nodeRole(node.identity), namespace: node.context.effectiveNamespace,
|
|
350
|
+
...(nodeName(node) ? { name: nodeName(node) } : {}), syntaxName: syntax.name, uri: source.uri, digest: source.digest, path: source.path,
|
|
351
|
+
start: source.start.offset, end: source.end.offset, attributes: rawAttributes, ...(node.kind === "particle" ? { occurs: node.occurs } : {}),
|
|
352
|
+
...(wildcard ? { wildcard } : {}), ...(children ? { children } : {}), ...(parents.has(node.id) ? { parent: parents.get(node.id) } : {}),
|
|
353
|
+
chameleon: Boolean(node.context.chameleon || contextChameleon.get(sourceKey(source.uri, source.digest, source.path))), ambiguousContext });
|
|
354
|
+
nodes.push(entry);
|
|
355
|
+
}
|
|
356
|
+
nodes.sort((a, b) => compareText(a.uri, b.uri) || compareText(a.path, b.path) || compareText(a.id, b.id));
|
|
357
|
+
return { inventory: Object.freeze({ version: 1, resources: inventoryResources, nodes: Object.freeze(nodes) }), targets, sources, aliases };
|
|
358
|
+
}
|
|
359
|
+
export function inspectSourceCorrectionInventory(inputs, signal) {
|
|
360
|
+
checkAbort(signal);
|
|
361
|
+
return targetMap(inputs, signal).inventory;
|
|
362
|
+
}
|
|
363
|
+
function targetFor(selectorValue, targets) {
|
|
364
|
+
const target = targets.get(selectorKey(selectorValue));
|
|
365
|
+
if (!target)
|
|
366
|
+
fail("stale-source", "Correction target is absent from the pinned canonical inventory");
|
|
367
|
+
if (target.ambiguousContext)
|
|
368
|
+
fail("ambiguous-target", "Correction target source is interpreted under multiple target namespaces");
|
|
369
|
+
return target;
|
|
370
|
+
}
|
|
371
|
+
function xmlEscapeAttribute(value, quote) {
|
|
372
|
+
let escaped = value.replace(/&/g, "&").replace(/</g, "<");
|
|
373
|
+
return quote === "\"" ? escaped.replace(/\"/g, """) : escaped.replace(/'/g, "'");
|
|
374
|
+
}
|
|
375
|
+
function startTag(text, start, signal, budget = { remaining: MAX_LEXICAL_SCAN }) {
|
|
376
|
+
if (text[start] !== "<" || text[start + 1] === "/" || text[start + 1] === "!" || text[start + 1] === "?")
|
|
377
|
+
fail("stale-source", "Canonical source span does not point to an opening tag");
|
|
378
|
+
let i = start + 1;
|
|
379
|
+
const nameStart = i;
|
|
380
|
+
while (i < text.length && !/[\s/>]/.test(text[i])) {
|
|
381
|
+
i++;
|
|
382
|
+
if ((i & 1023) === 0)
|
|
383
|
+
scanCheckpoint(budget, signal);
|
|
384
|
+
}
|
|
385
|
+
const name = text.slice(nameStart, i), attributes = new Map();
|
|
386
|
+
while (i < text.length) {
|
|
387
|
+
while (/\s/.test(text[i] ?? "")) {
|
|
388
|
+
i++;
|
|
389
|
+
if ((i & 1023) === 0)
|
|
390
|
+
scanCheckpoint(budget, signal);
|
|
391
|
+
}
|
|
392
|
+
if (text[i] === ">")
|
|
393
|
+
return { name, close: i, selfClosing: false, attributes };
|
|
394
|
+
if (text[i] === "/" && text[i + 1] === ">")
|
|
395
|
+
return { name, close: i, selfClosing: true, attributes };
|
|
396
|
+
const attrStart = i;
|
|
397
|
+
while (i < text.length && !/[\s=/>]/.test(text[i])) {
|
|
398
|
+
i++;
|
|
399
|
+
if ((i & 1023) === 0)
|
|
400
|
+
scanCheckpoint(budget, signal);
|
|
401
|
+
}
|
|
402
|
+
if (i === attrStart)
|
|
403
|
+
fail("stale-source", "Malformed XML attribute syntax in correction target");
|
|
404
|
+
const attrName = text.slice(attrStart, i);
|
|
405
|
+
while (/\s/.test(text[i] ?? "")) {
|
|
406
|
+
i++;
|
|
407
|
+
if ((i & 1023) === 0)
|
|
408
|
+
scanCheckpoint(budget, signal);
|
|
409
|
+
}
|
|
410
|
+
if (text[i++] !== "=")
|
|
411
|
+
fail("stale-source", "Malformed XML attribute syntax in correction target");
|
|
412
|
+
while (/\s/.test(text[i] ?? "")) {
|
|
413
|
+
i++;
|
|
414
|
+
if ((i & 1023) === 0)
|
|
415
|
+
scanCheckpoint(budget, signal);
|
|
416
|
+
}
|
|
417
|
+
const quote = text[i++];
|
|
418
|
+
if (quote !== "'" && quote !== "\"")
|
|
419
|
+
fail("stale-source", "XML attributes must be quoted");
|
|
420
|
+
const valueStart = i;
|
|
421
|
+
while (i < text.length && text[i] !== quote) {
|
|
422
|
+
i++;
|
|
423
|
+
if ((i & 1023) === 0)
|
|
424
|
+
scanCheckpoint(budget, signal);
|
|
425
|
+
}
|
|
426
|
+
if (i >= text.length)
|
|
427
|
+
fail("stale-source", "Unterminated XML attribute in correction target");
|
|
428
|
+
if (attributes.has(attrName))
|
|
429
|
+
fail("stale-source", "Duplicate lexical attribute in correction target");
|
|
430
|
+
attributes.set(attrName, { valueStart, valueEnd: i, quote });
|
|
431
|
+
i++;
|
|
432
|
+
}
|
|
433
|
+
return fail("stale-source", "Unterminated XML start tag in correction target");
|
|
434
|
+
}
|
|
435
|
+
function updateStartTag(text, target, set, signal, budget) {
|
|
436
|
+
const offset = target.node.context.source.start.offset, tag = startTag(text, offset, signal, budget);
|
|
437
|
+
const existingByLocal = new Map();
|
|
438
|
+
for (const [key, attr] of tag.attributes)
|
|
439
|
+
if (!key.includes(":"))
|
|
440
|
+
existingByLocal.set(key, { key, ...attr });
|
|
441
|
+
const patches = [], additions = [];
|
|
442
|
+
for (const [local, value] of Object.entries(set)) {
|
|
443
|
+
const existing = existingByLocal.get(local);
|
|
444
|
+
if (existing) {
|
|
445
|
+
if (text.slice(existing.valueStart, existing.valueEnd) !== value)
|
|
446
|
+
patches.push({ start: existing.valueStart, end: existing.valueEnd, text: xmlEscapeAttribute(value, existing.quote), label: `${target.node.id}:${local}` });
|
|
447
|
+
}
|
|
448
|
+
else
|
|
449
|
+
additions.push(`${local}="${xmlEscapeAttribute(value, "\"")}"`);
|
|
450
|
+
}
|
|
451
|
+
if (additions.length) {
|
|
452
|
+
// startTag() records absolute offsets in the full decoded document.
|
|
453
|
+
const position = tag.close;
|
|
454
|
+
patches.push({ start: position, end: position, text: ` ${additions.join(" ")}`, label: `${target.node.id}:attributes` });
|
|
455
|
+
}
|
|
456
|
+
return patches;
|
|
457
|
+
}
|
|
458
|
+
function findTerminator(text, token, start, limit, signal, budget) {
|
|
459
|
+
for (let i = start; i + token.length <= limit; i++) {
|
|
460
|
+
if ((i & 1023) === 0)
|
|
461
|
+
scanCheckpoint(budget, signal);
|
|
462
|
+
if (text[i] === token[0] && text.startsWith(token, i))
|
|
463
|
+
return i;
|
|
464
|
+
}
|
|
465
|
+
return -1;
|
|
466
|
+
}
|
|
467
|
+
function closingTagStart(text, target, signal, budget = { remaining: MAX_LEXICAL_SCAN }) {
|
|
468
|
+
const syntax = target.syntax, source = target.node.context.source, opening = startTag(text, source.start.offset, signal, budget);
|
|
469
|
+
if (opening.selfClosing)
|
|
470
|
+
fail("unsupported-target", "Cannot append a child to an empty sequence element");
|
|
471
|
+
let depth = 1, i = opening.close + 1;
|
|
472
|
+
while (i < source.end.offset) {
|
|
473
|
+
if ((i & 1023) === 0)
|
|
474
|
+
scanCheckpoint(budget, signal);
|
|
475
|
+
if (text[i] !== "<") {
|
|
476
|
+
i++;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (text.startsWith("<!--", i)) {
|
|
480
|
+
const end = findTerminator(text, "-->", i + 4, source.end.offset, signal, budget);
|
|
481
|
+
if (end < 0)
|
|
482
|
+
break;
|
|
483
|
+
i = end + 3;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (text.startsWith("<![CDATA[", i)) {
|
|
487
|
+
const end = findTerminator(text, "]]>", i + 9, source.end.offset, signal, budget);
|
|
488
|
+
if (end < 0)
|
|
489
|
+
break;
|
|
490
|
+
i = end + 3;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (text.startsWith("<?", i)) {
|
|
494
|
+
const end = findTerminator(text, "?>", i + 2, source.end.offset, signal, budget);
|
|
495
|
+
if (end < 0)
|
|
496
|
+
break;
|
|
497
|
+
i = end + 2;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
let j = i + 1, quote = "";
|
|
501
|
+
for (; j < source.end.offset; j++) {
|
|
502
|
+
const char = text[j];
|
|
503
|
+
if (quote) {
|
|
504
|
+
if (char === quote)
|
|
505
|
+
quote = "";
|
|
506
|
+
}
|
|
507
|
+
else if (char === "'" || char === "\"")
|
|
508
|
+
quote = char;
|
|
509
|
+
else if (char === ">")
|
|
510
|
+
break;
|
|
511
|
+
if ((j & 1023) === 0)
|
|
512
|
+
scanCheckpoint(budget, signal);
|
|
513
|
+
}
|
|
514
|
+
if (j >= source.end.offset)
|
|
515
|
+
break;
|
|
516
|
+
const token = text.slice(i, j + 1), closing = /^<\//.test(token), selfClosing = /\/\s*>$/.test(token);
|
|
517
|
+
if (closing) {
|
|
518
|
+
depth--;
|
|
519
|
+
if (depth === 0)
|
|
520
|
+
return i;
|
|
521
|
+
}
|
|
522
|
+
else if (!selfClosing && !/^<!/.test(token))
|
|
523
|
+
depth++;
|
|
524
|
+
i = j + 1;
|
|
525
|
+
}
|
|
526
|
+
return fail("stale-source", `Could not locate closing tag for ${syntax.name.local}`);
|
|
527
|
+
}
|
|
528
|
+
function indentAt(text, offset, signal) {
|
|
529
|
+
let start = offset, scanned = 0;
|
|
530
|
+
while (start > 0 && text[start - 1] !== "\n" && scanned < 4096) {
|
|
531
|
+
const char = text[start - 1];
|
|
532
|
+
if (char !== " " && char !== "\t" && char !== "\r")
|
|
533
|
+
return undefined;
|
|
534
|
+
start--;
|
|
535
|
+
scanned++;
|
|
536
|
+
if ((scanned & 1023) === 0)
|
|
537
|
+
checkAbort(signal);
|
|
538
|
+
}
|
|
539
|
+
if (start > 0 && text[start - 1] !== "\n")
|
|
540
|
+
return undefined;
|
|
541
|
+
return text.slice(start, offset);
|
|
542
|
+
}
|
|
543
|
+
function qname(namespace, local, context, fresh) {
|
|
544
|
+
if (namespace === "" && (context[""] ?? "") !== "") {
|
|
545
|
+
fail("unsupported-target", "Cannot emit a no-namespace QName beneath a nonempty default namespace");
|
|
546
|
+
}
|
|
547
|
+
const prefixes = Object.entries(context).filter(([prefix, uri]) => prefix && uri === namespace).map(([prefix]) => prefix).sort(compareText);
|
|
548
|
+
if (prefixes.length)
|
|
549
|
+
return { lexical: `${prefixes[0]}:${local}` };
|
|
550
|
+
if ((context[""] ?? "") === namespace)
|
|
551
|
+
return { lexical: local };
|
|
552
|
+
let prefix = "correction", suffix = 0;
|
|
553
|
+
while (hasOwn(context, prefix) || fresh.has(prefix))
|
|
554
|
+
prefix = `correction${++suffix}`;
|
|
555
|
+
fresh.add(prefix);
|
|
556
|
+
return { lexical: `${prefix}:${local}`, declaration: `xmlns:${prefix}="${xmlEscapeAttribute(namespace, "\"")}"` };
|
|
557
|
+
}
|
|
558
|
+
function siblingTagName(target, local) {
|
|
559
|
+
const colon = target.syntax.lexicalName.indexOf(":");
|
|
560
|
+
return colon < 0 ? local : `${target.syntax.lexicalName.slice(0, colon)}:${local}`;
|
|
561
|
+
}
|
|
562
|
+
function checkSelectorResource(selectorValue, sources) {
|
|
563
|
+
const source = sources.get(selectorValue.uri);
|
|
564
|
+
if (!source || source.digest !== selectorValue.digest)
|
|
565
|
+
fail("stale-source", "Correction selector resource URI or digest is stale");
|
|
566
|
+
return source;
|
|
567
|
+
}
|
|
568
|
+
function validateRecipePins(recipe, sources) {
|
|
569
|
+
if (recipe.resources.length !== sources.size)
|
|
570
|
+
fail("stale-source", "Recipe must pin every resource in the loaded input union exactly once");
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
for (const pin of recipe.resources) {
|
|
573
|
+
if (seen.has(pin.uri))
|
|
574
|
+
fail("invalid-recipe", "Recipe contains a duplicate resource pin");
|
|
575
|
+
seen.add(pin.uri);
|
|
576
|
+
const source = sources.get(pin.uri);
|
|
577
|
+
if (!source || source.digest !== pin.digest)
|
|
578
|
+
fail("stale-source", "Recipe resource URI/digest does not match the loaded source union");
|
|
579
|
+
}
|
|
580
|
+
if ([...sources.keys()].some(uri => !seen.has(uri)))
|
|
581
|
+
fail("stale-source", "Recipe omits a resource from the loaded source union");
|
|
582
|
+
}
|
|
583
|
+
function validateTargetType(qnameValue, targets) {
|
|
584
|
+
if (qnameValue.namespace === XSD_NAMESPACE && isBuiltinType(qnameValue.local))
|
|
585
|
+
return;
|
|
586
|
+
const exists = [...targets.values()].some(target => target.node.kind === "simpleType" || target.node.kind === "complexType"
|
|
587
|
+
? target.node.identity.kind === "global" && target.node.identity.name.namespace === qnameValue.namespace && target.node.identity.name.local === qnameValue.local : false);
|
|
588
|
+
if (!exists)
|
|
589
|
+
fail("unsupported-target", "Inserted element type must refer to an existing named type or XML Schema builtin");
|
|
590
|
+
}
|
|
591
|
+
function decodedResource(source, cache) {
|
|
592
|
+
const prior = cache.get(source.uri);
|
|
593
|
+
if (prior !== undefined)
|
|
594
|
+
return prior;
|
|
595
|
+
const decoded = decodeMetadataXml(source.bytes);
|
|
596
|
+
cache.set(source.uri, decoded);
|
|
597
|
+
return decoded;
|
|
598
|
+
}
|
|
599
|
+
function validateCorrection(correction, targets, sources, decoded, claimed, insertSlots, signal, scanBudget = { remaining: MAX_LEXICAL_SCAN }) {
|
|
600
|
+
const target = targetFor(correction.target, targets), source = checkSelectorResource(correction.target, sources), node = target.node;
|
|
601
|
+
checkAbort(signal);
|
|
602
|
+
if (correction.kind === "set-occurs") {
|
|
603
|
+
if (node.kind !== "particle")
|
|
604
|
+
fail("unsupported-target", "set-occurs target must be an XSD particle");
|
|
605
|
+
const set = { minOccurs: correction.min, maxOccurs: correction.max };
|
|
606
|
+
for (const key of ["minOccurs", "maxOccurs"]) {
|
|
607
|
+
const claim = `${selectorKey(correction.target)}:${key}`;
|
|
608
|
+
if (claimed.has(claim))
|
|
609
|
+
fail("invalid-recipe", `Conflicting correction for ${key}`);
|
|
610
|
+
claimed.add(claim);
|
|
611
|
+
}
|
|
612
|
+
return { patches: new Map([[source.uri, updateStartTag(decodedResource(source, decoded), target, set, signal, scanBudget)]]), provenance: { kind: correction.kind, target: correction.target } };
|
|
613
|
+
}
|
|
614
|
+
if (correction.kind === "set-wildcard-namespaces") {
|
|
615
|
+
const wildcard = node.kind === "particle" && node.term.kind === "any" ? node.term.wildcard : undefined;
|
|
616
|
+
if (!wildcard)
|
|
617
|
+
fail("unsupported-target", "Wildcard namespace target must be xs:any");
|
|
618
|
+
// @why Namespace identity uses XSD's XML whitespace rules; JavaScript trim would widen some IRI constraints.
|
|
619
|
+
const charge = (amount = 1) => {
|
|
620
|
+
checkAbort(signal);
|
|
621
|
+
scanBudget.remaining -= amount;
|
|
622
|
+
if (scanBudget.remaining < 0)
|
|
623
|
+
fail('resource-limit', 'Source correction namespace comparison exceeds its work limit');
|
|
624
|
+
};
|
|
625
|
+
const originalNamespaces = wildcardNamespaces(wildcard, charge);
|
|
626
|
+
if (correction.namespaces.some(namespace => !namespaceAllows(originalNamespaces, namespace, charge))) {
|
|
627
|
+
fail("unsupported-target", "Requested wildcard namespaces would widen the accepted namespace set");
|
|
628
|
+
}
|
|
629
|
+
const encoded = correction.namespaces.map(namespace => namespace === "" ? "##local" : namespace).sort(compareText).join(" ");
|
|
630
|
+
const claim = `${selectorKey(correction.target)}:namespace`;
|
|
631
|
+
if (claimed.has(claim))
|
|
632
|
+
fail("invalid-recipe", "Conflicting wildcard namespace corrections");
|
|
633
|
+
claimed.add(claim);
|
|
634
|
+
return { patches: new Map([[source.uri, updateStartTag(decodedResource(source, decoded), target, { namespace: encoded }, signal, scanBudget)]]), provenance: { kind: correction.kind, target: correction.target } };
|
|
635
|
+
}
|
|
636
|
+
if (node.kind !== "particle" || node.term.kind !== "sequence")
|
|
637
|
+
fail("unsupported-target", "insert-element target must be an xs:sequence particle");
|
|
638
|
+
const xml = decodedResource(source, decoded), freshPrefixes = new Set(), tagName = siblingTagName(target, "element");
|
|
639
|
+
let markup;
|
|
640
|
+
if (correction.element.kind === "declaration") {
|
|
641
|
+
validateTargetType(correction.element.type, targets);
|
|
642
|
+
const type = qname(correction.element.type.namespace, correction.element.type.local, node.context.namespaces, freshPrefixes);
|
|
643
|
+
const declaration = type.declaration ? ` ${type.declaration}` : "";
|
|
644
|
+
markup = `<${tagName}${declaration} name="${xmlEscapeAttribute(correction.element.name, "\"")}" type="${xmlEscapeAttribute(type.lexical, "\"")}" minOccurs="${correction.element.min}" maxOccurs="${xmlEscapeAttribute(correction.element.max, "\"")}"/>`;
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
const referenced = targetFor(correction.element.target, targets).node;
|
|
648
|
+
if (referenced.kind !== "element" || referenced.identity.kind !== "global")
|
|
649
|
+
fail("unsupported-target", "Inserted ref must target an existing global element");
|
|
650
|
+
const ref = qname(referenced.name.namespace, referenced.name.local, node.context.namespaces, freshPrefixes);
|
|
651
|
+
const declaration = ref.declaration ? ` ${ref.declaration}` : "";
|
|
652
|
+
markup = `<${tagName}${declaration} ref="${xmlEscapeAttribute(ref.lexical, "\"")}" minOccurs="${correction.element.min}" maxOccurs="${correction.element.max}"/>`;
|
|
653
|
+
}
|
|
654
|
+
let position, text, replacementEnd;
|
|
655
|
+
if (correction.position === "append") {
|
|
656
|
+
const opening = startTag(xml, target.syntax.source.start.offset, signal, scanBudget);
|
|
657
|
+
if (opening.selfClosing) {
|
|
658
|
+
// @why Expanding only the closing token preserves the empty sequence's attributes and namespace context.
|
|
659
|
+
position = opening.close;
|
|
660
|
+
replacementEnd = opening.close + 2;
|
|
661
|
+
text = `>${markup}</${opening.name}>`;
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
position = closingTagStart(xml, target, signal, scanBudget);
|
|
665
|
+
const indent = indentAt(xml, position, signal);
|
|
666
|
+
text = indent === undefined ? markup : `${markup}\n${indent}`;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
const anchor = targetFor(correction.position.target, targets);
|
|
671
|
+
if (anchor.node.kind !== "particle" || anchor.node.term.kind !== "element" || !node.term.children.includes(anchor.node.id)) {
|
|
672
|
+
fail("unsupported-target", "Insertion anchor must be a direct xs:element child of the selected sequence");
|
|
673
|
+
}
|
|
674
|
+
if (anchor.node.context.source.uri !== source.uri || anchor.node.context.source.digest !== source.digest)
|
|
675
|
+
fail("unsupported-target", "Insertion anchor and sequence must share a source resource");
|
|
676
|
+
if (anchor.ambiguousContext)
|
|
677
|
+
fail("ambiguous-target", "Insertion anchor source is interpreted under multiple target namespaces");
|
|
678
|
+
if (correction.position.kind === "before") {
|
|
679
|
+
position = anchor.node.context.source.start.offset;
|
|
680
|
+
const indent = indentAt(xml, position, signal);
|
|
681
|
+
text = indent === undefined ? `${markup} ` : `${markup}\n${indent}`;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
position = anchor.node.context.source.end.offset;
|
|
685
|
+
const indent = indentAt(xml, position, signal);
|
|
686
|
+
text = indent === undefined ? ` ${markup}` : `\n${indent}${markup}`;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const slot = `${selectorKey(correction.target)}:${position}`;
|
|
690
|
+
if (insertSlots.has(slot))
|
|
691
|
+
fail("invalid-recipe", "Multiple insertions target the same original source position");
|
|
692
|
+
insertSlots.add(slot);
|
|
693
|
+
return { patches: new Map([[source.uri, [{ start: position, end: replacementEnd ?? position, text, label: `${node.id}:insert` }]]]), provenance: { kind: correction.kind, target: correction.target } };
|
|
694
|
+
}
|
|
695
|
+
function applyPatches(original, patches, signal) {
|
|
696
|
+
const combined = new Map();
|
|
697
|
+
for (const patch of patches) {
|
|
698
|
+
const key = `${patch.start}:${patch.end}`;
|
|
699
|
+
const prior = combined.get(key);
|
|
700
|
+
if (!prior) {
|
|
701
|
+
combined.set(key, patch);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (patch.start !== patch.end || !prior.label.endsWith(":attributes") || !patch.label.endsWith(":attributes")) {
|
|
705
|
+
fail("invalid-recipe", "Correction source edits conflict at the same location");
|
|
706
|
+
}
|
|
707
|
+
combined.set(key, { ...prior, text: prior.text + patch.text.replace(/^\s+/, " ") });
|
|
708
|
+
}
|
|
709
|
+
const sorted = [...combined.values()].sort((a, b) => a.start - b.start || a.end - b.end || compareText(a.label, b.label));
|
|
710
|
+
let previousEnd = 0;
|
|
711
|
+
const chunks = [];
|
|
712
|
+
for (const patch of sorted) {
|
|
713
|
+
checkAbort(signal);
|
|
714
|
+
if (patch.start < previousEnd || patch.end < patch.start || patch.end > original.length)
|
|
715
|
+
fail("invalid-recipe", "Correction source spans overlap or exceed the original resource");
|
|
716
|
+
chunks.push(original.slice(previousEnd, patch.start), patch.text);
|
|
717
|
+
previousEnd = patch.end;
|
|
718
|
+
}
|
|
719
|
+
chunks.push(original.slice(previousEnd));
|
|
720
|
+
return chunks.join("");
|
|
721
|
+
}
|
|
722
|
+
function utf8Output(text, original) {
|
|
723
|
+
let output = text;
|
|
724
|
+
const declarationEnd = output.startsWith("\uFEFF") ? 1 : 0;
|
|
725
|
+
const declaration = /^<\?xml\b[^?]*\?>/i.exec(output.slice(declarationEnd));
|
|
726
|
+
if (declaration) {
|
|
727
|
+
const updated = declaration[0].replace(/(\bencoding\s*=\s*)(['"])[^'"]*\2/i, "$1$2UTF-8$2");
|
|
728
|
+
output = output.slice(0, declarationEnd) + updated + output.slice(declarationEnd + declaration[0].length);
|
|
729
|
+
}
|
|
730
|
+
const bytes = new TextEncoder().encode(output), hasBom = original[0] === 0xef && original[1] === 0xbb && original[2] === 0xbf || original[0] === 0xfe && original[1] === 0xff || original[0] === 0xff && original[1] === 0xfe;
|
|
731
|
+
if (!hasBom)
|
|
732
|
+
return bytes;
|
|
733
|
+
return Uint8Array.from([0xef, 0xbb, 0xbf, ...bytes]);
|
|
734
|
+
}
|
|
735
|
+
function canonicalRecipeDigest(recipe) {
|
|
736
|
+
const normalized = { version: recipe.version, resources: [...recipe.resources].sort((a, b) => compareText(a.uri, b.uri)), corrections: recipe.corrections };
|
|
737
|
+
return sha256(JSON.stringify(normalized));
|
|
738
|
+
}
|
|
739
|
+
/** Validate and apply a versioned correction recipe to an authenticated source union. */
|
|
740
|
+
export function prepareCorrectedSourceInputs(inputs, recipeValue, signal) {
|
|
741
|
+
checkAbort(signal);
|
|
742
|
+
const recipe = parseRecipe(recipeValue, signal), { inventory, targets, sources, aliases } = targetMap(inputs, signal);
|
|
743
|
+
validateRecipePins(recipe, sources);
|
|
744
|
+
const patches = new Map(), decoded = new Map(), claimed = new Set(), insertSlots = new Set();
|
|
745
|
+
const scanBudget = { remaining: MAX_LEXICAL_SCAN };
|
|
746
|
+
const provenanceCorrections = [];
|
|
747
|
+
for (const correction of recipe.corrections) {
|
|
748
|
+
checkAbort(signal);
|
|
749
|
+
const result = validateCorrection(correction, targets, sources, decoded, claimed, insertSlots, signal, scanBudget);
|
|
750
|
+
for (const [uri, values] of result.patches) {
|
|
751
|
+
const list = patches.get(uri) ?? [];
|
|
752
|
+
list.push(...values);
|
|
753
|
+
patches.set(uri, list);
|
|
754
|
+
}
|
|
755
|
+
provenanceCorrections.push(result.provenance);
|
|
756
|
+
}
|
|
757
|
+
const resources = new Map(), provenanceResources = [];
|
|
758
|
+
let effectiveBytesTotal = 0;
|
|
759
|
+
let outputGrowth = 0;
|
|
760
|
+
const outputLimit = Math.min(...inputs.map(input => input.limits.totalBytes)), singleLimit = Math.min(...inputs.map(input => input.limits.resourceBytes));
|
|
761
|
+
for (const source of [...sources.values()].sort((a, b) => compareText(a.uri, b.uri))) {
|
|
762
|
+
checkAbort(signal);
|
|
763
|
+
const edits = patches.get(source.uri) ?? [];
|
|
764
|
+
const originalText = edits.length ? decodedResource(source, decoded) : "";
|
|
765
|
+
const updated = edits.length ? applyPatches(originalText, edits, signal) : "";
|
|
766
|
+
const bytes = edits.length && updated !== originalText ? utf8Output(updated, source.bytes) : source.bytes;
|
|
767
|
+
if (bytes.byteLength > singleLimit)
|
|
768
|
+
fail("resource-limit", "Corrected XML resource exceeds its byte limit");
|
|
769
|
+
effectiveBytesTotal += bytes.byteLength;
|
|
770
|
+
if (effectiveBytesTotal > outputLimit)
|
|
771
|
+
fail("resource-limit", "Corrected source union exceeds its total byte limit");
|
|
772
|
+
outputGrowth += Math.max(0, bytes.byteLength - source.bytes.byteLength);
|
|
773
|
+
if (outputGrowth > MAX_OUTPUT_GROWTH)
|
|
774
|
+
fail("resource-limit", "Correction output growth exceeds the limit");
|
|
775
|
+
const effectiveDigest = sha256(bytes), changed = effectiveDigest !== source.digest;
|
|
776
|
+
const resource = Object.freeze({ bytes: new Uint8Array(bytes), digest: effectiveDigest });
|
|
777
|
+
resources.set(source.uri, resource);
|
|
778
|
+
for (const alias of source.aliases)
|
|
779
|
+
if (alias !== source.uri)
|
|
780
|
+
resources.set(alias, Object.freeze({ bytes: resource.bytes, digest: resource.digest, canonicalUri: source.uri }));
|
|
781
|
+
provenanceResources.push(Object.freeze({ uri: source.uri, originalDigest: source.digest, effectiveDigest, changed }));
|
|
782
|
+
}
|
|
783
|
+
for (const [alias, canonical] of aliases.entries()) {
|
|
784
|
+
const direct = sources.get(alias), actual = sources.get(canonical);
|
|
785
|
+
if (!actual)
|
|
786
|
+
fail("stale-source", "Offline alias lost its canonical source resource");
|
|
787
|
+
if (direct && alias !== canonical && (direct.digest !== actual.digest || !Buffer.from(direct.bytes).equals(Buffer.from(actual.bytes)))) {
|
|
788
|
+
fail("ambiguous-target", "A schema alias conflicts with another canonical resource URI");
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const provenance = Object.freeze({ version: 1, canonicalRecipeDigest: canonicalRecipeDigest(recipe),
|
|
792
|
+
resources: Object.freeze(provenanceResources), corrections: Object.freeze(provenanceCorrections.map(item => Object.freeze(item))) });
|
|
793
|
+
return Object.freeze({ inventory, resources, provenance });
|
|
794
|
+
}
|