@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,271 @@
1
+ /**
2
+ * Structural Candid subtyping over candid-core Contract graphs, with the pack-matching relaxation of D-019.
3
+ *
4
+ * No existing checker could be reused (see README.md, "Subtyping"): @dfinity/didc 0.0.4 exports only
5
+ * getServiceMethods/encode/decode, and @candid-core/cli 0.2.0 exported only didToContract/didToModule/init (surveyed 2026-10-09; 0.3.0, pinned since D-037, adds
6
+ * projectDid and checkCompatible, not checked here).
7
+ * candid-core's service-level check (b3hr4d/candid-core#247) is an open issue, not code. So this is our own
8
+ * implementation of the rules in the Candid specification, section "Upgrading and Subtyping > Rules"
9
+ * (https://github.com/dfinity/candid/blob/c7659ca/spec/Candid.md), checked against the Rust crate
10
+ * candid 0.10.37 (src/types/subtype.rs).
11
+ *
12
+ * `S <: T` ("S is a subtype of T") is decided between two Contract graphs (`Iface`), because a canister and a pack
13
+ * fragment are compiled separately. Rules implemented, with the spec subsection each comes from:
14
+ *
15
+ * Primitive types t <: t; nat <: int; any <: reserved; empty <: any.
16
+ * Vectors vec S <: vec T if S <: T. (`blob` is `vec nat8`, so it needs no rule of its own.)
17
+ * Options opt S <: opt T if S <: T;
18
+ * null <: opt T;
19
+ * S <: opt T if S <: T and T is not null, reserved or opt (the "constituent" rule).
20
+ * Optional, off by default (`lossyOpt`): the two "unusual" rules that make opt S <: opt T hold
21
+ * even when S and T are unrelated (the value decodes as null). The spec says they exist only
22
+ * to keep subtyping transitive, and that tools should warn when they are used. For pack matching
23
+ * they would let `fee : opt text` match a pack that says `fee : opt nat`, so they are rejected.
24
+ * Records record S <: record T if every field of T is in S with S_f <: T_f, or is missing from S and
25
+ * has an optional type (null <: T_f, that is opt, null or reserved).
26
+ * Extra fields in S are ignored.
27
+ * Variants variant S <: variant T if every case of S is in T with S_c <: T_c. (S may have fewer cases.)
28
+ * Functions func(A) -> (R) m <: func(A') -> (R') m' if the modes are equal, tuple(A') <: tuple(A)
29
+ * (arguments contravariant) and tuple(R) <: tuple(R') (results covariant);
30
+ * tuples follow the record rule with positional ids 0, 1, ...
31
+ * Services service S <: service T if every method of T is in S and its function type is a subtype.
32
+ * Extra methods in S are ignored. (Init args of a service class are ignored.)
33
+ * Recursion Coinductive: a pair under test is assumed to hold when met again.
34
+ *
35
+ * Not implemented, on purpose: the Rust crate's catch-all that accepts `S <: opt T` for any S with only a warning
36
+ * (not a rule of the spec); `service <: principal` (not in the spec revision above).
37
+ *
38
+ * D-019 relaxation (`mode: "relaxed"`, spec profile-v0 section 7): when matching a pack, a variant reached through a
39
+ * method *result* may have cases that the pack fragment lacks. Reached through means descending from the result by
40
+ * record fields, opt, vec and variant payloads only, with covariant polarity; entering any func or service type
41
+ * (where polarity can flip) resets to strict. Argument positions are always strict.
42
+ *
43
+ * Bounded work (profile-v0 section 11.6): depth and step limits fail closed to "not a subtype".
44
+ */
45
+
46
+ const DEFAULT_MAX_DEPTH = 256;
47
+ const DEFAULT_MAX_STEPS = 2_000_000;
48
+
49
+ const isNullLike = (iface, ref) => {
50
+ const n = iface.types[ref];
51
+ return n.kind === "opt" || (n.kind === "primitive" && (n.primitive === "null" || n.primitive === "reserved"));
52
+ };
53
+
54
+ /** A short rendering of a type for messages. Bounded in depth and width. */
55
+ export function show(iface, ref, depth = 2, seen = new Set()) {
56
+ const n = iface.types[ref];
57
+ if (n.kind === "primitive") return n.primitive;
58
+ if (iface.isBlob(ref)) return "blob";
59
+ if (n.kind === "opt") return `opt ${show(iface, n.inner, depth, seen)}`;
60
+ if (n.kind === "vec") return `vec ${show(iface, n.inner, depth, seen)}`;
61
+ if (depth <= 0 || seen.has(ref)) return n.kind;
62
+ const next = new Set(seen).add(ref);
63
+ if (n.kind === "record" || n.kind === "variant") {
64
+ const parts = n.fields.slice(0, 4).map((f) => {
65
+ const label = iface.fieldName(ref, f.id);
66
+ const inner = iface.types[f.type];
67
+ return n.kind === "variant" && inner.kind === "primitive" && inner.primitive === "null" ? label : `${label} : ${show(iface, f.type, depth - 1, next)}`;
68
+ });
69
+ return `${n.kind} { ${parts.join("; ")}${n.fields.length > 4 ? "; ..." : ""} }`;
70
+ }
71
+ if (n.kind === "func") return `func (${n.args.map((a) => show(iface, a, depth - 1, next)).join(", ")}) -> (${n.results.map((a) => show(iface, a, depth - 1, next)).join(", ")}) ${n.mode}`;
72
+ return n.kind;
73
+ }
74
+
75
+ export class SubtypeChecker {
76
+ /**
77
+ * @param opts.lossyOpt apply the spec's two "unusual" opt rules (default false)
78
+ * @param opts.maxDepth nesting limit, fails closed (default 256)
79
+ * @param opts.maxSteps pair-evaluation budget, fails closed (default 2,000,000)
80
+ */
81
+ constructor(opts = {}) {
82
+ this.lossyOpt = Boolean(opts.lossyOpt);
83
+ this.maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
84
+ this.maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
85
+ this.gamma = new Set();
86
+ this.relaxed = [];
87
+ this.steps = 0;
88
+ this.depth = 0;
89
+ this.ids = new Map();
90
+ }
91
+
92
+ #id(iface) {
93
+ if (!this.ids.has(iface)) this.ids.set(iface, this.ids.size);
94
+ return this.ids.get(iface);
95
+ }
96
+
97
+ /** Start a new top-level query: assumptions from an earlier (possibly failed) query must not leak. */
98
+ fresh() {
99
+ this.gamma = new Set();
100
+ this.depth = 0;
101
+ }
102
+
103
+ /**
104
+ * Decide S <: T for `s` in iface S and `t` in iface T.
105
+ * @param mode "strict" or "relaxed" (D-019, variants only)
106
+ * @param path address-like path segments, for diagnostics
107
+ * @returns null when it holds, else { path, message } for the first failure
108
+ */
109
+ sub(S, s, T, t, mode = "strict", path = []) {
110
+ if (S === T && s === t) return null;
111
+ const key = `${this.#id(S)}:${s}<:${this.#id(T)}:${t}|${mode}`;
112
+ if (this.gamma.has(key)) return null; // coinductive assumption
113
+ if (++this.steps > this.maxSteps) return { path: [...path], message: "step budget exceeded; treated as not a subtype" };
114
+ if (this.depth >= this.maxDepth) return { path: [...path], message: "type nesting exceeds the depth limit; treated as not a subtype" };
115
+ this.gamma.add(key);
116
+ this.depth++;
117
+ const err = this.#rules(S, s, T, t, mode, path);
118
+ this.depth--;
119
+ if (err) this.gamma.delete(key);
120
+ return err;
121
+ }
122
+
123
+ #rules(S, s, T, t, mode, path) {
124
+ const ns = S.types[s];
125
+ const nt = T.types[t];
126
+ const fail = (message) => ({ path: [...path], message });
127
+ const mismatch = () => fail(`${show(S, s)} (${S.label}) is not a subtype of ${show(T, t)} (${T.label})`);
128
+ const sp = ns.kind === "primitive" ? ns.primitive : null;
129
+ const tp = nt.kind === "primitive" ? nt.primitive : null;
130
+
131
+ if (tp === "reserved") return null; // <datatype> <: reserved
132
+ if (sp === "empty") return null; // empty <: <datatype>
133
+
134
+ if (nt.kind === "opt") {
135
+ if (ns.kind === "opt") {
136
+ const e = this.sub(S, ns.inner, T, nt.inner, mode, path); // opt S <: opt T if S <: T
137
+ if (!e) return null;
138
+ return this.lossyOpt ? null : e; // the two unusual rules, only when asked for
139
+ }
140
+ if (sp === "null") return null; // null <: opt T
141
+ if (isNullLike(T, nt.inner)) return mismatch(); // S <: opt T needs not (null <: T)
142
+ return this.sub(S, s, T, nt.inner, mode, path); // S <: opt T if S <: T
143
+ }
144
+ if (ns.kind === "opt") return mismatch(); // opt S is never a subtype of a non-opt T (T = reserved handled above)
145
+
146
+ if (sp !== null || tp !== null) {
147
+ if (sp !== null && tp !== null && (sp === tp || (sp === "nat" && tp === "int"))) return null;
148
+ return mismatch();
149
+ }
150
+ if (ns.kind !== nt.kind) return mismatch();
151
+
152
+ switch (ns.kind) {
153
+ case "vec":
154
+ return this.sub(S, ns.inner, T, nt.inner, mode, [...path, "[]"]);
155
+ case "record": {
156
+ const have = new Map(ns.fields.map((f) => [f.id, f.type]));
157
+ for (const f of nt.fields) {
158
+ const label = T.fieldName(t, f.id);
159
+ if (have.has(f.id)) {
160
+ const e = this.sub(S, have.get(f.id), T, f.type, mode, [...path, `.${label}`]);
161
+ if (e) return e;
162
+ } else if (!isNullLike(T, f.type)) {
163
+ return fail(`field "${label}" (${show(T, f.type)}) is required by the ${T.label} but missing from the ${S.label}`);
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ case "variant": {
169
+ const have = new Map(nt.fields.map((f) => [f.id, f.type]));
170
+ const extra = [];
171
+ for (const f of ns.fields) {
172
+ const label = S.fieldName(s, f.id);
173
+ if (!have.has(f.id)) {
174
+ if (mode === "relaxed") { extra.push(label); continue; } // D-019
175
+ return fail(`variant case "${label}" is in the ${S.label} but not in the ${T.label}`);
176
+ }
177
+ const e = this.sub(S, f.type, T, have.get(f.id), mode, [...path, `.${label}`]);
178
+ if (e) return e;
179
+ }
180
+ if (extra.length) this.relaxed.push({ at: pathString(path), cases: extra.sort() });
181
+ return null;
182
+ }
183
+ case "func": {
184
+ if (ns.mode !== nt.mode) return fail(`function mode is ${ns.mode} in the ${S.label} but ${nt.mode} in the ${T.label}`);
185
+ // Arguments are contravariant: tuple(T args) <: tuple(S args). Calls from a func value cannot use the D-019 relaxation.
186
+ for (let i = 0; i < ns.args.length; i++) {
187
+ if (i < nt.args.length) {
188
+ const e = this.sub(T, nt.args[i], S, ns.args[i], "strict", [...path, `(${i})`]);
189
+ if (e) return e;
190
+ } else if (!isNullLike(S, ns.args[i])) {
191
+ return fail(`argument ${i} (${show(S, ns.args[i])}) is required by the ${S.label} but not supplied by the ${T.label}`);
192
+ }
193
+ }
194
+ // Results are covariant: tuple(S results) <: tuple(T results).
195
+ for (let i = 0; i < nt.results.length; i++) {
196
+ if (i < ns.results.length) {
197
+ const e = this.sub(S, ns.results[i], T, nt.results[i], "strict", [...path, `->(${i})`]);
198
+ if (e) return e;
199
+ } else if (!isNullLike(T, nt.results[i])) {
200
+ return fail(`result ${i} (${show(T, nt.results[i])}) is expected by the ${T.label} but not returned by the ${S.label}`);
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ case "service": {
206
+ const have = new Map(ns.methods.map((m) => [m.name, m.function]));
207
+ for (const m of nt.methods) {
208
+ if (!have.has(m.name)) return fail(`method "${m.name}" is missing from the ${S.label}`);
209
+ const e = this.sub(S, have.get(m.name), T, m.function, "strict", [...path, m.name]);
210
+ if (e) return e;
211
+ }
212
+ return null;
213
+ }
214
+ default:
215
+ return fail(`unsupported type kind ${ns.kind}`);
216
+ }
217
+ }
218
+ }
219
+
220
+ function pathString(path) {
221
+ return path.join("");
222
+ }
223
+
224
+ /** Is type `s` of iface S a subtype of type `t` of iface T? A standalone entry point for tests and tools. */
225
+ export function typeSubtype(S, s, T, t, opts = {}) {
226
+ const c = new SubtypeChecker(opts);
227
+ const mode = opts.resultVariants === "relaxed" ? "relaxed" : "strict";
228
+ const err = c.sub(S, s, T, t, mode);
229
+ return err ? { ok: false, reasons: [{ at: pathString(err.path), message: err.message }], relaxed: [] } : { ok: true, reasons: [], relaxed: c.relaxed };
230
+ }
231
+
232
+ /**
233
+ * Is the service of `canister` a subtype of the service of `fragment`, with the D-019 relaxation on result positions?
234
+ * Both are Iface. Methods of the fragment are checked one by one; every failing method contributes one reason.
235
+ * @param opts.resultVariants "relaxed" (default) applies D-019; "strict" is plain Candid subtyping
236
+ * @param opts.lossyOpt see SubtypeChecker
237
+ * @returns { ok, reasons: [{ at, message }], relaxed: [{ at, cases }] }
238
+ */
239
+ export function matchService(canister, fragment, opts = {}) {
240
+ const resultMode = opts.resultVariants === "strict" ? "strict" : "relaxed";
241
+ const checker = new SubtypeChecker(opts);
242
+ const reasons = [];
243
+ const relaxed = [];
244
+ const note = (at, message) => reasons.push({ at, message });
245
+ const run = (at, fn) => {
246
+ checker.fresh();
247
+ const before = checker.relaxed.length;
248
+ const e = fn();
249
+ if (e) {
250
+ note(pathString(e.path) || at, e.message);
251
+ checker.relaxed.length = before; // a failed position contributes no relaxation
252
+ }
253
+ };
254
+ for (const [name, fm] of fragment.methods) {
255
+ const cm = canister.methods.get(name);
256
+ if (!cm) { note(name, `method "${name}" is missing from the ${canister.label}`); continue; }
257
+ if (cm.mode !== fm.mode) { note(name, `method "${name}" is ${cm.mode} in the ${canister.label} but ${fm.mode} in the ${fragment.label}`); continue; }
258
+ // Arguments, contravariant (strict): tuple(fragment args) <: tuple(canister args).
259
+ for (let i = 0; i < cm.args.length; i++) {
260
+ if (i < fm.args.length) run(`${name}(${i})`, () => checker.sub(fragment, fm.args[i], canister, cm.args[i], "strict", [`${name}(${i})`]));
261
+ else if (!isNullLike(canister, cm.args[i])) note(`${name}(${i})`, `argument ${i} (${show(canister, cm.args[i])}) is required by the ${canister.label} but not supplied by the ${fragment.label}`);
262
+ }
263
+ // Results, covariant (D-019 relaxation): tuple(canister results) <: tuple(fragment results).
264
+ for (let i = 0; i < fm.results.length; i++) {
265
+ if (i < cm.results.length) run(`${name}->(${i})`, () => checker.sub(canister, cm.results[i], fragment, fm.results[i], resultMode, [`${name}->(${i})`]));
266
+ else if (!isNullLike(fragment, fm.results[i])) note(`${name}->(${i})`, `result ${i} (${show(fragment, fm.results[i])}) is expected by the ${fragment.label} but not returned by the ${canister.label}`);
267
+ }
268
+ }
269
+ relaxed.push(...checker.relaxed);
270
+ return { ok: reasons.length === 0, reasons, relaxed };
271
+ }
package/src/tags.mjs ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tag storage and position enumeration. TagIndex, `positions` are moved verbatim from scripts/validate.mjs;
3
+ * LayeredTags is the multi-profile merge of spec section 8.
4
+ */
5
+ import { edgeKey } from "./address.mjs";
6
+
7
+ // ---------------------------------------------------------------- tag index
8
+
9
+ /** Tags reachable at a position: node tags < edge tags < method-position tags (spec §8). */
10
+ export class TagIndex {
11
+ constructor() { this.byKey = new Map(); }
12
+ add(key, tag, entry) {
13
+ if (!this.byKey.has(key)) this.byKey.set(key, {});
14
+ this.byKey.get(key)[tag] = entry;
15
+ }
16
+ at({ key, parent, ref }, iface) {
17
+ const nodeTags = this.byKey.get(`n:${iface.unwrapOpt(ref).ref}`);
18
+ const edgeTags = parent ? this.byKey.get(edgeKey(parent)) : undefined;
19
+ const posTags = this.byKey.get(key);
20
+ if (!nodeTags && !edgeTags && !posTags) return undefined;
21
+ return { ...(nodeTags ?? {}), ...(edgeTags ?? {}), ...(posTags ?? {}) };
22
+ }
23
+ }
24
+
25
+ /** Every value position of a method side, with canonical key, parent edge and display address. */
26
+ export function* positions(iface, methodName, side, maxDepth = 8) {
27
+ const m = iface.methods.get(methodName);
28
+ const tuple = side === "args" ? m.args : m.results;
29
+ const sideKey = side === "args" ? "arg" : "res";
30
+ function* rec(ref, parent, ids, display, index, depth, seen) {
31
+ const key = `m:${methodName}|${sideKey}|${index}|${ids.join("/")}`;
32
+ const { ref: r } = iface.unwrapOpt(ref);
33
+ const cyclic = seen.has(r) || depth >= maxDepth;
34
+ yield { ref, parent, key, display, cyclic };
35
+ if (cyclic) return;
36
+ const n = iface.node(r);
37
+ const next = new Set(seen).add(r);
38
+ if (n.kind === "record" || n.kind === "variant") {
39
+ for (const f of n.fields) {
40
+ yield* rec(f.type, { container: r, kind: n.kind, id: f.id }, [...ids, String(f.id)], `${display}.${iface.fieldName(r, f.id)}`, index, depth + 1, next);
41
+ }
42
+ } else if (n.kind === "vec" && !iface.isBlob(r)) {
43
+ yield* rec(n.inner, { container: r, kind: "vec" }, [...ids, "[]"], `${display}[]`, index, depth + 1, next);
44
+ }
45
+ }
46
+ for (let i = 0; i < tuple.length; i++) {
47
+ yield* rec(tuple[i], null, [], `${methodName}${side === "args" ? "" : "->"}(${i})`, i, 0, new Set());
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Spec section 8, merging. One TagIndex per layer, highest precedence first (author, third_party, pack, inferred).
53
+ * - Within a layer: a method-position tag beats a `type:` edge tag beats a whole-declaration tag (TagIndex.at).
54
+ * - Across layers: the first layer that has the tag at the position wins; the same tag in lower layers is overridden.
55
+ * Merging is per tag per position: other tags on the position are kept.
56
+ */
57
+ export class LayeredTags {
58
+ /** @param layers Array<{ name: string, index: TagIndex }> */
59
+ constructor(layers) { this.layers = layers; }
60
+
61
+ static #first(parts) {
62
+ let out;
63
+ for (const t of parts) {
64
+ if (!t) continue;
65
+ out ??= {};
66
+ for (const k of Object.keys(t)) if (!(k in out)) out[k] = t[k];
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** Effective tags at a value position (undefined when none). */
72
+ at(pos, iface) { return LayeredTags.#first(this.layers.map((l) => l.index.at(pos, iface))); }
73
+
74
+ /** Effective tags on a method (always an object). */
75
+ methodTags(name) { return LayeredTags.#first(this.layers.map((l) => l.index.byKey.get(`m:${name}`))) ?? {}; }
76
+
77
+ /** Every candidate per tag at a value position, highest layer first: { tag -> [entry, ...] }. */
78
+ candidates(pos, iface) { return this.#collect(this.layers.map((l) => l.index.at(pos, iface))); }
79
+
80
+ candidatesAtMethod(name) { return this.#collect(this.layers.map((l) => l.index.byKey.get(`m:${name}`))); }
81
+
82
+ #collect(parts) {
83
+ const out = {};
84
+ for (const t of parts) {
85
+ if (!t) continue;
86
+ for (const [k, v] of Object.entries(t)) (out[k] ??= []).push(v);
87
+ }
88
+ return out;
89
+ }
90
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,16 @@
1
+ import { sha256Hex } from "./sha256.mjs";
2
+
3
+ // The pure label helpers live in idl.mjs, so that the address entry loads nothing else.
4
+ export { U32_MAX, idlHash } from "./idl.mjs";
5
+
6
+ /** RFC 8785 (JCS) canonical JSON for the value shapes profiles use. */
7
+ export function jcs(v) {
8
+ if (v === null || typeof v !== "object") return JSON.stringify(v);
9
+ if (Array.isArray(v)) return `[${v.map(jcs).join(",")}]`;
10
+ const keys = Object.keys(v).sort();
11
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${jcs(v[k])}`).join(",")}}`;
12
+ }
13
+
14
+ export function profileId(doc) {
15
+ return `candid-profile:v0:sha256:${sha256Hex(jcs(doc))}`;
16
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Single-profile validation: the behavior of the original scripts/validate.mjs, as a library call.
3
+ * A pack is checked against its own `binds.requires` fragment; a canister profile against the given interface.
4
+ */
5
+ import { profileId } from "./util.mjs";
6
+ import { compileInterface } from "./compile.mjs";
7
+ import { abiInterface, checkDeployments } from "./abi-iface.mjs";
8
+ import { analyzeProfiles } from "./analyze.mjs";
9
+ import { resolvedForm } from "./resolved.mjs";
10
+ import { schemaDiagnostics } from "./schema-core.mjs";
11
+
12
+ /**
13
+ * @param doc the parsed profile
14
+ * @param opts.validateSchema from createSchemaValidator()
15
+ * @param opts.did Candid source text of the interface (canister profiles; packs use binds.requires)
16
+ * @param opts.didLabel how E_CANDID names the .did (a path)
17
+ * @param opts.resolved also return the resolved form
18
+ * @returns { ok, profile_id?, interface_id?, methods?, annotations?, diagnostics, resolved? }
19
+ */
20
+ export async function validateProfile(doc, opts) {
21
+ const diagnostics = [];
22
+ const diag = (severity, code, at, message) => diagnostics.push({ severity, code, at, message });
23
+
24
+ if (!opts.validateSchema(doc)) {
25
+ diagnostics.push(...schemaDiagnostics(opts.validateSchema));
26
+ return { ok: false, diagnostics };
27
+ }
28
+ const pid = profileId(doc);
29
+
30
+ // An EVM contract profile or pack carries its own ABI (spec section 15): it is checked against that.
31
+ if (doc.kind === "contract" || (doc.kind === "pack" && doc.binds.abi !== undefined)) {
32
+ const compiled = abiInterface(doc.binds.abi, "binds.abi");
33
+ if (compiled.failure) {
34
+ for (const d of compiled.failure) diag("error", "E_ABI", d.at, d.message);
35
+ return { ok: false, profile_id: pid, diagnostics };
36
+ }
37
+ if (doc.kind === "contract") checkDeployments(doc, diag);
38
+ return finish(doc, pid, compiled.iface, false, diagnostics, opts);
39
+ }
40
+
41
+ let didSource;
42
+ if (doc.kind === "pack") didSource = doc.binds.requires;
43
+ else if (opts.did !== undefined && opts.did !== null) didSource = opts.did;
44
+ else {
45
+ diag("warning", "W_NO_INTERFACE", "", "canister profile without --did: semantic checks skipped");
46
+ return { ok: true, profile_id: pid, diagnostics };
47
+ }
48
+ const compiled = await compileInterface(didSource);
49
+ if (compiled.failure) {
50
+ for (const d of compiled.failure) diag("error", "E_CANDID", doc.kind === "pack" ? "binds.requires" : opts.didLabel, `${d.code}: ${d.message}`);
51
+ return { ok: false, profile_id: pid, diagnostics };
52
+ }
53
+ const { iface } = compiled;
54
+ const rebinding = doc.kind === "canister" && doc.binds.interface_id !== iface.interfaceId;
55
+ return finish(doc, pid, iface, rebinding, diagnostics, opts);
56
+ }
57
+
58
+ function finish(doc, pid, iface, rebinding, earlier, opts) {
59
+ const analysis = analyzeProfiles(iface, [{ doc, pid, rebinding, apply: false }], { attribute: false });
60
+ const diagnostics = [...earlier, ...analysis.diagnostics];
61
+ const ok = !diagnostics.some((d) => d.severity === "error");
62
+ const result = { ok, profile_id: pid, interface_id: iface.interfaceId, methods: iface.methods.size, annotations: doc.annotations.length, diagnostics };
63
+ if (opts.resolved) result.resolved = resolvedForm(iface, [{ doc, pid }], analysis.layered, diagnostics);
64
+ return result;
65
+ }