@meantjs/profile 0.0.0-stage → 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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Resolve a service with any number of profiles (spec sections 7, 8 and 10).
3
+ *
4
+ * explicit profiles applied as given: canister profiles are checked for binding (rebound/stale), packs must match;
5
+ * candidate packs tried by Candid subtyping against `binds.requires`; those that match are applied, the rest
6
+ * are reported and left out;
7
+ * layers author > third_party > pack > inferred, merged per tag per position (W_OVERRIDDEN, E_CONFLICT).
8
+ *
9
+ * New diagnostic codes introduced here (additive; proposed in docs/proposals/w1-resolved-form-additions.md):
10
+ * I_PACK_MATCHED, I_PACK_NOT_MATCHED, E_PACK_NOT_MATCHED, I_PACK_ANNOTATION_SKIPPED; and an optional `profile`
11
+ * field on diagnostics that come from a profile. E_ENVELOPE (an `envelope` input that is not a readable candid-core
12
+ * ContractEnvelope) is proposed in docs/proposals/w1-envelope-input.md.
13
+ */
14
+ import { profileId } from "./util.mjs";
15
+ import { compileInterface, interfaceFromEnvelope } from "./compile.mjs";
16
+ import { analyzeProfiles } from "./analyze.mjs";
17
+ import { matchPack } from "./pack-match.mjs";
18
+ import { resolvedForm } from "./resolved.mjs";
19
+ import { schemaDiagnostics } from "./schema-core.mjs";
20
+
21
+ const describeReasons = (reasons) => {
22
+ const shown = reasons.slice(0, 3).map((r) => `${r.at}: ${r.message}`).join("; ");
23
+ return reasons.length > 3 ? `${shown}; and ${reasons.length - 3} more` : shown;
24
+ };
25
+
26
+ /**
27
+ * @param opts.did Candid source of the service, compiled here; or
28
+ * @param opts.envelope the service's candid-core ContractEnvelope, already compiled (exactly one of the two)
29
+ * @param opts.didLabel how E_CANDID (or E_ENVELOPE) names the input
30
+ * @param opts.profiles [{ doc, label? }] explicit profiles, applied or rejected
31
+ * @param opts.packs [{ doc, label? }] candidate packs, applied only if they match
32
+ * @param opts.validateSchema from createSchemaValidator()
33
+ * @returns { ok, resolved, diagnostics, profiles } `resolved` is null when the service does not compile (or the
34
+ * envelope cannot be read).
35
+ * `profiles[i]` = { name, origin, kind, profile_id, status, explicit } with status one of
36
+ * applied | rebound | stale | not_matched | invalid.
37
+ */
38
+ export async function resolveService(opts) {
39
+ const fromEnvelope = opts.envelope !== undefined;
40
+ if (fromEnvelope === (opts.did !== undefined)) throw new TypeError("resolveService: give exactly one of `did` and `envelope`");
41
+ if (!fromEnvelope) return resolveCompiled(opts, false);
42
+ // interfaceFromEnvelope checks the graph before anything walks it; anything it still misses in someone else's
43
+ // document is reported against the envelope rather than thrown.
44
+ try {
45
+ return await resolveCompiled(opts, true);
46
+ } catch (e) {
47
+ const at = opts.didLabel ?? "envelope";
48
+ return { ok: false, resolved: null, diagnostics: [{ severity: "error", code: "E_ENVELOPE", at, message: `the envelope's graph could not be resolved: ${e?.message ?? e}` }], profiles: [] };
49
+ }
50
+ }
51
+
52
+ async function resolveCompiled(opts, fromEnvelope) {
53
+ const diagnostics = [];
54
+ const statuses = [];
55
+ const compiled = fromEnvelope ? interfaceFromEnvelope(opts.envelope, "canister") : await compileInterface(opts.did, "canister");
56
+ if (compiled.failure) {
57
+ const [code, at] = fromEnvelope ? ["E_ENVELOPE", opts.didLabel ?? "envelope"] : ["E_CANDID", opts.didLabel ?? "service.did"];
58
+ for (const d of compiled.failure) diagnostics.push({ severity: "error", code, at, message: `${d.code}: ${d.message}` });
59
+ return { ok: false, resolved: null, diagnostics, profiles: statuses };
60
+ }
61
+ const { iface } = compiled;
62
+
63
+ const candidates = [
64
+ ...(opts.profiles ?? []).map((p, i) => ({ ...p, explicit: true, label: p.label ?? `profile #${i + 1}` })),
65
+ ...(opts.packs ?? []).map((p, i) => ({ ...p, explicit: false, label: p.label ?? `pack #${i + 1}` })),
66
+ ];
67
+ const items = [];
68
+ const taken = new Set();
69
+ for (const c of candidates) {
70
+ const name = c.doc?.name ?? c.label;
71
+ const note = (severity, code, at, message) => diagnostics.push({ severity, code, at, message, profile: name });
72
+ if (!opts.validateSchema(c.doc)) {
73
+ for (const d of schemaDiagnostics(opts.validateSchema)) diagnostics.push({ ...d, profile: name });
74
+ statuses.push({ name, origin: c.doc?.origin ?? null, kind: c.doc?.kind ?? null, profile_id: null, status: "invalid", explicit: c.explicit });
75
+ continue;
76
+ }
77
+ const doc = c.doc;
78
+ const pid = profileId(doc);
79
+ if (taken.has(pid)) continue; // the same document given twice (for example explicitly and through the pack search)
80
+ const status = { name: doc.name, origin: doc.origin, kind: doc.kind, profile_id: pid, status: "applied", explicit: c.explicit };
81
+ if (doc.kind === "pack") {
82
+ const m = await matchPack(iface, doc);
83
+ if (m.error) {
84
+ for (const d of m.error) note("error", "E_CANDID", "binds.requires", `${d.code}: ${d.message}`);
85
+ statuses.push({ ...status, status: "invalid" });
86
+ continue;
87
+ }
88
+ if (!m.matched) {
89
+ const why = describeReasons(m.reasons);
90
+ if (c.explicit) note("error", "E_PACK_NOT_MATCHED", doc.name, `the service is not a subtype of the pack's binds.requires: ${why}`);
91
+ else note("info", "I_PACK_NOT_MATCHED", doc.name, `pack does not apply: ${why}`);
92
+ statuses.push({ ...status, status: "not_matched" });
93
+ continue;
94
+ }
95
+ const wider = m.relaxed.map((r) => `${r.at} has extra cases ${r.cases.join(", ")}`);
96
+ note("info", "I_PACK_MATCHED", doc.name, `the service is a subtype of the pack's binds.requires${wider.length ? ` (result variants wider than the pack, D-019: ${wider.join("; ")})` : ""}`);
97
+ items.push({ doc, pid, rebinding: false, apply: true, status });
98
+ } else {
99
+ items.push({ doc, pid, rebinding: doc.binds.interface_id !== iface.interfaceId, apply: false, status });
100
+ }
101
+ taken.add(pid);
102
+ }
103
+
104
+ const analysis = analyzeProfiles(iface, items, { attribute: true });
105
+ diagnostics.push(...analysis.diagnostics);
106
+ for (const item of analysis.ordered) {
107
+ item.status.status = analysis.stale.has(item) ? "stale" : item.rebinding ? "rebound" : "applied";
108
+ }
109
+ // Report profiles in precedence order, then the ones that were left out.
110
+ const applied = analysis.ordered.map((i) => i.status);
111
+ const left = statuses.splice(0);
112
+ const resolved = resolvedForm(iface, analysis.ordered, analysis.layered, diagnostics);
113
+ return { ok: !diagnostics.some((d) => d.severity === "error"), resolved, diagnostics, profiles: [...applied, ...left] };
114
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The resolved form (spec section 10): one tree per method with the effective tags attached to every position.
3
+ * buildTree is moved verbatim from scripts/validate.mjs. The shape is a shared contract (fixtures/*.resolved.json).
4
+ */
5
+ import { positions } from "./tags.mjs";
6
+
7
+ export function buildTree(iface, methodName, side, tags) {
8
+ const out = [];
9
+ const stack = []; // [node object, depth]
10
+ for (const pos of positions(iface, methodName, side)) {
11
+ const { ref: r, optional } = iface.unwrapOpt(pos.ref);
12
+ const n = iface.node(r);
13
+ const node = { candid: n.kind === "primitive" ? n.primitive : iface.isBlob(r) ? "blob" : n.kind };
14
+ if (optional) node.optional = true;
15
+ const t = tags.at(pos, iface);
16
+ if (t) node.tags = t;
17
+ if (pos.cyclic) node.recursive = true;
18
+ const depth = pos.key.split("|")[3] === "" ? 0 : pos.key.split("|")[3].split("/").length;
19
+ while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop();
20
+ if (depth === 0) out.push(node);
21
+ else {
22
+ const parentNode = stack[stack.length - 1].node;
23
+ const last = pos.display.endsWith("[]") ? null : pos.display.slice(pos.display.lastIndexOf(".") + 1);
24
+ if (last === null) parentNode.element = node;
25
+ else {
26
+ const bucket = pos.parent.kind === "record" ? "fields" : "cases";
27
+ parentNode[bucket] ??= {};
28
+ parentNode[bucket][last] = node;
29
+ }
30
+ }
31
+ stack.push({ node, depth });
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * @param iface the interface the tree is built over
38
+ * @param profiles every profile that contributes, highest precedence first: [{ doc, pid }]
39
+ * @param tags a LayeredTags
40
+ */
41
+ export function resolvedForm(iface, profiles, tags, diagnostics) {
42
+ const methods = {};
43
+ for (const [name, m] of [...iface.methods.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
44
+ const mt = { ...tags.methodTags(name) };
45
+ if (!mt.effect) {
46
+ mt.effect = m.mode === "update" || m.mode === "oneway"
47
+ ? { args: { kind: "write", irreversible: true }, origin: "default" }
48
+ : { args: { kind: "read", irreversible: false }, origin: "default" };
49
+ }
50
+ methods[name] = { mode: m.mode, tags: mt, args: buildTree(iface, name, "args", tags), results: buildTree(iface, name, "results", tags) };
51
+ }
52
+ return {
53
+ format: "candid-profile-resolved",
54
+ format_version: 0,
55
+ interface_id: iface.contract.identities.interface ?? null,
56
+ profiles: profiles.map(({ doc, pid }) => ({ name: doc.name, origin: doc.origin, profile_id: pid })),
57
+ methods,
58
+ diagnostics,
59
+ };
60
+ }
@@ -0,0 +1,45 @@
1
+ // The schema checks without the file system: compile a schema document, report its errors. schema.mjs adds reading
2
+ // the schema from a path (Node); the browser entry passes the document the package ships.
3
+ //
4
+ // The schema this package ships is compiled ahead of time (data/profile-v0.validate.mjs, ajv's standalone code,
5
+ // written by scripts/data.mjs), so validating against it generates no code at run time and runs under a
6
+ // Content-Security-Policy without 'unsafe-eval'. Any other schema document is compiled by ajv here, at run time, which
7
+ // uses `new Function`; ajv is loaded only then.
8
+ import shipped from "../data/profile-v0.schema.json" with { type: "json" };
9
+ import { validate as precompiled } from "../data/profile-v0.validate.mjs";
10
+ import { jcs } from "./util.mjs";
11
+
12
+ const SHIPPED_JCS = jcs(shipped);
13
+
14
+ /**
15
+ * Compile a profile schema document once; the result is a validate function with ajv's `errors`. A document equal
16
+ * to the shipped schema (as JSON, whatever its key order) gets the precompiled validator.
17
+ */
18
+ export async function compileSchema(doc) {
19
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) throw new TypeError("createSchemaValidator: expected a path or a schema object");
20
+ if (doc === shipped || jcs(doc) === SHIPPED_JCS) return ownErrors(precompiled);
21
+ const { default: Ajv2020Module } = await import("ajv/dist/2020.js");
22
+ const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
23
+ return new Ajv2020({ allErrors: true, strict: true }).compile(doc);
24
+ }
25
+
26
+ /** A validate function with its own `errors`, as each run-time compile has, over the one shared precompiled one. */
27
+ function ownErrors(validate) {
28
+ const own = (doc) => {
29
+ const ok = validate(doc);
30
+ own.errors = validate.errors;
31
+ return ok;
32
+ };
33
+ own.errors = null;
34
+ return own;
35
+ }
36
+
37
+ /** E_SCHEMA diagnostics for the errors of a failed validate call. */
38
+ export function schemaDiagnostics(validateSchema) {
39
+ return validateSchema.errors.map((e) => ({
40
+ severity: "error",
41
+ code: "E_SCHEMA",
42
+ at: e.instancePath || "/",
43
+ message: `${e.message}${e.params ? ` ${JSON.stringify(e.params)}` : ""}`,
44
+ }));
45
+ }
package/src/schema.mjs ADDED
@@ -0,0 +1,23 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { compileSchema, schemaDiagnostics } from "./schema-core.mjs";
4
+
5
+ export { schemaDiagnostics };
6
+
7
+ /**
8
+ * The schema this package ships (data/profile-v0.schema.json), so the default works wherever the package is
9
+ * installed. It is a copy of the repository's spec/profile-v0.schema.json (W0's shared contract), regenerated by
10
+ * `npm run data -w packages/resolver`; test/data.test.mjs fails when the copy is stale.
11
+ */
12
+ export const DEFAULT_SCHEMA_PATH = fileURLToPath(new URL("../data/profile-v0.schema.json", import.meta.url));
13
+
14
+ /**
15
+ * The validate function for the profile schema, with ajv's `errors`. The shipped schema (the default, or a document or
16
+ * file equal to it) gets the validator compiled ahead of time; any other schema is compiled by ajv at run time.
17
+ *
18
+ * @param schema a path to the schema file (default: the copy this package ships), or the schema document itself
19
+ * (`import schema from "@meantjs/profile/schema.json" with { type: "json" }` is the same copy).
20
+ */
21
+ export async function createSchemaValidator(schema = DEFAULT_SCHEMA_PATH) {
22
+ return compileSchema(typeof schema === "string" ? JSON.parse(await readFile(schema, "utf8")) : schema);
23
+ }
package/src/served.mjs ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A profile served by its canister (spec section 8.1, D-048): the public custom section `icp:public candid:profile`,
3
+ * read by a consumer through a certified `read_state` at `/canister/<id>/metadata/candid:profile`.
4
+ *
5
+ * This module reads the section's bytes; it does not fetch them, so the library keeps no agent dependency (the kit's
6
+ * `fetch` does the network part). What it checks is what only a served profile can get wrong: the bytes are UTF-8
7
+ * JSON of at most 1 MiB, the document is a `canister` profile of origin `author`, and its `binds.canister_id`, when
8
+ * given, is the canister that served it. Everything else (the schema, the binding to the interface the same canister
9
+ * serves as `candid:service`, every tag) is `resolveService`'s, as for any profile.
10
+ *
11
+ * New diagnostic code (spec section 13): E_SERVED.
12
+ */
13
+
14
+ /** The name of the custom section a canister serves its profile in. */
15
+ export const SERVED_SECTION = "icp:public candid:profile";
16
+ /** The metadata name a consumer reads: `/canister/<id>/metadata/candid:profile`. */
17
+ export const SERVED_METADATA = "candid:profile";
18
+ /** The largest served profile read: the platform's limit for all of a canister's custom sections together. */
19
+ export const SERVED_MAX_BYTES = 1024 * 1024;
20
+
21
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
22
+
23
+ /**
24
+ * @param bytes the content of the canister's `candid:profile` metadata (a Uint8Array)
25
+ * @param opts.canisterId the textual id of the canister that served it; null only before the module is installed
26
+ * anywhere (the kit's `embed`), which leaves `binds.canister_id` unchecked
27
+ * @param opts.label how diagnostics name the input (default "candid:profile")
28
+ * @returns { ok, doc, diagnostics } `doc` is the parsed profile, or null when it cannot be used (E_SERVED)
29
+ */
30
+ export function readServedProfile(bytes, opts = {}) {
31
+ const at = opts.label ?? SERVED_METADATA;
32
+ const refuse = (message) => ({ ok: false, doc: null, diagnostics: [{ severity: "error", code: "E_SERVED", at, message }] });
33
+ if (opts.canisterId !== null && (typeof opts.canisterId !== "string" || opts.canisterId === "")) {
34
+ throw new TypeError("readServedProfile: give the serving canister's id as opts.canisterId (null before it is installed)");
35
+ }
36
+ if (!(bytes instanceof Uint8Array)) return refuse("the served profile is not bytes");
37
+ if (bytes.length > SERVED_MAX_BYTES) return refuse(`the served profile is ${bytes.length} bytes, over the ${SERVED_MAX_BYTES} a canister can serve`);
38
+ let text;
39
+ try {
40
+ text = decoder.decode(bytes);
41
+ } catch {
42
+ return refuse("the served profile is not UTF-8");
43
+ }
44
+ if (text.startsWith("")) return refuse("the served profile starts with a byte order mark");
45
+ let doc;
46
+ try {
47
+ doc = JSON.parse(text);
48
+ } catch (e) {
49
+ return refuse(`the served profile is not JSON: ${e.message}`);
50
+ }
51
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return refuse("the served profile is not a JSON object");
52
+ if (doc.kind !== "canister") return refuse(`a canister serves a profile of kind "canister", not ${JSON.stringify(doc.kind ?? null)}`);
53
+ if (doc.origin !== "author") return refuse(`a profile its canister serves is the author's: origin "author", not ${JSON.stringify(doc.origin ?? null)}`);
54
+ const bound = doc.binds?.canister_id;
55
+ if (bound !== undefined && opts.canisterId !== null && bound !== opts.canisterId) {
56
+ return refuse(`the served profile is bound to canister ${JSON.stringify(bound)}, but ${opts.canisterId} served it`);
57
+ }
58
+ return { ok: true, doc, diagnostics: [] };
59
+ }
package/src/sha256.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // SHA-256 in plain JavaScript, synchronous, so that `profileId` needs no node:crypto (WebCrypto is asynchronous, and
2
+ // profileId is called inside synchronous code). test/browser-entry.test.mjs compares it with node:crypto.
3
+
4
+ const K = new Uint32Array([
5
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
6
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
7
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
8
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
9
+ ]);
10
+
11
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
12
+
13
+ /** SHA-256 of a byte array, as 32 bytes. */
14
+ export function sha256(bytes) {
15
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
16
+ const bitLength = bytes.length * 8;
17
+ const padded = new Uint8Array(((bytes.length + 9 + 63) >> 6) << 6);
18
+ padded.set(bytes);
19
+ padded[bytes.length] = 0x80;
20
+ const view = new DataView(padded.buffer);
21
+ view.setUint32(padded.length - 8, Math.floor(bitLength / 2 ** 32), false);
22
+ view.setUint32(padded.length - 4, bitLength >>> 0, false);
23
+ const w = new Uint32Array(64);
24
+ for (let off = 0; off < padded.length; off += 64) {
25
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + 4 * i, false);
26
+ for (let i = 16; i < 64; i++) {
27
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
28
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
29
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
30
+ }
31
+ let [a, b, c, d, e, f, g, hh] = h;
32
+ for (let i = 0; i < 64; i++) {
33
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
34
+ const ch = (e & f) ^ (~e & g);
35
+ const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
36
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
37
+ const maj = (a & b) ^ (a & c) ^ (b & c);
38
+ const t2 = (S0 + maj) | 0;
39
+ hh = g;
40
+ g = f;
41
+ f = e;
42
+ e = (d + t1) | 0;
43
+ d = c;
44
+ c = b;
45
+ b = a;
46
+ a = (t1 + t2) | 0;
47
+ }
48
+ h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
49
+ }
50
+ const out = new Uint8Array(32);
51
+ const ov = new DataView(out.buffer);
52
+ h.forEach((x, i) => ov.setUint32(4 * i, x, false));
53
+ return out;
54
+ }
55
+
56
+ /** Lowercase hex SHA-256 of a string's UTF-8 bytes. */
57
+ export function sha256Hex(text) {
58
+ return Array.from(sha256(new TextEncoder().encode(text)), (b) => b.toString(16).padStart(2, "0")).join("");
59
+ }
@@ -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
+ }