@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.
package/src/cli.mjs ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * candid-profile resolve (<service.did> | --envelope <file>) [--profile p.json ...] [--packs] [--packs-dir dir]
3
+ *
4
+ * Prints the resolved form (docs/profile-v0.md section 10) on stdout and a summary on stderr.
5
+ * Exit code: 0 when no diagnostic is an error, 1 when one is (the resolved form is still printed when the service
6
+ * compiles), 2 for a usage or I/O problem.
7
+ */
8
+ import { readFile } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { createSchemaValidator } from "./schema.mjs";
11
+ import { DEFAULT_PACKS_DIR, loadPacks } from "./packs.mjs";
12
+ import { resolveService } from "./resolve.mjs";
13
+
14
+ export const USAGE = `usage: candid-profile resolve <service.did> [options] > resolved.json
15
+ candid-profile resolve --envelope <file> [options] > resolved.json
16
+
17
+ --envelope <file> the service's candid-core ContractEnvelope (as \`candid-core-cli gen\` writes it), instead of
18
+ compiling a .did here
19
+ --profile <file> a profile to apply (repeatable). A pack given this way must match the service.
20
+ --packs try every pack in the packs directory; apply those that match
21
+ --packs-dir <dir> packs directory (default: the standard packs this package ships)
22
+ --verbose also list info diagnostics on stderr
23
+ --quiet no summary on stderr
24
+ -h, --help this text
25
+ `;
26
+
27
+ class UsageError extends Error {}
28
+
29
+ function parseArgs(argv) {
30
+ const opts = { did: null, envelope: null, profiles: [], packs: false, packsDir: DEFAULT_PACKS_DIR, verbose: false, quiet: false, help: false };
31
+ const args = [...argv];
32
+ const value = (name, inline) => {
33
+ if (inline !== undefined) return inline;
34
+ const v = args.shift();
35
+ if (v === undefined || v.startsWith("--")) throw new UsageError(`${name} needs a value`);
36
+ return v;
37
+ };
38
+ while (args.length) {
39
+ const a = args.shift();
40
+ const [flag, inline] = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, undefined];
41
+ if (flag === "--profile") opts.profiles.push(value(flag, inline));
42
+ else if (flag === "--envelope") opts.envelope = value(flag, inline);
43
+ else if (flag === "--packs") opts.packs = true;
44
+ else if (flag === "--packs-dir") opts.packsDir = value(flag, inline);
45
+ else if (flag === "--verbose") opts.verbose = true;
46
+ else if (flag === "--quiet") opts.quiet = true;
47
+ else if (flag === "-h" || flag === "--help") opts.help = true;
48
+ else if (a.startsWith("-")) throw new UsageError(`unknown option ${a}`);
49
+ else if (opts.did === null) opts.did = a;
50
+ else throw new UsageError(`unexpected argument ${a}`);
51
+ }
52
+ return opts;
53
+ }
54
+
55
+ async function readJsonFile(file, what) {
56
+ try {
57
+ return JSON.parse(await readFile(file, "utf8"));
58
+ } catch (e) {
59
+ throw new UsageError(`cannot read ${what} ${file}: ${e.message}`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param argv arguments after the program name, starting with the command
65
+ * @param io { stdout, stderr } with write(string)
66
+ * @returns the exit code
67
+ */
68
+ export async function main(argv, io) {
69
+ const err = (s) => io.stderr.write(`${s}\n`);
70
+ try {
71
+ const [command, ...rest] = argv;
72
+ if (command === undefined || command === "-h" || command === "--help") { err(USAGE.trimEnd()); return command === undefined ? 2 : 0; }
73
+ if (command !== "resolve") throw new UsageError(`unknown command ${command}`);
74
+ const opts = parseArgs(rest);
75
+ if (opts.help) { err(USAGE.trimEnd()); return 0; }
76
+ if (opts.did && opts.envelope) throw new UsageError("give <service.did> or --envelope, not both");
77
+ if (!opts.did && !opts.envelope) throw new UsageError("missing <service.did> (or --envelope <file>)");
78
+ const input = {};
79
+ if (opts.envelope) input.envelope = await readJsonFile(opts.envelope, "envelope");
80
+ else {
81
+ try { input.did = await readFile(opts.did, "utf8"); } catch (e) { throw new UsageError(`cannot read ${opts.did}: ${e.message}`); }
82
+ }
83
+
84
+ const validateSchema = await createSchemaValidator();
85
+ const profiles = [];
86
+ for (const f of opts.profiles) profiles.push({ doc: await readJsonFile(f, "profile"), label: f });
87
+ const packs = [];
88
+ if (opts.packs) {
89
+ let found;
90
+ try { found = await loadPacks(opts.packsDir); } catch (e) { throw new UsageError(`cannot read packs from ${opts.packsDir}: ${e.message}`); }
91
+ if (!found.length) throw new UsageError(`no packs (*.profile.json of kind pack) in ${opts.packsDir}`);
92
+ for (const p of found) packs.push({ doc: p.doc, label: path.relative(process.cwd(), p.file) });
93
+ }
94
+
95
+ const r = await resolveService({ ...input, didLabel: opts.did ?? opts.envelope, validateSchema, profiles, packs });
96
+ if (r.resolved) io.stdout.write(`${JSON.stringify(r.resolved, null, 2)}\n`);
97
+ if (!opts.quiet) summary(r, opts, err);
98
+ return r.ok ? 0 : 1;
99
+ } catch (e) {
100
+ if (e instanceof UsageError) { err(`candid-profile: ${e.message}`); err(USAGE.trimEnd()); return 2; }
101
+ throw e;
102
+ }
103
+ }
104
+
105
+ function summary(r, opts, err) {
106
+ const count = (s) => r.diagnostics.filter((d) => d.severity === s).length;
107
+ if (r.resolved) err(`candid-profile: ${opts.did ?? opts.envelope} ${r.resolved.interface_id} [${Object.keys(r.resolved.methods).length} methods]`);
108
+ for (const p of r.profiles) err(` ${p.status.padEnd(11)} ${String(p.name).padEnd(16)} ${String(p.origin ?? "?").padEnd(12)} ${p.profile_id ?? ""}`);
109
+ err(` ${count("error")} error, ${count("warning")} warning, ${count("info")} info`);
110
+ for (const d of r.diagnostics) {
111
+ if (d.severity === "info" && !opts.verbose) continue;
112
+ err(` ${d.severity.padEnd(7)} ${d.code.padEnd(26)} ${d.profile ? `[${d.profile}] ` : ""}${d.at} ${d.message}`);
113
+ }
114
+ }
@@ -0,0 +1,304 @@
1
+ import { didToContract } from "@candid-core/cli";
2
+ import { Iface } from "./iface.mjs";
3
+ import { U32_MAX, idlHash } from "./idl.mjs";
4
+
5
+ /**
6
+ * Compile Candid source text to an Iface with @candid-core/cli (wasm).
7
+ * @returns { iface } or { failure: [candid-core diagnostics] }
8
+ */
9
+ export async function compileInterface(source, label = "interface") {
10
+ const env = await didToContract(source);
11
+ if (env.ok === false) return { failure: env.diagnostics };
12
+ return { iface: new Iface(env, label) };
13
+ }
14
+
15
+ /**
16
+ * An Iface from a candid-core ContractEnvelope another tool already wrote (the one `candid-core-cli gen` writes next
17
+ * to a generated module), without compiling the .did again. The envelope is checked for the shape the resolver reads;
18
+ * its identities are taken as written (nothing here can recompute them).
19
+ * @returns { iface } or { failure: [{ code, message }] }
20
+ */
21
+ export function interfaceFromEnvelope(envelope, label = "interface") {
22
+ const fail = (message) => ({ failure: [{ code: "invalid_envelope", message }] });
23
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) return fail("not a JSON object");
24
+ const c = envelope.contract;
25
+ if (c === null || typeof c !== "object" || Array.isArray(c)) return fail("no `contract` object (is this a candid-core ContractEnvelope?)");
26
+ if (c.format !== "candid-core") return fail(`contract.format is ${JSON.stringify(c.format)}, expected "candid-core"`);
27
+ if (c.format_version !== 1) return fail(`contract.format_version is ${JSON.stringify(c.format_version)}, this resolver reads version 1`);
28
+ const id = c.identities?.interface;
29
+ if (typeof id !== "string" || !/^candid-core:interface:v1:sha256:[0-9a-f]{64}$/.test(id)) return fail("no contract.identities.interface of the form candid-core:interface:v1:sha256:<64 lowercase hex>");
30
+ // the Candid semantics and the canonical interning this resolver implements; the interface id covers both markers
31
+ if (c.semantics_profile !== "candid-1") return fail(`contract.semantics_profile is ${JSON.stringify(c.semantics_profile)}, this resolver implements "candid-1"`);
32
+ if (c.canonicalization_profile !== "candid-core-canon-1") return fail(`contract.canonicalization_profile is ${JSON.stringify(c.canonicalization_profile)}, this resolver implements "candid-core-canon-1"`);
33
+ if (!Array.isArray(c.types) || !Array.isArray(c.declarations)) return fail("contract.types and contract.declarations must be arrays");
34
+ const names = envelope.extensions?.["org.candid-core.field-names/v1"];
35
+ if (!Array.isArray(names)) return fail("no extensions[\"org.candid-core.field-names/v1\"] array: without it every field and case would be renamed to its id");
36
+ const bad = badGraph(c, names);
37
+ if (bad) return fail(bad);
38
+ try {
39
+ return { iface: new Iface(envelope, label) };
40
+ } catch (e) {
41
+ return fail(`the contract graph cannot be read: ${e.message}`);
42
+ }
43
+ }
44
+
45
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
46
+ const KINDS = new Set(["primitive", "opt", "vec", "record", "variant", "func", "service", "class"]);
47
+ const MODES = new Set(["update", "query", "composite_query", "oneway"]);
48
+ const PRIMITIVES = new Set([
49
+ "nat", "nat8", "nat16", "nat32", "nat64", "int", "int8", "int16", "int32", "int64",
50
+ "float32", "float64", "bool", "text", "null", "reserved", "empty", "principal",
51
+ ]);
52
+
53
+ /**
54
+ * The first thing in the contract graph this resolver cannot read, as a readable sentence; null if none. Checked
55
+ * here, before anything walks the graph, so a malformed envelope is an E_ENVELOPE diagnostic and never a throw or a
56
+ * loop further in: every reference is an index into `types`, declaration names are distinct strings, every node has the fields its kind needs, a method is
57
+ * a `func` with a mode (a oneway one with no results) and an id that is its name's hash, declarations are named by
58
+ * Candid identifiers, a class appears only as the actor, an actor names a `service` or `class`, every node is reached from the actor
59
+ * or a declaration, no `opt` contains only itself, and every name in the field-names
60
+ * table is the name of a field its container has, with that id.
61
+ */
62
+ function badGraph(c, names) {
63
+ const n = c.types.length;
64
+ const ok = (r) => Number.isInteger(r) && r >= 0 && r < n;
65
+ const isKind = (r, kind) => ok(r) && c.types[r]?.kind === kind;
66
+ const refs = (a) => Array.isArray(a) && a.every(ok);
67
+ const declNames = new Set();
68
+ for (const [i, d] of c.declarations.entries()) {
69
+ if (!ok(d?.type)) return `dangling type reference: declarations[${i}].type`;
70
+ if (typeof d.name !== "string" || !IDENT.test(d.name)) return `declarations[${i}] needs a Candid identifier as its name, got ${JSON.stringify(d.name)}`;
71
+ if (declNames.has(d.name)) return `declaration ${JSON.stringify(d.name)} is listed twice`;
72
+ declNames.add(d.name);
73
+ }
74
+ for (const [i, t] of c.types.entries()) {
75
+ const at = `types[${i}]`;
76
+ if (t === null || typeof t !== "object" || !KINDS.has(t.kind)) return `${at} has no known kind`;
77
+ switch (t.kind) {
78
+ case "primitive":
79
+ if (!PRIMITIVES.has(t.primitive)) return `${at}: ${JSON.stringify(t.primitive)} is not a Candid primitive`;
80
+ break;
81
+ case "opt":
82
+ case "vec":
83
+ if (!ok(t.inner)) return `dangling type reference: ${at}.inner`;
84
+ break;
85
+ case "record":
86
+ case "variant": {
87
+ if (!Array.isArray(t.fields)) return `${at}: a ${t.kind} needs fields`;
88
+ const ids = new Set();
89
+ for (const f of t.fields) {
90
+ if (!ok(f?.type)) return `dangling type reference: ${at}.fields`;
91
+ if (!Number.isInteger(f.id) || f.id < 0 || f.id > U32_MAX || ids.has(f.id)) return `${at}: field ids must be distinct u32 values`;
92
+ ids.add(f.id);
93
+ }
94
+ break;
95
+ }
96
+ case "func":
97
+ if (!refs(t.args) || !refs(t.results)) return `dangling type reference: ${at}.args or .results`;
98
+ if (!MODES.has(t.mode)) return `${at}: a func needs a mode (update, query, composite_query or oneway), got ${JSON.stringify(t.mode)}`;
99
+ if (t.mode === "oneway" && t.results.length > 0) return `${at}: a oneway func has no results`;
100
+ break;
101
+ case "service":
102
+ if (!Array.isArray(t.methods)) return `${at}: a service needs methods`;
103
+ const methodNames = new Set();
104
+ for (const m of t.methods) {
105
+ if (typeof m?.name !== "string" || m.name === "") return `${at}: every method needs a non-empty name`;
106
+ if (!Number.isInteger(m.id) || m.id < 0 || m.id > U32_MAX || m.id !== idlHash(m.name)) return `${at}: method ${JSON.stringify(m.name)} needs the id ${idlHash(m.name)} (the hash of its name), got ${JSON.stringify(m.id)}`;
107
+ if (methodNames.has(m.name)) return `${at}: method ${JSON.stringify(m.name)} is listed twice`;
108
+ methodNames.add(m.name);
109
+ if (!isKind(m.function, "func")) return `${at}: method ${JSON.stringify(m.name)} does not name a func type`;
110
+ }
111
+ break;
112
+ case "class":
113
+ if (!refs(t.init) || !isKind(t.service, "service")) return `${at}: a class needs init arguments and a service type`;
114
+ break;
115
+ }
116
+ }
117
+ // each opt is walked once: a chain already known to end in a non-opt is not walked again (linear in the graph)
118
+ const state = new Uint8Array(n); // 0 unseen, 1 on the chain being walked, 2 ends in a non-opt
119
+ for (const [i, t] of c.types.entries()) {
120
+ if (t.kind !== "opt" || state[i] !== 0) continue;
121
+ const chain = [];
122
+ let r = i;
123
+ for (; c.types[r].kind === "opt" && state[r] === 0; r = c.types[r].inner) {
124
+ state[r] = 1;
125
+ chain.push(r);
126
+ }
127
+ if (c.types[r].kind === "opt" && state[r] === 1) return `types[${i}]: an opt that contains only opts of itself`;
128
+ for (const q of chain) state[q] = 2;
129
+ }
130
+ // A class is valid only as the actor (contract-graph.md, validation checklist 5): no declaration or type refers to one.
131
+ const isClass = (r) => c.types[r].kind === "class";
132
+ if (c.declarations.some((d) => isClass(d.type))) return "a declaration names a class: a class is only the actor";
133
+ for (const [i, t] of c.types.entries()) {
134
+ if (childrenOf(t).some(isClass)) return `types[${i}] refers to a class: a class is only the actor`;
135
+ }
136
+ const a = c.actor;
137
+ if (a === null || typeof a !== "object") return "no actor: the envelope describes no service";
138
+ if (a.kind === "service" ? !isKind(a.service, "service") : a.kind === "class" ? !isKind(a.class, "class") : true) {
139
+ return "actor must name a service or a class type";
140
+ }
141
+ // Every node is reachable from the actor or a declaration (contract-graph.md, validation checklist 6): nothing the
142
+ // interface id does not cover rides along in an envelope this resolver accepts.
143
+ const reached = new Uint8Array(n);
144
+ const stack = [a.kind === "service" ? a.service : a.class, ...c.declarations.map((d) => d.type)];
145
+ for (const r of stack) reached[r] = 1;
146
+ while (stack.length > 0) {
147
+ for (const r of childrenOf(c.types[stack.pop()])) {
148
+ if (reached[r] === 0) {
149
+ reached[r] = 1;
150
+ stack.push(r);
151
+ }
152
+ }
153
+ }
154
+ const stray = reached.indexOf(0);
155
+ if (stray !== -1) return `types[${stray}] is reached from neither the actor nor a declaration`;
156
+ const named = new Set();
157
+ for (const [k, e] of names.entries()) {
158
+ if (!Array.isArray(e) || e.length !== 3) return `field-names entry ${k} is not [container, id, name]`;
159
+ const [container, id, name] = e;
160
+ const t = ok(container) ? c.types[container] : undefined;
161
+ if (t?.kind !== "record" && t?.kind !== "variant") return `field-names entry ${k} names no record or variant`;
162
+ if (typeof name !== "string" || !t.fields.some((f) => f.id === id) || idlHash(name) !== id) return `field-names entry ${k}: ${JSON.stringify(name)} is not the name of field ${id} of types[${container}]`;
163
+ // two names for one field (they can share a hash) would leave the name to the order of the entries
164
+ if (named.has(`${container}:${id}`)) return `field-names entry ${k} names field ${id} of types[${container}] a second time`;
165
+ named.add(`${container}:${id}`);
166
+ }
167
+ const twin = duplicateNodes(c.types);
168
+ if (twin) return `types[${twin[0]}] and types[${twin[1]}] are the same type: the graph is not interned as candid-core-canon-1 requires`;
169
+ // A field with no entry keeps its numeric label (`#n`, spec §3.2 rule 1): candid-core writes none for `1 : nat` or a tuple
170
+ // field, so a missing entry cannot be told from a numeric label and is not checked.
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * The references of a node, in a fixed order, reading only the properties its kind has (checked above): another
176
+ * property on the node, such as `fields` on a primitive, is ignored. Record and variant fields go by id, methods by name.
177
+ */
178
+ function childrenOf(t) {
179
+ switch (t.kind) {
180
+ case "opt": case "vec": return [t.inner];
181
+ case "record": case "variant": return [...t.fields].sort((a, b) => a.id - b.id).map((f) => f.type);
182
+ case "func": return [...t.args, ...t.results];
183
+ case "service": return [...t.methods].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)).map((m) => m.function);
184
+ case "class": return [...t.init, t.service];
185
+ default: return [];
186
+ }
187
+ }
188
+
189
+ /** What a node is apart from its references: two nodes with equal keys are equal types if their references are. */
190
+ function localKey(t) {
191
+ switch (t.kind) {
192
+ case "primitive": return JSON.stringify(["p", t.primitive]);
193
+ case "record": case "variant": return JSON.stringify([t.kind, t.fields.map((f) => f.id).sort((a, b) => a - b)]);
194
+ case "func": return JSON.stringify(["f", t.mode, t.args.length, t.results.length]);
195
+ case "service": return JSON.stringify(["s", t.methods.map((m) => m.name).sort()]);
196
+ case "class": return JSON.stringify(["c", t.init.length]);
197
+ default: return JSON.stringify([t.kind]);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Two indexes holding structurally equal types (recursion included), or null. candid-core-canon-1 interns every type
203
+ * once, and the resolver relies on it: an alias reaches a method position only through an equal reference (profile
204
+ * spec 3.2 rule 7). Equal types are the states a DFA minimization merges, so this is Hopcroft's partition refinement
205
+ * (O(k n log n) for n nodes with at most k references each): start from blocks of equal `localKey`, and split a block whenever its nodes' j-th
206
+ * references fall in different blocks, always queueing the smaller half. Two nodes still sharing a block are equal.
207
+ */
208
+ function duplicateNodes(types) {
209
+ const n = types.length;
210
+ const succ = types.map(childrenOf);
211
+ const preds = types.map(() => new Map()); // preds[t].get(j): the nodes whose j-th reference is t
212
+ for (const [s, refs] of succ.entries()) {
213
+ for (const [j, t] of refs.entries()) {
214
+ const list = preds[t].get(j);
215
+ if (list) list.push(s);
216
+ else preds[t].set(j, [s]);
217
+ }
218
+ }
219
+ // blocks are segments [start, end) of `elems`; `where[i]` is i's index in `elems`
220
+ const elems = new Int32Array(n);
221
+ const where = new Int32Array(n);
222
+ const block = new Int32Array(n);
223
+ const start = [];
224
+ const end = [];
225
+ const groups = new Map();
226
+ for (const [i, t] of types.entries()) {
227
+ const k = localKey(t);
228
+ if (!groups.has(k)) groups.set(k, []);
229
+ groups.get(k).push(i);
230
+ }
231
+ let fill = 0;
232
+ for (const members of groups.values()) {
233
+ start.push(fill);
234
+ for (const i of members) {
235
+ block[i] = start.length - 1;
236
+ where[i] = fill;
237
+ elems[fill++] = i;
238
+ }
239
+ end.push(fill);
240
+ }
241
+ const queued = new Set();
242
+ const work = [];
243
+ const queue = (b) => {
244
+ for (let x = start[b]; x < end[b]; x++) {
245
+ for (const j of preds[elems[x]].keys()) {
246
+ const key = `${b},${j}`;
247
+ if (!queued.has(key)) {
248
+ queued.add(key);
249
+ work.push([b, j]);
250
+ }
251
+ }
252
+ }
253
+ };
254
+ for (let b = 0; b < start.length; b++) queue(b);
255
+ const marked = [];
256
+ while (work.length > 0) {
257
+ const [b, j] = work.pop();
258
+ queued.delete(`${b},${j}`);
259
+ const touched = [];
260
+ // the splitter's members are copied first: marking moves nodes inside their blocks, the splitter's own included
261
+ for (const t of elems.slice(start[b], end[b])) {
262
+ for (const s of preds[t].get(j) ?? []) {
263
+ // s has one j-th reference, so it is marked at most once per splitter; marked nodes move to their block's front
264
+ const y = block[s];
265
+ if (marked[y] === undefined || marked[y] === 0) {
266
+ marked[y] = 0;
267
+ touched.push(y);
268
+ }
269
+ const to = start[y] + marked[y]++;
270
+ const other = elems[to];
271
+ elems[where[s]] = other;
272
+ where[other] = where[s];
273
+ elems[to] = s;
274
+ where[s] = to;
275
+ }
276
+ }
277
+ for (const y of touched) {
278
+ const m = marked[y];
279
+ marked[y] = 0;
280
+ const size = end[y] - start[y];
281
+ if (m === size) continue;
282
+ // the smaller part becomes the new block z; y keeps the rest (and any of its splitters already queued)
283
+ const z = start.length;
284
+ if (m <= size - m) {
285
+ start.push(start[y]);
286
+ end.push(start[y] + m);
287
+ start[y] += m;
288
+ } else {
289
+ start.push(start[y] + m);
290
+ end.push(end[y]);
291
+ end[y] = start[y] + m;
292
+ }
293
+ for (let x = start[z]; x < end[z]; x++) block[elems[x]] = z;
294
+ queue(z);
295
+ }
296
+ }
297
+ for (let b = 0; b < start.length; b++) {
298
+ if (end[b] - start[b] > 1) {
299
+ const pair = [elems[start[b]], elems[start[b] + 1]].sort((p, q) => p - q);
300
+ return pair;
301
+ }
302
+ }
303
+ return null;
304
+ }