@meantjs/profile 0.0.0-bootstrap → 0.1.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/CHANGELOG.md +37 -0
- package/README.md +248 -4
- package/bin/candid-profile.mjs +6 -0
- package/data/packs/icrc1.profile.json +275 -0
- package/data/packs/icrc2.profile.json +515 -0
- package/data/profile-v0.schema.json +966 -0
- package/data/profile-v0.validate.mjs +9 -0
- package/package.json +60 -4
- package/src/address.d.mts +30 -0
- package/src/address.mjs +62 -0
- package/src/analyze.mjs +298 -0
- package/src/browser.d.mts +208 -0
- package/src/browser.mjs +50 -0
- package/src/checks.mjs +81 -0
- package/src/cli.mjs +114 -0
- package/src/compile.mjs +304 -0
- package/src/doc-annotations.mjs +394 -0
- package/src/idl.mjs +10 -0
- package/src/iface.mjs +123 -0
- package/src/index.d.mts +70 -0
- package/src/index.mjs +15 -0
- package/src/pack-match.mjs +19 -0
- package/src/packs.mjs +25 -0
- package/src/resolve.mjs +114 -0
- package/src/resolved.mjs +60 -0
- package/src/schema-core.mjs +45 -0
- package/src/schema.mjs +23 -0
- package/src/served.mjs +59 -0
- package/src/sha256.mjs +59 -0
- package/src/subtype.mjs +271 -0
- package/src/tags.mjs +90 -0
- package/src/util.mjs +16 -0
- package/src/validate.mjs +49 -0
package/src/analyze.mjs
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic engine: index every annotation of every contributing profile by canonical position, merge the layers
|
|
3
|
+
* (spec section 8) and run the per-annotation checks. For a single profile this is exactly the old
|
|
4
|
+
* scripts/validate.mjs `validateFile` core, in the same order, with the same diagnostics.
|
|
5
|
+
*/
|
|
6
|
+
import { parseAddress } from "./address.mjs";
|
|
7
|
+
import { jcs } from "./util.mjs";
|
|
8
|
+
import { TagIndex, LayeredTags, positions } from "./tags.mjs";
|
|
9
|
+
import {
|
|
10
|
+
NUMERIC, METHOD_TAGS, STALE_CODES, VALUE_CONVERSION_TAGS,
|
|
11
|
+
checkApplicability, checkDynamic, parseTemplate, outcomeCoverage,
|
|
12
|
+
} from "./checks.mjs";
|
|
13
|
+
|
|
14
|
+
/** Spec section 8, highest precedence first. */
|
|
15
|
+
export const LAYERS = ["author", "third_party", "pack", "inferred"];
|
|
16
|
+
const rank = (origin) => LAYERS.indexOf(origin);
|
|
17
|
+
|
|
18
|
+
function buildLayers(entries, keep = () => true) {
|
|
19
|
+
const byLayer = new Map();
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
if (!keep(e)) continue;
|
|
22
|
+
const layer = e.item.doc.origin;
|
|
23
|
+
if (!byLayer.has(layer)) byLayer.set(layer, new TagIndex());
|
|
24
|
+
byLayer.get(layer).add(e.res.key, e.ann.tag, e.entry);
|
|
25
|
+
}
|
|
26
|
+
return new LayeredTags(LAYERS.filter((l) => byLayer.has(l)).map((name) => ({ name, index: byLayer.get(name) })));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param iface the interface every address resolves against
|
|
31
|
+
* @param items [{ doc, pid, rebinding, apply }]; `apply` marks a pack that matched a service and is applied to it
|
|
32
|
+
* @param opts.attribute add `profile` to each diagnostic (multi-profile output)
|
|
33
|
+
* @returns { diagnostics, layered, ordered, stale } `ordered` is items by precedence; `stale` the stale items
|
|
34
|
+
*/
|
|
35
|
+
export function analyzeProfiles(iface, items, { attribute = true } = {}) {
|
|
36
|
+
const diagnostics = [];
|
|
37
|
+
const ordered = items.map((item) => ({ ...item, diags: [] })).sort((a, b) => rank(a.doc.origin) - rank(b.doc.origin));
|
|
38
|
+
for (const item of ordered) {
|
|
39
|
+
item.diag = (severity, code, at, message) => {
|
|
40
|
+
const d = { severity, code, at, message };
|
|
41
|
+
if (attribute) d.profile = item.doc.name;
|
|
42
|
+
diagnostics.push(d);
|
|
43
|
+
item.diags.push(d);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const interfaceId = iface.interfaceId;
|
|
47
|
+
|
|
48
|
+
for (const item of ordered) {
|
|
49
|
+
if (item.rebinding) {
|
|
50
|
+
item.diag("warning", "W_REBOUND", "binds.interface_id", `profile is bound to ${item.doc.binds.interface_id} but the given interface is ${interfaceId}; checking whether every address still resolves`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Pass 1: resolve every address and index tags by canonical key, one TagIndex per layer.
|
|
55
|
+
const seenByLayer = new Map();
|
|
56
|
+
const entries = [];
|
|
57
|
+
const meta = new WeakMap(); // tag entry -> { item, at } (kept out of the entry so the resolved form stays unchanged)
|
|
58
|
+
for (const item of ordered) {
|
|
59
|
+
const { doc, diag } = item;
|
|
60
|
+
if (!seenByLayer.has(doc.origin)) seenByLayer.set(doc.origin, new Map());
|
|
61
|
+
const seen = seenByLayer.get(doc.origin);
|
|
62
|
+
for (const original of doc.annotations) {
|
|
63
|
+
let a = original;
|
|
64
|
+
const at = a.at;
|
|
65
|
+
const addr = parseAddress(at);
|
|
66
|
+
if (!addr) { diag("error", "E_ADDR_SYNTAX", at, "address does not parse"); continue; }
|
|
67
|
+
if (doc.kind === "pack" && addr.kind === "type") { diag("error", "E_PACK_TYPE_REF", at, "packs must use method addresses (declaration names are not standardized)"); continue; }
|
|
68
|
+
const res = iface.resolve(addr);
|
|
69
|
+
if (res.error) {
|
|
70
|
+
// A pack applied to a service that is a narrower subtype (fewer result cases, fewer optional fields) cannot
|
|
71
|
+
// address what is not there. That is expected, not an error in the pack.
|
|
72
|
+
if (item.apply) diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag}: ${res.error}`);
|
|
73
|
+
else diag("error", res.code ?? "E_ADDR_UNRESOLVED", at, res.error);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (addr.kind === "type") {
|
|
77
|
+
const others = (iface.aliases.get(iface.decls.get(addr.decl)) ?? []).filter((n) => n !== addr.decl);
|
|
78
|
+
if (others.length) diag("warning", "W_STRUCTURAL_ALIAS", at, `declaration "${addr.decl}" is structurally identical to ${others.map((n) => `"${n}"`).join(", ")}; the tag reaches every one of them`);
|
|
79
|
+
}
|
|
80
|
+
const dupKey = `${a.tag}@${res.key}`;
|
|
81
|
+
if (seen.has(dupKey)) {
|
|
82
|
+
const prev = seen.get(dupKey);
|
|
83
|
+
const where = prev.item === item ? `"${prev.a.at}"` : `profile "${prev.item.doc.name}" "${prev.a.at}"`;
|
|
84
|
+
if (jcs(prev.a.args) === jcs(a.args)) diag("warning", "W_DUPLICATE", at, `duplicate ${a.tag} (same position as ${where})`);
|
|
85
|
+
else if (prev.item === item) diag("error", "E_CONFLICT", at, `two different ${a.tag} annotations at the same position ("${prev.a.at}" and "${at}")`);
|
|
86
|
+
else diag("error", "E_CONFLICT", at, `two different ${a.tag} annotations at the same position (profile "${prev.item.doc.name}" "${prev.a.at}" and profile "${doc.name}" "${at}")`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (item.apply && a.tag === "outcome" && a.args.classes) {
|
|
90
|
+
const kept = {};
|
|
91
|
+
for (const [k, v] of Object.entries(a.args.classes)) {
|
|
92
|
+
const r = iface.resolveRel(addr.method, "results", k);
|
|
93
|
+
if (r.error || r.parent?.kind !== "variant") diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `outcome "${k}": ${r.error ?? "not a variant case"}`);
|
|
94
|
+
else kept[k] = v;
|
|
95
|
+
}
|
|
96
|
+
a = { ...a, args: { ...a.args, classes: kept } };
|
|
97
|
+
}
|
|
98
|
+
seen.set(dupKey, { a, item });
|
|
99
|
+
const entry = { args: a.args, origin: doc.origin, profile: doc.name };
|
|
100
|
+
meta.set(entry, { item, at });
|
|
101
|
+
entries.push({ item, ann: a, addr, res, entry });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const tags = buildLayers(entries);
|
|
105
|
+
|
|
106
|
+
// Pass 2: per-annotation checks (needs the merged index for stamp lookups).
|
|
107
|
+
// A pack applied to a service is allowed to differ from its fragment in the ways Candid subtyping allows, so an
|
|
108
|
+
// annotation that no longer holds on this service is dropped with an info, not reported as an error in the pack.
|
|
109
|
+
const dropped = new Set();
|
|
110
|
+
const sets = new Map(); // names set -> { at, method, fields: { name, value, show?, kind? } } (spec 5.3, D-045)
|
|
111
|
+
const named = []; // named positions, checked once every set is known
|
|
112
|
+
for (const e of entries) {
|
|
113
|
+
const { item, ann: a, addr, res } = e;
|
|
114
|
+
const { doc } = item;
|
|
115
|
+
const diag = !item.apply ? item.diag : (severity, code, at, message) => {
|
|
116
|
+
if (severity !== "error") return item.diag(severity, code, at, message);
|
|
117
|
+
dropped.add(e);
|
|
118
|
+
item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag} dropped: ${code}: ${message}`);
|
|
119
|
+
};
|
|
120
|
+
const at = a.at;
|
|
121
|
+
if (METHOD_TAGS.has(a.tag)) {
|
|
122
|
+
const m = res.method;
|
|
123
|
+
const methodName = addr.method;
|
|
124
|
+
if (a.tag === "effect") {
|
|
125
|
+
const { kind, irreversible, replicated } = a.args;
|
|
126
|
+
if (m.mode !== "update" && m.mode !== "oneway" && kind !== "read") diag("error", "E_EFFECT_MODE", at, `${m.mode} method cannot have effect kind "${kind}"`);
|
|
127
|
+
// A read-only update (D-038): a read sent as an update so that its answer goes through consensus.
|
|
128
|
+
if (replicated === true) {
|
|
129
|
+
if (m.mode !== "update") diag("error", "E_EFFECT_MODE", at, `replicated: true applies to update methods, not to a ${m.mode} method`);
|
|
130
|
+
if (kind !== "read" || irreversible !== false) diag("error", "E_EFFECT_MODE", at, `replicated: true needs kind "read" and irreversible: false, found kind "${kind}" and irreversible: ${irreversible}`);
|
|
131
|
+
}
|
|
132
|
+
const replicatedRead = replicated === true && m.mode === "update" && kind === "read" && irreversible === false;
|
|
133
|
+
if ((m.mode === "update" || m.mode === "oneway") && kind === "read" && !replicatedRead) {
|
|
134
|
+
diag("warning", "W_EFFECT_MODE", at, `${m.mode} method declared as read${m.mode === "update" && replicated !== true ? "; a read sent as an update says irreversible: false, replicated: true" : ""}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (a.tag === "idempotency") {
|
|
138
|
+
if (m.mode !== "update") diag("error", "E_IDEMPOTENCY_MODE", at, "idempotency applies to update methods");
|
|
139
|
+
const r = iface.resolveRel(methodName, "args", a.args.stamp);
|
|
140
|
+
if (r.error) diag("error", "E_ADDR_UNRESOLVED", at, `stamp: ${r.error}`);
|
|
141
|
+
else {
|
|
142
|
+
if (iface.node(r.ref).kind !== "opt") diag("warning", "W_STAMP_REQUIRED", at, "stamp field is not optional; the tool layer must always fill it");
|
|
143
|
+
if (!tags.at(r, iface)?.timestamp) diag("error", "E_STAMP_UNTAGGED", at, `stamp field "${a.args.stamp}" needs a timestamp annotation`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (a.tag === "outcome") {
|
|
147
|
+
const classes = a.args.classes ?? {}; // left out: every reply is the default's class (spec 5.3, D-049)
|
|
148
|
+
for (const k of Object.keys(classes)) {
|
|
149
|
+
const r = iface.resolveRel(methodName, "results", k);
|
|
150
|
+
if (r.error) diag("error", "E_ADDR_UNRESOLVED", at, `outcome "${k}": ${r.error}`);
|
|
151
|
+
else if (r.parent?.kind !== "variant") diag("error", "E_OUTCOME_NOT_CASE", at, `outcome "${k}" must select a variant case`);
|
|
152
|
+
}
|
|
153
|
+
outcomeCoverage(iface, methodName, classes, diag, at, Boolean(a.args.default));
|
|
154
|
+
if (doc.kind === "pack" && !a.args.default) diag("warning", "W_PACK_NO_DEFAULT", at, "pack outcome without default: result cases a matching canister adds (spec §7) would be unclassified");
|
|
155
|
+
}
|
|
156
|
+
if (a.tag === "intent") {
|
|
157
|
+
const t = parseTemplate(a.args.template);
|
|
158
|
+
if (t.error) diag("error", "E_TEMPLATE", at, t.error);
|
|
159
|
+
else for (const p of t.placeholders) {
|
|
160
|
+
const r = iface.resolveRel(methodName, "args", p.path);
|
|
161
|
+
if (r.error && item.apply && p.inOptional) { // absent in this service: the optional segment renders empty (spec section 6)
|
|
162
|
+
item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `intent: {${p.path}} is absent in this service (${r.error}); its optional segment renders empty`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (r.error) { diag("error", "E_TEMPLATE_PATH", at, `{${p.path}}: ${r.error}`); continue; }
|
|
166
|
+
if (!p.inOptional && iface.node(r.ref).kind === "opt") diag("warning", "W_TEMPLATE_OPTIONAL", at, `{${p.path}} is optional but sits outside a [ ] segment`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (a.tag === "names") {
|
|
170
|
+
const effect = tags.methodTags(methodName).effect?.args;
|
|
171
|
+
const replicatedRead = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
|
|
172
|
+
if (m.mode !== "query" && m.mode !== "composite_query" && !replicatedRead) {
|
|
173
|
+
diag("error", "E_NAMES_MODE", at, `names needs a query, a composite query or a read-only update (D-038); "${methodName}" is ${m.mode}`);
|
|
174
|
+
}
|
|
175
|
+
if (m.args.length !== 0) diag("error", "E_NAMES_SHAPE", at, `names needs a method without arguments; "${methodName}" takes ${m.args.length}`);
|
|
176
|
+
const items = iface.resolveRel(methodName, "results", a.args.items);
|
|
177
|
+
const vec = items.error ? undefined : iface.unwrapOpt(items.ref).ref;
|
|
178
|
+
if (items.error) diag("error", "E_ADDR_UNRESOLVED", at, `names.items: ${items.error}`);
|
|
179
|
+
else if (iface.node(vec).kind !== "vec" || iface.isBlob(vec) || iface.node(iface.unwrapOpt(iface.node(vec).inner).ref).kind !== "record") {
|
|
180
|
+
diag("error", "E_NAMES_SHAPE", at, `names.items must be a vec of records, found ${iface.describeType(items.ref)}`);
|
|
181
|
+
} else {
|
|
182
|
+
const fields = {};
|
|
183
|
+
for (const k of ["name", "value", "show", "kind"]) {
|
|
184
|
+
if (a.args[k] === undefined) continue;
|
|
185
|
+
const r = iface.resolveRel(methodName, "results", `${a.args.items}[].${a.args[k]}`);
|
|
186
|
+
if (r.error) diag("error", "E_ADDR_UNRESOLVED", at, `names.${k}: ${r.error}`);
|
|
187
|
+
else fields[k] = r.ref;
|
|
188
|
+
}
|
|
189
|
+
if (fields.name !== undefined && iface.prim(iface.unwrapOpt(fields.name).ref) !== "text") {
|
|
190
|
+
diag("error", "E_NAMES_FIELD", at, `names.name must be text, found ${iface.describeType(fields.name)}`);
|
|
191
|
+
} else if (fields.name !== undefined && fields.value !== undefined) {
|
|
192
|
+
const prev = sets.get(a.args.set);
|
|
193
|
+
if (prev) diag("error", "E_NAMES_SET_DUPLICATE", at, `names set "${a.args.set}" is already declared at "${prev.at}"`);
|
|
194
|
+
else sets.set(a.args.set, { at, method: methodName, fields });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (a.tag === "list") {
|
|
199
|
+
for (const [k, side] of [["cursor", "args"], ["next", "results"], ["items", "results"]]) {
|
|
200
|
+
const r = iface.resolveRel(methodName, side, a.args[k]);
|
|
201
|
+
if (r.error) diag("error", "E_ADDR_UNRESOLVED", at, `list.${k}: ${r.error}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (res.method) {
|
|
207
|
+
if (a.tag !== "describe") diag("error", "E_TAG_TARGET", at, `${a.tag} is a value tag; address a value, not a method`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const err = checkApplicability(iface, a.tag, res.ref);
|
|
211
|
+
if (err) diag("error", "E_TAG_NOT_APPLICABLE", at, err);
|
|
212
|
+
if (a.tag === "named") named.push({ a, res, diag, at });
|
|
213
|
+
if (a.tag === "amount") {
|
|
214
|
+
checkDynamic(iface, a.args.decimals, "numeric", diag, at);
|
|
215
|
+
checkDynamic(iface, a.args.symbol, "text", diag, at);
|
|
216
|
+
}
|
|
217
|
+
if (a.tag === "default") {
|
|
218
|
+
if (a.args.value === "null" && iface.node(res.ref).kind !== "opt") diag("error", "E_DEFAULT_NULL", at, "default null on a non-optional value");
|
|
219
|
+
if (typeof a.args.value === "object") checkDynamic(iface, a.args.value, "any", diag, at);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A named position needs its set, and the type of the field that fills it (spec 5.3, D-045).
|
|
224
|
+
for (const { a, res, diag, at } of named) {
|
|
225
|
+
const set = sets.get(a.args.set);
|
|
226
|
+
const by = a.args.by ?? "value";
|
|
227
|
+
if (!set) { diag("error", "E_NAMED_SET", at, `no names tag declares the set "${a.args.set}"`); continue; }
|
|
228
|
+
const field = set.fields[by];
|
|
229
|
+
if (field === undefined) { diag("error", "E_NAMED_SET", at, `names set "${a.args.set}" has no ${by} field`); continue; }
|
|
230
|
+
if (iface.unwrapOpt(res.ref).ref !== iface.unwrapOpt(field).ref) {
|
|
231
|
+
diag("error", "E_NAMED_TYPE", at, `named: the position is ${iface.describeType(res.ref)}, but the ${by} of set "${a.args.set}" is ${iface.describeType(field)}`);
|
|
232
|
+
} else if (iface.prim(iface.unwrapOpt(res.ref).ref) === "text" && tags.at(res, iface)?.text?.args?.pattern === undefined) {
|
|
233
|
+
diag("warning", "W_NAMED_UNPATTERNED", at, `named text without a text.pattern: every input is looked up as a name first, so a name could stand in for a value (spec 5.3)`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Dropping a value tag can leave an idempotency stamp without its timestamp tag: drop that idempotency too.
|
|
238
|
+
if (dropped.size) {
|
|
239
|
+
const without = buildLayers(entries, (e) => !dropped.has(e));
|
|
240
|
+
for (const e of entries) {
|
|
241
|
+
if (!e.item.apply || e.ann.tag !== "idempotency" || dropped.has(e)) continue;
|
|
242
|
+
const r = iface.resolveRel(e.addr.method, "args", e.ann.args.stamp);
|
|
243
|
+
if (!r.error && !without.at(r, iface)?.timestamp) {
|
|
244
|
+
dropped.add(e);
|
|
245
|
+
e.item.diag("info", "I_PACK_ANNOTATION_SKIPPED", e.ann.at, `idempotency dropped: the stamp field "${e.ann.args.stamp}" has no timestamp tag in this service`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Binding (spec section 7): a canister profile whose interface changed is stale when anything no longer holds.
|
|
251
|
+
const stale = new Set(ordered.filter((item) => item.rebinding && item.diags.some((d) => STALE_CODES.has(d.code))));
|
|
252
|
+
// A stale profile must not supply value-conversion tags, and a dropped pack annotation is not applied: keep both
|
|
253
|
+
// out of the merged result.
|
|
254
|
+
const keep = (e) => !dropped.has(e) && !(stale.has(e.item) && VALUE_CONVERSION_TAGS.has(e.ann.tag));
|
|
255
|
+
const final = stale.size || dropped.size ? buildLayers(entries, keep) : tags;
|
|
256
|
+
|
|
257
|
+
// Overrides: the same tag at the same position in a lower layer (W_OVERRIDDEN, spec section 8).
|
|
258
|
+
if (final.layers.length > 1) {
|
|
259
|
+
const overrides = new Map();
|
|
260
|
+
const note = (cands, display) => {
|
|
261
|
+
for (const [tag, list] of Object.entries(cands)) {
|
|
262
|
+
const winner = list[0];
|
|
263
|
+
for (const loser of list.slice(1)) {
|
|
264
|
+
const w = meta.get(winner);
|
|
265
|
+
const l = meta.get(loser);
|
|
266
|
+
const key = `${tag}|${l.item.doc.name}|${l.at}|${w.item.doc.name}|${w.at}`;
|
|
267
|
+
const o = overrides.get(key);
|
|
268
|
+
if (o) o.positions++;
|
|
269
|
+
else overrides.set(key, { tag, winner, loser, w, l, positions: 1, first: display });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
for (const [name] of iface.methods) {
|
|
274
|
+
note(final.candidatesAtMethod(name), name);
|
|
275
|
+
for (const side of ["args", "results"]) for (const pos of positions(iface, name, side)) note(final.candidates(pos, iface), pos.display);
|
|
276
|
+
}
|
|
277
|
+
for (const o of overrides.values()) {
|
|
278
|
+
const where = o.positions > 1 ? `${o.positions} positions, first ${o.first}` : o.first;
|
|
279
|
+
o.l.item.diag("warning", "W_OVERRIDDEN", o.l.at, `${o.tag} from profile "${o.l.item.doc.name}" (${o.loser.origin}) is overridden by profile "${o.w.item.doc.name}" (${o.winner.origin}) at "${o.w.at}" [${where}]`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Coverage hint: numeric positions in update-method arguments with no meaning attached (merged view).
|
|
284
|
+
const MEANING = ["amount", "timestamp", "duration", "describe", "range"];
|
|
285
|
+
for (const [name, m] of iface.methods) {
|
|
286
|
+
if (m.mode !== "update") continue;
|
|
287
|
+
for (const pos of positions(iface, name, "args")) {
|
|
288
|
+
const p = iface.prim(iface.unwrapOpt(pos.ref).ref);
|
|
289
|
+
const t = final.at(pos, iface);
|
|
290
|
+
if (NUMERIC.has(p) && !MEANING.some((x) => t?.[x])) diagnostics.push({ severity: "info", code: "I_UNTAGGED_NUMERIC", at: pos.display, message: `${p} argument without amount/timestamp/duration/describe` }); // about the merged interface, so no profile
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const item of stale) {
|
|
295
|
+
item.diag("error", "E_STALE", "binds.interface_id", "the interface changed and some annotations no longer hold; value-conversion tags must not be used (the resolved form omits this profile's value-conversion tags)");
|
|
296
|
+
}
|
|
297
|
+
return { diagnostics, layered: final, ordered, stale };
|
|
298
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Types for @meantjs/profile/browser. Hand-written; the source is plain ES modules (src/*.mjs). The internal
|
|
2
|
+
// graph classes (Iface, TagIndex, LayeredTags, SubtypeChecker) are declared opaquely.
|
|
3
|
+
|
|
4
|
+
/** A Candid Profile document (docs/profile-v0.md): a canister profile or a pack. */
|
|
5
|
+
export interface ProfileDocument {
|
|
6
|
+
format: "candid-profile";
|
|
7
|
+
format_version: 0;
|
|
8
|
+
kind: "canister" | "pack";
|
|
9
|
+
name: string;
|
|
10
|
+
origin: "author" | "third_party" | "pack" | "inferred";
|
|
11
|
+
annotations: { at: string; tag: string; args?: Record<string, unknown> }[];
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A candid-profile-resolved v0 document (docs/profile-v0.md section 10). */
|
|
16
|
+
export interface ResolvedForm {
|
|
17
|
+
format: "candid-profile-resolved";
|
|
18
|
+
format_version: 0;
|
|
19
|
+
interface_id: string;
|
|
20
|
+
profiles: unknown[];
|
|
21
|
+
methods: Record<string, unknown>;
|
|
22
|
+
diagnostics: Diagnostic[];
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Diagnostic {
|
|
27
|
+
severity: "error" | "warning" | "info";
|
|
28
|
+
code: string;
|
|
29
|
+
/** A JSON pointer for E_SCHEMA, an address (`send(0).to`, `type:Tokens.e8s`) or a document path (`binds.interface_id`). */
|
|
30
|
+
at: string;
|
|
31
|
+
message: string;
|
|
32
|
+
/** The profile's name, when the diagnostic comes from one. */
|
|
33
|
+
profile?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ProfileStatus {
|
|
37
|
+
name: string;
|
|
38
|
+
origin: string | null;
|
|
39
|
+
kind: string | null;
|
|
40
|
+
profile_id: string | null;
|
|
41
|
+
status: "applied" | "rebound" | "stale" | "not_matched" | "invalid";
|
|
42
|
+
explicit: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Pack {
|
|
46
|
+
file: string;
|
|
47
|
+
doc: ProfileDocument;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An Ajv validate function for the profile schema. */
|
|
51
|
+
export interface SchemaValidator {
|
|
52
|
+
(doc: unknown): boolean;
|
|
53
|
+
errors?: { instancePath: string; message?: string; params?: Record<string, unknown> }[] | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ResolveOptions {
|
|
57
|
+
/** Candid source of the service (exactly one of `did` and `envelope`). */
|
|
58
|
+
did?: string;
|
|
59
|
+
/** The service's candid-core ContractEnvelope. */
|
|
60
|
+
envelope?: unknown;
|
|
61
|
+
didLabel?: string;
|
|
62
|
+
/** Applied as given; a pack here must match. */
|
|
63
|
+
profiles?: { doc: unknown; label?: string }[];
|
|
64
|
+
/** Tried by Candid subtyping; applied only if they match. */
|
|
65
|
+
packs?: { doc: unknown; label?: string }[];
|
|
66
|
+
validateSchema: SchemaValidator;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ResolveResult {
|
|
70
|
+
ok: boolean;
|
|
71
|
+
/** null when the service does not compile (or the envelope cannot be read). */
|
|
72
|
+
resolved: ResolvedForm | null;
|
|
73
|
+
diagnostics: Diagnostic[];
|
|
74
|
+
profiles: ProfileStatus[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ValidateResult {
|
|
78
|
+
ok: boolean;
|
|
79
|
+
profile_id?: string;
|
|
80
|
+
interface_id?: string;
|
|
81
|
+
methods?: number;
|
|
82
|
+
annotations?: number;
|
|
83
|
+
diagnostics: Diagnostic[];
|
|
84
|
+
resolved?: ResolvedForm;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
import type { Address, PathSegment } from "./address.mjs";
|
|
88
|
+
export type { Address, PathSegment } from "./address.mjs";
|
|
89
|
+
|
|
90
|
+
/** A node of the candid-core Contract graph (`kind` is primitive, opt, vec, record, variant, func, service, ...). */
|
|
91
|
+
export interface ContractNode {
|
|
92
|
+
kind: string;
|
|
93
|
+
primitive?: string;
|
|
94
|
+
inner?: number;
|
|
95
|
+
fields?: { id: number; type: number }[];
|
|
96
|
+
[key: string]: unknown;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A compiled interface: the Contract graph with the lookups the resolver uses. */
|
|
100
|
+
export declare class Iface {
|
|
101
|
+
constructor(envelope: unknown, label?: string);
|
|
102
|
+
label: string;
|
|
103
|
+
/** Method name -> mode and the type refs of its argument and result tuples. */
|
|
104
|
+
methods: Map<string, { mode: string; args: number[]; results: number[] }>;
|
|
105
|
+
/** Declaration name -> type ref. */
|
|
106
|
+
decls: Map<string, number>;
|
|
107
|
+
get interfaceId(): string | null;
|
|
108
|
+
node(ref: number): ContractNode;
|
|
109
|
+
unwrapOpt(ref: number): { ref: number; optional: boolean };
|
|
110
|
+
fieldName(container: number, id: number): string;
|
|
111
|
+
isBlob(ref: number): boolean;
|
|
112
|
+
isAccount(ref: number): boolean;
|
|
113
|
+
/** `nat64`, `opt blob`, `record`, ... */
|
|
114
|
+
describeType(ref: number): string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One value position of a method side, as `positions` yields it. */
|
|
118
|
+
export interface Position {
|
|
119
|
+
ref: number;
|
|
120
|
+
parent: { container: number; kind: string; id?: number } | null;
|
|
121
|
+
key: string;
|
|
122
|
+
/** The position's address, `send(0).amount.e8s` or `balance->(0)`. */
|
|
123
|
+
display: string;
|
|
124
|
+
/** The walk stopped here (a cycle or the depth bound). */
|
|
125
|
+
cyclic: boolean;
|
|
126
|
+
}
|
|
127
|
+
export declare class TagIndex {
|
|
128
|
+
[key: string]: unknown;
|
|
129
|
+
}
|
|
130
|
+
export declare class LayeredTags {
|
|
131
|
+
[key: string]: unknown;
|
|
132
|
+
}
|
|
133
|
+
export declare class SubtypeChecker {
|
|
134
|
+
[key: string]: unknown;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The profile schema (a copy of spec/profile-v0.schema.json). */
|
|
138
|
+
export const PROFILE_SCHEMA: Record<string, unknown>;
|
|
139
|
+
/** The standard packs (copies of packs/*.profile.json), sorted by file name. */
|
|
140
|
+
export const STANDARD_PACKS: readonly Pack[];
|
|
141
|
+
export const LAYERS: readonly ["author", "third_party", "pack", "inferred"];
|
|
142
|
+
|
|
143
|
+
/** Initialize the cli's wasm compiler; optional, for a bundler that moves the wasm file. */
|
|
144
|
+
export function initCompiler(input?: BufferSource | URL | string | Response): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* The validate function for the profile schema. The shipped schema (the default, or a document equal to it) is
|
|
147
|
+
* compiled ahead of time and needs no CSP 'unsafe-eval'; any other document is compiled by ajv with `new Function`.
|
|
148
|
+
*/
|
|
149
|
+
export function createSchemaValidator(doc?: Record<string, unknown>): Promise<SchemaValidator>;
|
|
150
|
+
export function schemaDiagnostics(validateSchema: SchemaValidator): Diagnostic[];
|
|
151
|
+
export function resolveService(opts: ResolveOptions): Promise<ResolveResult>;
|
|
152
|
+
export function validateProfile(doc: unknown, opts: { validateSchema: SchemaValidator; did?: string | null; didLabel?: string; resolved?: boolean }): Promise<ValidateResult>;
|
|
153
|
+
|
|
154
|
+
export function idlHash(label: string): number;
|
|
155
|
+
/** RFC 8785 (JCS) canonical JSON. */
|
|
156
|
+
export function jcs(value: unknown): string;
|
|
157
|
+
/** `candid-profile:v0:sha256:<hex>` over the JCS form of the document. */
|
|
158
|
+
export function profileId(doc: unknown): string;
|
|
159
|
+
export function parseAddress(at: string): Address | null;
|
|
160
|
+
export function parseRelPath(rel: string): { index: number | null; segs: PathSegment[] } | null;
|
|
161
|
+
|
|
162
|
+
export function compileInterface(source: string, label?: string): Promise<{ iface: Iface } | { failure: { code: string; message: string }[] }>;
|
|
163
|
+
export function interfaceFromEnvelope(envelope: unknown, label?: string): { iface: Iface } | { failure: { code: string; message: string }[] };
|
|
164
|
+
export function matchPack(canister: Iface, pack: unknown, opts?: Record<string, unknown>): Promise<{ matched: boolean; reasons: { at: string; message: string }[]; relaxed: unknown; fragment: Iface } | { error: { code: string; message: string }[] }>;
|
|
165
|
+
export function matchService(canister: Iface, fragment: Iface, opts?: Record<string, unknown>): { ok: boolean; reasons: { at: string; message: string }[]; relaxed: unknown };
|
|
166
|
+
export function typeSubtype(S: Iface, s: unknown, T: Iface, t: unknown, opts?: Record<string, unknown>): unknown;
|
|
167
|
+
export function positions(iface: Iface, methodName: string, side: "args" | "results", maxDepth?: number): Generator<Position>;
|
|
168
|
+
export function analyzeProfiles(iface: Iface, items: unknown[], opts?: { attribute?: boolean }): { diagnostics: Diagnostic[]; layered: LayeredTags; ordered: unknown[]; stale: Set<unknown> };
|
|
169
|
+
export function resolvedForm(iface: Iface, profiles: unknown[], tags: LayeredTags, diagnostics: Diagnostic[]): ResolvedForm;
|
|
170
|
+
export function buildTree(iface: Iface, methodName: string, side: "args" | "results", tags: LayeredTags): unknown;
|
|
171
|
+
|
|
172
|
+
/** The doc attributes version `profileFromDocAttributes` reads (candid-core `docs/doc-attributes.md`). */
|
|
173
|
+
export const DOC_ATTRIBUTES_VERSION: 1;
|
|
174
|
+
/** A diagnostic about a doc comment: `at` is the item's address once it has one, else its SourceInfo path. */
|
|
175
|
+
export interface DocDiagnostic extends Diagnostic {
|
|
176
|
+
/** The documented item's SourceInfo occurrence path (`actor.methods[2]`, `type:Tokens.fields[0]`). */
|
|
177
|
+
path?: string;
|
|
178
|
+
/** The source file the comment is in, as candid-core names it (`memory:/service.did`). */
|
|
179
|
+
source?: string;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* An author canister profile from the tags written as doc comments in a `.did` (spec section 9). `response` is what
|
|
183
|
+
* `didToDocAttributes(sources)` of `@candid-core/cli` returns; a failed response is passed through as diagnostics.
|
|
184
|
+
* `doc` is null when the comments hold no tag. The profile still goes through `resolveService`, which checks the tags.
|
|
185
|
+
*/
|
|
186
|
+
export function profileFromDocAttributes(
|
|
187
|
+
response: unknown,
|
|
188
|
+
opts?: { name?: string; origin?: "author" | "third_party"; language?: string; canisterId?: string },
|
|
189
|
+
): { ok: boolean; doc: ProfileDocument | null; diagnostics: DocDiagnostic[] };
|
|
190
|
+
|
|
191
|
+
/** The custom section a canister serves its profile in (spec section 8.1): `"icp:public candid:profile"`. */
|
|
192
|
+
export const SERVED_SECTION: "icp:public candid:profile";
|
|
193
|
+
/** The metadata name a consumer reads through `read_state`: `/canister/<id>/metadata/candid:profile`. */
|
|
194
|
+
export const SERVED_METADATA: "candid:profile";
|
|
195
|
+
/** The largest served profile `readServedProfile` reads, in bytes (1 MiB, the platform's custom section limit). */
|
|
196
|
+
export const SERVED_MAX_BYTES: number;
|
|
197
|
+
/**
|
|
198
|
+
* A profile its canister serves (spec section 8.1), from the bytes of the canister's `candid:profile` metadata. Refused
|
|
199
|
+
* with E_SERVED when the bytes are not UTF-8 JSON of at most 1 MiB, the document is not a `canister` profile of origin
|
|
200
|
+
* `author`, or its `binds.canister_id` names another canister. The profile still goes through `resolveService`, which
|
|
201
|
+
* checks the schema, the binding (against the same canister's `candid:service`) and the tags.
|
|
202
|
+
*/
|
|
203
|
+
export function readServedProfile(
|
|
204
|
+
bytes: Uint8Array,
|
|
205
|
+
/** `canisterId`: the canister that served it; null only to check a profile before its module is installed. */
|
|
206
|
+
opts: { canisterId: string | null; label?: string },
|
|
207
|
+
): { ok: boolean; doc: ProfileDocument | null; diagnostics: Diagnostic[] };
|
|
208
|
+
|
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@meantjs/profile/browser`: the resolver without Node built-ins, for a browser bundle. The same functions as
|
|
3
|
+
* the main entry, except the ones that read files (`loadPacks`, the CLI, the default paths): the schema and the
|
|
4
|
+
* standard packs come in as the data this package ships, and `createSchemaValidator` takes a document, never a
|
|
5
|
+
* path. The Candid compiler is the `@candid-core/cli` wasm; it initializes itself on first use (fetched next to the
|
|
6
|
+
* module), or call `initCompiler(urlOrBytes)` first when a bundler moves the wasm file.
|
|
7
|
+
*
|
|
8
|
+
* test/browser-entry.test.mjs keeps the module graph free of node:* imports and checks the data against data/.
|
|
9
|
+
*/
|
|
10
|
+
import schema from "../data/profile-v0.schema.json" with { type: "json" };
|
|
11
|
+
import icrc1 from "../data/packs/icrc1.profile.json" with { type: "json" };
|
|
12
|
+
import icrc2 from "../data/packs/icrc2.profile.json" with { type: "json" };
|
|
13
|
+
import { compileSchema } from "./schema-core.mjs";
|
|
14
|
+
|
|
15
|
+
export { init as initCompiler } from "@candid-core/cli";
|
|
16
|
+
export { idlHash, jcs, profileId } from "./util.mjs";
|
|
17
|
+
export { parseAddress, parseRelPath } from "./address.mjs";
|
|
18
|
+
export { Iface } from "./iface.mjs";
|
|
19
|
+
export { TagIndex, LayeredTags, positions } from "./tags.mjs";
|
|
20
|
+
export { schemaDiagnostics } from "./schema-core.mjs";
|
|
21
|
+
export { compileInterface, interfaceFromEnvelope } from "./compile.mjs";
|
|
22
|
+
export { validateProfile } from "./validate.mjs";
|
|
23
|
+
export { resolveService } from "./resolve.mjs";
|
|
24
|
+
export { matchPack } from "./pack-match.mjs";
|
|
25
|
+
export { matchService, typeSubtype, SubtypeChecker } from "./subtype.mjs";
|
|
26
|
+
export { analyzeProfiles, LAYERS } from "./analyze.mjs";
|
|
27
|
+
export { resolvedForm, buildTree } from "./resolved.mjs";
|
|
28
|
+
export { profileFromDocAttributes, DOC_ATTRIBUTES_VERSION } from "./doc-annotations.mjs";
|
|
29
|
+
export { readServedProfile, SERVED_SECTION, SERVED_METADATA, SERVED_MAX_BYTES } from "./served.mjs";
|
|
30
|
+
|
|
31
|
+
/** The profile schema (a copy of spec/profile-v0.schema.json). */
|
|
32
|
+
export const PROFILE_SCHEMA = schema;
|
|
33
|
+
|
|
34
|
+
/** The standard packs (copies of packs/*.profile.json), sorted by file name, in the shape `loadPacks` returns. */
|
|
35
|
+
export const STANDARD_PACKS = Object.freeze([
|
|
36
|
+
{ file: "icrc1.profile.json", doc: icrc1 },
|
|
37
|
+
{ file: "icrc2.profile.json", doc: icrc2 },
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The validate function for the profile schema. The schema this package ships (the default, or any document equal to
|
|
42
|
+
* it) is compiled ahead of time and generates no code at run time, so it needs no 'unsafe-eval' in a page's
|
|
43
|
+
* Content-Security-Policy. Any other document is compiled by ajv with `new Function`, which does.
|
|
44
|
+
*
|
|
45
|
+
* @param doc the schema document (default: the copy this package ships)
|
|
46
|
+
*/
|
|
47
|
+
export async function createSchemaValidator(doc = schema) {
|
|
48
|
+
if (typeof doc === "string") throw new TypeError("createSchemaValidator: the browser entry takes the schema document, not a path");
|
|
49
|
+
return compileSchema(doc);
|
|
50
|
+
}
|
package/src/checks.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-annotation semantic checks (spec sections 3 to 6). Moved verbatim from scripts/validate.mjs.
|
|
3
|
+
*/
|
|
4
|
+
export const NUMERIC = new Set(["nat", "int", "nat8", "nat16", "nat32", "nat64", "int8", "int16", "int32", "int64"]);
|
|
5
|
+
export const TIMEISH = new Set(["nat", "int", "nat32", "nat64", "int64"]);
|
|
6
|
+
export const METHOD_TAGS = new Set(["intent", "effect", "idempotency", "outcome", "list", "names"]);
|
|
7
|
+
/** Tags that convert a value between its wire form and a human form; a stale profile must not supply them (spec section 7). */
|
|
8
|
+
export const VALUE_CONVERSION_TAGS = new Set(["amount", "timestamp", "duration", "account", "subaccount", "bytes", "named"]);
|
|
9
|
+
export const STALE_CODES = new Set(["E_ADDR_UNRESOLVED", "E_TYPE_NODE", "E_TAG_NOT_APPLICABLE", "E_REF_UNRESOLVED", "E_REF_NOT_QUERY", "E_REF_SHAPE", "E_REF_TYPE", "E_STAMP_UNTAGGED", "E_OUTCOME_NOT_CASE", "E_TEMPLATE_PATH", "E_TAG_TARGET", "E_NAMES_MODE", "E_NAMES_SHAPE", "E_NAMES_FIELD", "E_NAMED_SET", "E_NAMED_TYPE"]);
|
|
10
|
+
|
|
11
|
+
export function checkApplicability(iface, tag, ref) {
|
|
12
|
+
const { ref: r } = iface.unwrapOpt(ref);
|
|
13
|
+
const p = iface.prim(r);
|
|
14
|
+
switch (tag) {
|
|
15
|
+
case "amount": return NUMERIC.has(p) ? null : `amount needs a numeric type, found ${iface.describeType(ref)}`;
|
|
16
|
+
case "timestamp":
|
|
17
|
+
case "duration": return TIMEISH.has(p) ? null : `${tag} needs nat/int/nat32/nat64/int64, found ${iface.describeType(ref)}`;
|
|
18
|
+
case "account": return iface.isAccount(r) ? null : `account needs record { owner : principal; subaccount : opt blob }, found ${iface.describeType(ref)}`;
|
|
19
|
+
case "subaccount":
|
|
20
|
+
case "bytes": return iface.isBlob(r) ? null : `${tag} needs blob, found ${iface.describeType(ref)}`;
|
|
21
|
+
case "text": return p === "text" ? null : `text needs text, found ${iface.describeType(ref)}`;
|
|
22
|
+
case "range": return NUMERIC.has(p) || p === "text" || iface.node(r).kind === "vec" ? null : `range needs numeric, text or vec, found ${iface.describeType(ref)}`;
|
|
23
|
+
default: return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function checkDynamic(iface, value, want, diag, at) {
|
|
28
|
+
if (!value || typeof value !== "object") return;
|
|
29
|
+
const from = value.from;
|
|
30
|
+
if (from === "now" || from === "caller") {
|
|
31
|
+
if (want !== "any") diag("error", "E_DYNAMIC_KIND", at, `"${from}" is not valid here; expected query:<method> returning ${want}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const name = from.slice("query:".length);
|
|
35
|
+
const m = iface.methods.get(name);
|
|
36
|
+
if (!m) return diag("error", "E_REF_UNRESOLVED", at, `dynamic ref "${from}": no method "${name}" in the interface`);
|
|
37
|
+
if (m.mode !== "query" && m.mode !== "composite_query") diag("error", "E_REF_NOT_QUERY", at, `dynamic ref "${from}" must be a query method`);
|
|
38
|
+
if (m.args.length !== 0 || m.results.length !== 1) diag("error", "E_REF_SHAPE", at, `dynamic ref "${from}" must take no arguments and return one value`);
|
|
39
|
+
else {
|
|
40
|
+
const p = iface.prim(iface.unwrapOpt(m.results[0]).ref);
|
|
41
|
+
if (want === "numeric" && !NUMERIC.has(p)) diag("error", "E_REF_TYPE", at, `"${from}" returns ${p}, expected a number`);
|
|
42
|
+
if (want === "text" && p !== "text") diag("error", "E_REF_TYPE", at, `"${from}" returns ${p}, expected text`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseTemplate(t) {
|
|
47
|
+
const placeholders = [];
|
|
48
|
+
let depth = 0;
|
|
49
|
+
for (let i = 0; i < t.length; i++) {
|
|
50
|
+
const c = t[i];
|
|
51
|
+
if ((c === "{" || c === "}" || c === "[" || c === "]") && t[i + 1] === c) { i++; continue; } // escaped
|
|
52
|
+
if (c === "[") { if (depth) return { error: "nested [ ] segments are not allowed in v0" }; depth = 1; }
|
|
53
|
+
else if (c === "]") { if (!depth) return { error: "unbalanced ]" }; depth = 0; }
|
|
54
|
+
else if (c === "{") {
|
|
55
|
+
const end = t.indexOf("}", i);
|
|
56
|
+
if (end < 0) return { error: "unclosed {" };
|
|
57
|
+
placeholders.push({ path: t.slice(i + 1, end), inOptional: depth === 1 });
|
|
58
|
+
i = end;
|
|
59
|
+
} else if (c === "}") return { error: "unbalanced }" };
|
|
60
|
+
}
|
|
61
|
+
if (depth) return { error: "unclosed [" };
|
|
62
|
+
return { placeholders };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function outcomeCoverage(iface, methodName, classes, diag, at, hasDefault) {
|
|
66
|
+
const m = iface.methods.get(methodName);
|
|
67
|
+
if (m.results.length !== 1) return;
|
|
68
|
+
const visit = (ref, prefix) => {
|
|
69
|
+
const { ref: r } = iface.unwrapOpt(ref);
|
|
70
|
+
const n = iface.node(r);
|
|
71
|
+
if (n.kind !== "variant") return;
|
|
72
|
+
for (const f of n.fields) {
|
|
73
|
+
const key = `${prefix}${iface.fieldName(r, f.id)}`;
|
|
74
|
+
if (classes[key]) continue;
|
|
75
|
+
const inner = iface.node(iface.unwrapOpt(f.type).ref);
|
|
76
|
+
if (inner.kind === "variant" && Object.keys(classes).some((k) => k.startsWith(`${key}.`))) visit(f.type, `${key}.`);
|
|
77
|
+
else diag(hasDefault ? "info" : "warning", "W_OUTCOME_UNCLASSIFIED", at, `result case "${key}" has no class${hasDefault ? " (falls back to default)" : ""}`);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
visit(m.results[0], "");
|
|
81
|
+
}
|