@meantjs/profile 0.0.0-stage → 0.2.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.
@@ -0,0 +1,30 @@
1
+ // Types for @meantjs/profile/address: the address grammar (spec section 3) and the Candid field-name hash, with
2
+ // no dependency, for a browser bundle. Hand-written; the source is src/address.mjs and src/idl.mjs.
3
+ // test/types.test.mjs keeps the declared names equal to the runtime exports.
4
+
5
+ export interface PathSegment {
6
+ kind: "field" | "elem";
7
+ /** The field name, or `#<id>` for a numeric id. */
8
+ label?: string;
9
+ id?: number;
10
+ }
11
+
12
+ export type Address =
13
+ | { kind: "type"; decl: string; segs: PathSegment[] }
14
+ | { kind: "arg" | "result"; method: string; index: number; segs: PathSegment[] }
15
+ | { kind: "method"; method: string; segs: [] };
16
+
17
+ /** The largest Candid field id. */
18
+ export const U32_MAX: 4294967295;
19
+ /** The Candid field-name hash. */
20
+ export function idlHash(label: string): number;
21
+ /** `.amount.e8s`, `.#5`, `[]` to segments; null when the path does not parse. */
22
+ export function parsePath(path: string): PathSegment[] | null;
23
+ /** `send(0).to`, `balance->(0)`, `type:Tokens.e8s` or a method name; null when the address does not parse. */
24
+ export function parseAddress(at: string): Address | null;
25
+ /** A path relative to a method: `(0).to.owner`, `to.owner` or `Ok`; `index` is null without `(n)`. */
26
+ export function parseRelPath(rel: string): { index: number | null; segs: PathSegment[] } | null;
27
+ /** The resolver's key of a method position. */
28
+ export function methodKey(method: string, side: "arg" | "res", index: number, segs: PathSegment[]): string;
29
+ /** The resolver's key of a `type:` edge. */
30
+ export function edgeKey(parent: { container: number; kind: string; id?: number }): string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Address grammar (spec section 3). Addresses are compared by their resolved form, never as strings (D-011):
3
+ * method positions -> "m:<method>|<arg|res>|<index>|<id>/<id>/[]..."
4
+ * type: edges -> "e:<container>:<id or []>" (applies wherever the edge is reached)
5
+ * type: whole decl -> "n:<node>" (records and variants only, E_TYPE_NODE otherwise)
6
+ *
7
+ * Browser-safe: this module and idl.mjs import nothing else, and the package exports them as
8
+ * `@meantjs/profile/address` (test/address-entry.test.mjs keeps it that way).
9
+ */
10
+ import { idlHash, U32_MAX } from "./idl.mjs";
11
+
12
+ export { idlHash, U32_MAX };
13
+
14
+ const IDENT = "[A-Za-z_][A-Za-z0-9_]*";
15
+ const NUMID = "#(?:0|[1-9][0-9]{0,9})";
16
+ const SEG_RE = new RegExp(`\\.(${IDENT}|${NUMID})|\\[\\]`, "y");
17
+
18
+ export function parsePath(str) {
19
+ const segs = [];
20
+ let i = 0;
21
+ while (i < str.length) {
22
+ SEG_RE.lastIndex = i;
23
+ const m = SEG_RE.exec(str);
24
+ if (!m) return null;
25
+ if (m[0] === "[]") segs.push({ kind: "elem" });
26
+ else if (m[1].startsWith("#")) {
27
+ const id = Number(m[1].slice(1));
28
+ if (id > U32_MAX) return null;
29
+ segs.push({ kind: "field", label: m[1], id });
30
+ } else segs.push({ kind: "field", label: m[1], id: idlHash(m[1]) });
31
+ i = SEG_RE.lastIndex;
32
+ }
33
+ return segs;
34
+ }
35
+
36
+ export function parseAddress(at) {
37
+ let m = new RegExp(`^type:(${IDENT})(.*)$`).exec(at);
38
+ if (m) {
39
+ const segs = parsePath(m[2]);
40
+ return segs && { kind: "type", decl: m[1], segs };
41
+ }
42
+ m = new RegExp(`^(${IDENT})(?:\\((\\d+)\\)|->\\((\\d+)\\))?(.*)$`).exec(at);
43
+ if (!m) return null;
44
+ const segs = parsePath(m[4]);
45
+ if (!segs) return null;
46
+ if (m[2] !== undefined) return { kind: "arg", method: m[1], index: Number(m[2]), segs };
47
+ if (m[3] !== undefined) return { kind: "result", method: m[1], index: Number(m[3]), segs };
48
+ if (segs.length) return null;
49
+ return { kind: "method", method: m[1], segs: [] };
50
+ }
51
+
52
+ export function parseRelPath(rel) {
53
+ const m = /^(?:\((\d+)\))?(.*)$/.exec(rel);
54
+ let rest = m[2];
55
+ if (rest && !rest.startsWith(".") && !rest.startsWith("[")) rest = `.${rest}`;
56
+ const segs = parsePath(rest);
57
+ return segs && { index: m[1] === undefined ? null : Number(m[1]), segs };
58
+ }
59
+
60
+ const segKey = (s) => (s.kind === "elem" ? "[]" : String(s.id));
61
+ export const methodKey = (method, side, index, segs) => `m:${method}|${side}|${index}|${segs.map(segKey).join("/")}`;
62
+ export const edgeKey = (parent) => `e:${parent.container}:${parent.kind === "vec" ? "[]" : parent.id}`;
@@ -0,0 +1,344 @@
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, CASE_TAGS, CONFIRM_KINDS, STALE_CODES, VALUE_CONVERSION_TAGS, EVM_LATER_TAGS,
11
+ checkApplicability, checkDynamic, parseTemplate, outcomeCoverage, caseTargetError, otherCase, overlap, defaultEffect,
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 caseEntries = []; // case intents and effects, checked against each other and their method after the loop
111
+
112
+ /** An effect against the method's mode (spec 5.3); for a case effect, the mode of the method it sits in. */
113
+ const checkEffectMode = (m, { kind, irreversible, replicated }, diag, at) => {
114
+ if (m.mode !== "update" && m.mode !== "oneway" && kind !== "read") diag("error", "E_EFFECT_MODE", at, `${m.mode} method cannot have effect kind "${kind}"`);
115
+ // A read-only update (D-038): a read sent as an update so that its answer goes through consensus.
116
+ if (replicated === true) {
117
+ if (m.mode !== "update") diag("error", "E_EFFECT_MODE", at, `replicated: true applies to update methods, not to a ${m.mode} method`);
118
+ 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}`);
119
+ }
120
+ const replicatedRead = replicated === true && m.mode === "update" && kind === "read" && irreversible === false;
121
+ if ((m.mode === "update" || m.mode === "oneway") && kind === "read" && !replicatedRead) {
122
+ 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" : ""}`);
123
+ }
124
+ };
125
+
126
+ /** An intent template's placeholders (spec section 6); `caseAddr` is the case a case intent sits on. */
127
+ const checkTemplate = (item, methodName, template, diag, at, caseAddr) => {
128
+ const t = parseTemplate(template);
129
+ if (t.error) return diag("error", "E_TEMPLATE", at, t.error);
130
+ if (iface.chain === "evm" && t.segments) return diag("error", "E_TEMPLATE", at, "an EVM contract's template has no [ ] segments: an ABI value is never absent (spec section 15)");
131
+ for (const p of t.placeholders) {
132
+ const r = iface.resolveRel(methodName, "args", p.path);
133
+ if (r.error && item.apply && p.inOptional) { // absent in this service: the optional segment renders empty (spec section 6)
134
+ item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `intent: {${p.path}} is absent in this service (${r.error}); its optional segment renders empty`);
135
+ continue;
136
+ }
137
+ if (r.error) { diag("error", "E_TEMPLATE_PATH", at, `{${p.path}}: ${r.error}`); continue; }
138
+ const other = caseAddr && otherCase(iface, caseAddr, p.path);
139
+ if (other) { diag("error", "E_TEMPLATE_PATH", at, `{${p.path}}: ${other}`); continue; }
140
+ if (!p.inOptional && iface.node(r.ref).kind === "opt") diag("warning", "W_TEMPLATE_OPTIONAL", at, `{${p.path}} is optional but sits outside a [ ] segment`);
141
+ }
142
+ };
143
+
144
+ const sets = new Map(); // names set -> { at, method, fields: { name, value, show?, kind? } } (spec 5.3, D-045)
145
+ const named = []; // named positions, checked once every set is known
146
+ for (const e of entries) {
147
+ const { item, ann: a, addr, res } = e;
148
+ const { doc } = item;
149
+ const diag = !item.apply ? item.diag : (severity, code, at, message) => {
150
+ if (severity !== "error") return item.diag(severity, code, at, message);
151
+ dropped.add(e);
152
+ item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag} dropped: ${code}: ${message}`);
153
+ };
154
+ const at = a.at;
155
+ if (iface.chain === "evm" && EVM_LATER_TAGS.has(a.tag)) {
156
+ diag("error", "E_TAG_TARGET", at, `${a.tag} is not defined for EVM contracts yet: it needs receipts, nonces or reads that a later phase adds (spec section 15)`);
157
+ continue;
158
+ }
159
+ // intent and effect on a case of a variant in the arguments (spec 5.2, D-053)
160
+ if (CASE_TAGS.has(a.tag) && !res.method) {
161
+ const why = caseTargetError(iface, addr, res, a.tag);
162
+ if (why) { diag("error", "E_TAG_TARGET", at, why); continue; }
163
+ const m = iface.methods.get(addr.method);
164
+ if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
165
+ else checkTemplate(item, addr.method, a.args.template, diag, at, addr);
166
+ caseEntries.push({ e, diag });
167
+ continue;
168
+ }
169
+ if (METHOD_TAGS.has(a.tag)) {
170
+ const m = res.method;
171
+ const methodName = addr.method;
172
+ if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
173
+ if (a.tag === "idempotency") {
174
+ if (m.mode !== "update") diag("error", "E_IDEMPOTENCY_MODE", at, "idempotency applies to update methods");
175
+ const r = iface.resolveRel(methodName, "args", a.args.stamp);
176
+ if (r.error) diag("error", "E_ADDR_UNRESOLVED", at, `stamp: ${r.error}`);
177
+ else {
178
+ 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");
179
+ if (!tags.at(r, iface)?.timestamp) diag("error", "E_STAMP_UNTAGGED", at, `stamp field "${a.args.stamp}" needs a timestamp annotation`);
180
+ }
181
+ }
182
+ if (a.tag === "outcome") {
183
+ const classes = a.args.classes ?? {}; // left out: every reply is the default's class (spec 5.3, D-049)
184
+ for (const k of Object.keys(classes)) {
185
+ const r = iface.resolveRel(methodName, "results", k);
186
+ if (r.error) diag("error", "E_ADDR_UNRESOLVED", at, `outcome "${k}": ${r.error}`);
187
+ else if (r.parent?.kind !== "variant") diag("error", "E_OUTCOME_NOT_CASE", at, `outcome "${k}" must select a variant case`);
188
+ }
189
+ outcomeCoverage(iface, methodName, classes, diag, at, Boolean(a.args.default));
190
+ 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");
191
+ }
192
+ if (a.tag === "intent") checkTemplate(item, methodName, a.args.template, diag, at);
193
+ if (a.tag === "names") {
194
+ const effect = tags.methodTags(methodName).effect?.args;
195
+ const replicatedRead = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
196
+ if (m.mode !== "query" && m.mode !== "composite_query" && !replicatedRead) {
197
+ diag("error", "E_NAMES_MODE", at, `names needs a query, a composite query or a read-only update (D-038); "${methodName}" is ${m.mode}`);
198
+ }
199
+ if (m.args.length !== 0) diag("error", "E_NAMES_SHAPE", at, `names needs a method without arguments; "${methodName}" takes ${m.args.length}`);
200
+ const items = iface.resolveRel(methodName, "results", a.args.items);
201
+ const vec = items.error ? undefined : iface.unwrapOpt(items.ref).ref;
202
+ if (items.error) diag("error", "E_ADDR_UNRESOLVED", at, `names.items: ${items.error}`);
203
+ else if (iface.node(vec).kind !== "vec" || iface.isBlob(vec) || iface.node(iface.unwrapOpt(iface.node(vec).inner).ref).kind !== "record") {
204
+ diag("error", "E_NAMES_SHAPE", at, `names.items must be a vec of records, found ${iface.describeType(items.ref)}`);
205
+ } else {
206
+ const fields = {};
207
+ for (const k of ["name", "value", "show", "kind"]) {
208
+ if (a.args[k] === undefined) continue;
209
+ const r = iface.resolveRel(methodName, "results", `${a.args.items}[].${a.args[k]}`);
210
+ if (r.error) diag("error", "E_ADDR_UNRESOLVED", at, `names.${k}: ${r.error}`);
211
+ else fields[k] = r.ref;
212
+ }
213
+ if (fields.name !== undefined && iface.prim(iface.unwrapOpt(fields.name).ref) !== "text") {
214
+ diag("error", "E_NAMES_FIELD", at, `names.name must be text, found ${iface.describeType(fields.name)}`);
215
+ } else if (fields.name !== undefined && fields.value !== undefined) {
216
+ const prev = sets.get(a.args.set);
217
+ if (prev) diag("error", "E_NAMES_SET_DUPLICATE", at, `names set "${a.args.set}" is already declared at "${prev.at}"`);
218
+ else sets.set(a.args.set, { at, method: methodName, fields });
219
+ }
220
+ }
221
+ }
222
+ if (a.tag === "list") {
223
+ for (const [k, side] of [["cursor", "args"], ["next", "results"], ["items", "results"]]) {
224
+ const r = iface.resolveRel(methodName, side, a.args[k]);
225
+ if (r.error) diag("error", "E_ADDR_UNRESOLVED", at, `list.${k}: ${r.error}`);
226
+ }
227
+ }
228
+ continue;
229
+ }
230
+ if (res.method) {
231
+ if (a.tag !== "describe") diag("error", "E_TAG_TARGET", at, `${a.tag} is a value tag; address a value, not a method`);
232
+ continue;
233
+ }
234
+ const err = checkApplicability(iface, a.tag, res.ref);
235
+ if (err) diag("error", "E_TAG_NOT_APPLICABLE", at, err);
236
+ if (a.tag === "named") named.push({ a, res, diag, at });
237
+ if (a.tag === "amount") {
238
+ checkDynamic(iface, a.args.decimals, "numeric", diag, at);
239
+ checkDynamic(iface, a.args.symbol, "text", diag, at);
240
+ }
241
+ if (a.tag === "default") {
242
+ if (a.args.value === "null" && iface.node(res.ref).kind !== "opt") diag("error", "E_DEFAULT_NULL", at, "default null on a non-optional value");
243
+ if (typeof a.args.value === "object") checkDynamic(iface, a.args.value, "any", diag, at);
244
+ }
245
+ }
246
+
247
+ // A named position needs its set, and the type of the field that fills it (spec 5.3, D-045).
248
+ for (const { a, res, diag, at } of named) {
249
+ const set = sets.get(a.args.set);
250
+ const by = a.args.by ?? "value";
251
+ if (!set) { diag("error", "E_NAMED_SET", at, `no names tag declares the set "${a.args.set}"`); continue; }
252
+ const field = set.fields[by];
253
+ if (field === undefined) { diag("error", "E_NAMED_SET", at, `names set "${a.args.set}" has no ${by} field`); continue; }
254
+ if (iface.unwrapOpt(res.ref).ref !== iface.unwrapOpt(field).ref) {
255
+ 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)}`);
256
+ } else if (iface.prim(iface.unwrapOpt(res.ref).ref) === "text" && tags.at(res, iface)?.text?.args?.pattern === undefined) {
257
+ 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)`);
258
+ }
259
+ }
260
+
261
+ // Tagged cases of one method must not overlap, and a case effect needs a method effect at least as strong
262
+ // (spec 5.2 and 5.3, D-053).
263
+ for (let j = 0; j < caseEntries.length; j++) {
264
+ const { e, diag } = caseEntries[j];
265
+ const { addr, ann: a } = e;
266
+ for (const { e: prev } of caseEntries.slice(0, j)) {
267
+ if (prev.ann.tag !== a.tag || prev.addr.method !== addr.method || prev.res.key === e.res.key) continue;
268
+ const why = overlap(iface, prev.addr, addr);
269
+ if (why) diag("error", "E_CASE_OVERLAP", a.at, `${a.tag} on "${prev.ann.at}" and on "${a.at}": one call can take both cases (${why})`);
270
+ }
271
+ if (a.tag !== "effect") continue;
272
+ const m = iface.methods.get(addr.method);
273
+ const declared = tags.methodTags(addr.method).effect?.args;
274
+ const method = declared ?? defaultEffect(m.mode);
275
+ const whose = `the method's effect is ${method.kind}${method.irreversible ? ", irreversible" : ""}${declared ? "" : " (the default)"}`;
276
+ if (CONFIRM_KINDS.has(a.args.kind) && !CONFIRM_KINDS.has(method.kind)) {
277
+ diag("error", "E_CASE_EFFECT", a.at, `case effect ${a.args.kind} needs a person's confirmation, but ${whose}; declare the method's effect as transfer, approve, admin or destroy, so that a consumer that reads only method tags confirms it too`);
278
+ } else if (a.args.irreversible === true && method.irreversible !== true) {
279
+ diag("error", "E_CASE_EFFECT", a.at, `case effect is irreversible, but ${whose}; a method with an irreversible case is irreversible`);
280
+ }
281
+ }
282
+
283
+ // Dropping a value tag can leave an idempotency stamp without its timestamp tag: drop that idempotency too.
284
+ if (dropped.size) {
285
+ const without = buildLayers(entries, (e) => !dropped.has(e));
286
+ for (const e of entries) {
287
+ if (!e.item.apply || e.ann.tag !== "idempotency" || dropped.has(e)) continue;
288
+ const r = iface.resolveRel(e.addr.method, "args", e.ann.args.stamp);
289
+ if (!r.error && !without.at(r, iface)?.timestamp) {
290
+ dropped.add(e);
291
+ 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`);
292
+ }
293
+ }
294
+ }
295
+
296
+ // Binding (spec section 7): a canister profile whose interface changed is stale when anything no longer holds.
297
+ const stale = new Set(ordered.filter((item) => item.rebinding && item.diags.some((d) => STALE_CODES.has(d.code))));
298
+ // A stale profile must not supply value-conversion tags, and a dropped pack annotation is not applied: keep both
299
+ // out of the merged result.
300
+ const keep = (e) => !dropped.has(e) && !(stale.has(e.item) && VALUE_CONVERSION_TAGS.has(e.ann.tag));
301
+ const final = stale.size || dropped.size ? buildLayers(entries, keep) : tags;
302
+
303
+ // Overrides: the same tag at the same position in a lower layer (W_OVERRIDDEN, spec section 8).
304
+ if (final.layers.length > 1) {
305
+ const overrides = new Map();
306
+ const note = (cands, display) => {
307
+ for (const [tag, list] of Object.entries(cands)) {
308
+ const winner = list[0];
309
+ for (const loser of list.slice(1)) {
310
+ const w = meta.get(winner);
311
+ const l = meta.get(loser);
312
+ const key = `${tag}|${l.item.doc.name}|${l.at}|${w.item.doc.name}|${w.at}`;
313
+ const o = overrides.get(key);
314
+ if (o) o.positions++;
315
+ else overrides.set(key, { tag, winner, loser, w, l, positions: 1, first: display });
316
+ }
317
+ }
318
+ };
319
+ for (const [name] of iface.methods) {
320
+ note(final.candidatesAtMethod(name), name);
321
+ for (const side of ["args", "results"]) for (const pos of positions(iface, name, side)) note(final.candidates(pos, iface), pos.display);
322
+ }
323
+ for (const o of overrides.values()) {
324
+ const where = o.positions > 1 ? `${o.positions} positions, first ${o.first}` : o.first;
325
+ 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}]`);
326
+ }
327
+ }
328
+
329
+ // Coverage hint: numeric positions in update-method arguments with no meaning attached (merged view).
330
+ const MEANING = ["amount", "timestamp", "duration", "describe", "range"];
331
+ for (const [name, m] of iface.methods) {
332
+ if (m.mode !== "update") continue;
333
+ for (const pos of positions(iface, name, "args")) {
334
+ const p = iface.prim(iface.unwrapOpt(pos.ref).ref);
335
+ const t = final.at(pos, iface);
336
+ 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
337
+ }
338
+ }
339
+
340
+ for (const item of stale) {
341
+ 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)");
342
+ }
343
+ return { diagnostics, layered: final, ordered, stale };
344
+ }
@@ -0,0 +1,212 @@
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, a pack, or an EVM contract profile (section 15). */
5
+ export interface ProfileDocument {
6
+ format: "candid-profile";
7
+ format_version: 0;
8
+ kind: "canister" | "pack" | "contract";
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
+ /** "evm" for an EVM contract's form (section 15.5); absent for a Candid service's. */
21
+ chain?: "evm";
22
+ profiles: unknown[];
23
+ methods: Record<string, unknown>;
24
+ diagnostics: Diagnostic[];
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ export interface Diagnostic {
29
+ severity: "error" | "warning" | "info";
30
+ code: string;
31
+ /** A JSON pointer for E_SCHEMA, an address (`send(0).to`, `type:Tokens.e8s`) or a document path (`binds.interface_id`). */
32
+ at: string;
33
+ message: string;
34
+ /** The profile's name, when the diagnostic comes from one. */
35
+ profile?: string;
36
+ }
37
+
38
+ export interface ProfileStatus {
39
+ name: string;
40
+ origin: string | null;
41
+ kind: string | null;
42
+ profile_id: string | null;
43
+ status: "applied" | "rebound" | "stale" | "not_matched" | "invalid";
44
+ explicit: boolean;
45
+ }
46
+
47
+ export interface Pack {
48
+ file: string;
49
+ doc: ProfileDocument;
50
+ }
51
+
52
+ /** An Ajv validate function for the profile schema. */
53
+ export interface SchemaValidator {
54
+ (doc: unknown): boolean;
55
+ errors?: { instancePath: string; message?: string; params?: Record<string, unknown> }[] | null;
56
+ }
57
+
58
+ export interface ResolveOptions {
59
+ /** Candid source of the service (exactly one of `did`, `envelope` and `abi`). */
60
+ did?: string;
61
+ /** The service's candid-core ContractEnvelope. */
62
+ envelope?: unknown;
63
+ /** An EVM contract's ABI: human-readable fragments or JSON ABI entries (section 15). */
64
+ abi?: readonly (string | Record<string, unknown>)[];
65
+ didLabel?: string;
66
+ /** Applied as given; a pack here must match. */
67
+ profiles?: { doc: unknown; label?: string }[];
68
+ /** Tried by Candid subtyping (an EVM pack by selector); applied only if they match. */
69
+ packs?: { doc: unknown; label?: string }[];
70
+ validateSchema: SchemaValidator;
71
+ }
72
+
73
+ export interface ResolveResult {
74
+ ok: boolean;
75
+ /** null when the service does not compile (or the envelope cannot be read). */
76
+ resolved: ResolvedForm | null;
77
+ diagnostics: Diagnostic[];
78
+ profiles: ProfileStatus[];
79
+ }
80
+
81
+ export interface ValidateResult {
82
+ ok: boolean;
83
+ profile_id?: string;
84
+ interface_id?: string;
85
+ methods?: number;
86
+ annotations?: number;
87
+ diagnostics: Diagnostic[];
88
+ resolved?: ResolvedForm;
89
+ }
90
+
91
+ import type { Address, PathSegment } from "./address.mjs";
92
+ export type { Address, PathSegment } from "./address.mjs";
93
+
94
+ /** A node of the candid-core Contract graph (`kind` is primitive, opt, vec, record, variant, func, service, ...). */
95
+ export interface ContractNode {
96
+ kind: string;
97
+ primitive?: string;
98
+ inner?: number;
99
+ fields?: { id: number; type: number }[];
100
+ [key: string]: unknown;
101
+ }
102
+
103
+ /** A compiled interface: the Contract graph with the lookups the resolver uses. */
104
+ export declare class Iface {
105
+ constructor(envelope: unknown, label?: string);
106
+ label: string;
107
+ /** Method name -> mode and the type refs of its argument and result tuples. */
108
+ methods: Map<string, { mode: string; args: number[]; results: number[] }>;
109
+ /** Declaration name -> type ref. */
110
+ decls: Map<string, number>;
111
+ get interfaceId(): string | null;
112
+ node(ref: number): ContractNode;
113
+ unwrapOpt(ref: number): { ref: number; optional: boolean };
114
+ fieldName(container: number, id: number): string;
115
+ isBlob(ref: number): boolean;
116
+ isAccount(ref: number): boolean;
117
+ /** `nat64`, `opt blob`, `record`, ... */
118
+ describeType(ref: number): string;
119
+ }
120
+
121
+ /** One value position of a method side, as `positions` yields it. */
122
+ export interface Position {
123
+ ref: number;
124
+ parent: { container: number; kind: string; id?: number } | null;
125
+ key: string;
126
+ /** The position's address, `send(0).amount.e8s` or `balance->(0)`. */
127
+ display: string;
128
+ /** The walk stopped here (a cycle or the depth bound). */
129
+ cyclic: boolean;
130
+ }
131
+ export declare class TagIndex {
132
+ [key: string]: unknown;
133
+ }
134
+ export declare class LayeredTags {
135
+ [key: string]: unknown;
136
+ }
137
+ export declare class SubtypeChecker {
138
+ [key: string]: unknown;
139
+ }
140
+
141
+ /** The profile schema (a copy of spec/profile-v0.schema.json). */
142
+ export const PROFILE_SCHEMA: Record<string, unknown>;
143
+ /** The standard packs (copies of packs/*.profile.json), sorted by file name. */
144
+ export const STANDARD_PACKS: readonly Pack[];
145
+ export const LAYERS: readonly ["author", "third_party", "pack", "inferred"];
146
+
147
+ /** Initialize the cli's wasm compiler; optional, for a bundler that moves the wasm file. */
148
+ export function initCompiler(input?: BufferSource | URL | string | Response): Promise<void>;
149
+ /**
150
+ * The validate function for the profile schema. The shipped schema (the default, or a document equal to it) is
151
+ * compiled ahead of time and needs no CSP 'unsafe-eval'; any other document is compiled by ajv with `new Function`.
152
+ */
153
+ export function createSchemaValidator(doc?: Record<string, unknown>): Promise<SchemaValidator>;
154
+ export function schemaDiagnostics(validateSchema: SchemaValidator): Diagnostic[];
155
+ export function resolveService(opts: ResolveOptions): Promise<ResolveResult>;
156
+ export function validateProfile(doc: unknown, opts: { validateSchema: SchemaValidator; did?: string | null; didLabel?: string; resolved?: boolean }): Promise<ValidateResult>;
157
+
158
+ export function idlHash(label: string): number;
159
+ /** RFC 8785 (JCS) canonical JSON. */
160
+ export function jcs(value: unknown): string;
161
+ /** `candid-profile:v0:sha256:<hex>` over the JCS form of the document. */
162
+ export function profileId(doc: unknown): string;
163
+ export function parseAddress(at: string): Address | null;
164
+ export function parseRelPath(rel: string): { index: number | null; segs: PathSegment[] } | null;
165
+
166
+ export function compileInterface(source: string, label?: string): Promise<{ iface: Iface } | { failure: { code: string; message: string }[] }>;
167
+ export function interfaceFromEnvelope(envelope: unknown, label?: string): { iface: Iface } | { failure: { code: string; message: string }[] };
168
+ 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 }[] }>;
169
+ export function matchService(canister: Iface, fragment: Iface, opts?: Record<string, unknown>): { ok: boolean; reasons: { at: string; message: string }[]; relaxed: unknown };
170
+ export function typeSubtype(S: Iface, s: unknown, T: Iface, t: unknown, opts?: Record<string, unknown>): unknown;
171
+ export function positions(iface: Iface, methodName: string, side: "args" | "results", maxDepth?: number): Generator<Position>;
172
+ export function analyzeProfiles(iface: Iface, items: unknown[], opts?: { attribute?: boolean }): { diagnostics: Diagnostic[]; layered: LayeredTags; ordered: unknown[]; stale: Set<unknown> };
173
+ export function resolvedForm(iface: Iface, profiles: unknown[], tags: LayeredTags, diagnostics: Diagnostic[]): ResolvedForm;
174
+ export function buildTree(iface: Iface, methodName: string, side: "args" | "results", tags: LayeredTags): unknown;
175
+
176
+ /** The doc attributes version `profileFromDocAttributes` reads (candid-core `docs/doc-attributes.md`). */
177
+ export const DOC_ATTRIBUTES_VERSION: 1;
178
+ /** A diagnostic about a doc comment: `at` is the item's address once it has one, else its SourceInfo path. */
179
+ export interface DocDiagnostic extends Diagnostic {
180
+ /** The documented item's SourceInfo occurrence path (`actor.methods[2]`, `type:Tokens.fields[0]`). */
181
+ path?: string;
182
+ /** The source file the comment is in, as candid-core names it (`memory:/service.did`). */
183
+ source?: string;
184
+ }
185
+ /**
186
+ * An author canister profile from the tags written as doc comments in a `.did` (spec section 9). `response` is what
187
+ * `didToDocAttributes(sources)` of `@candid-core/cli` returns; a failed response is passed through as diagnostics.
188
+ * `doc` is null when the comments hold no tag. The profile still goes through `resolveService`, which checks the tags.
189
+ */
190
+ export function profileFromDocAttributes(
191
+ response: unknown,
192
+ opts?: { name?: string; origin?: "author" | "third_party"; language?: string; canisterId?: string },
193
+ ): { ok: boolean; doc: ProfileDocument | null; diagnostics: DocDiagnostic[] };
194
+
195
+ /** The custom section a canister serves its profile in (spec section 8.1): `"icp:public candid:profile"`. */
196
+ export const SERVED_SECTION: "icp:public candid:profile";
197
+ /** The metadata name a consumer reads through `read_state`: `/canister/<id>/metadata/candid:profile`. */
198
+ export const SERVED_METADATA: "candid:profile";
199
+ /** The largest served profile `readServedProfile` reads, in bytes (1 MiB, the platform's custom section limit). */
200
+ export const SERVED_MAX_BYTES: number;
201
+ /**
202
+ * A profile its canister serves (spec section 8.1), from the bytes of the canister's `candid:profile` metadata. Refused
203
+ * 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
204
+ * `author`, or its `binds.canister_id` names another canister. The profile still goes through `resolveService`, which
205
+ * checks the schema, the binding (against the same canister's `candid:service`) and the tags.
206
+ */
207
+ export function readServedProfile(
208
+ bytes: Uint8Array,
209
+ /** `canisterId`: the canister that served it; null only to check a profile before its module is installed. */
210
+ opts: { canisterId: string | null; label?: string },
211
+ ): { ok: boolean; doc: ProfileDocument | null; diagnostics: Diagnostic[] };
212
+