@meantjs/cli 0.0.0-bootstrap → 0.0.0-stage
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/README.md +2 -513
- package/package.json +4 -47
- package/CHANGELOG.md +0 -733
- package/LICENSE +0 -202
- package/bin/cli.js +0 -762
- package/lib/index.d.ts +0 -521
- package/lib/index.js +0 -131
- package/wasm/candid_core_wasm.js +0 -291
- package/wasm/candid_core_wasm_bg.wasm +0 -0
package/bin/cli.js
DELETED
|
@@ -1,762 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// The @meantjs/cli entry point:
|
|
3
|
-
//
|
|
4
|
-
// meant gen <service.did>... [-o <dir>] [--json] [--check]
|
|
5
|
-
// meant project <in.did> --methods <a,b,...> -o <out.did> [--json]
|
|
6
|
-
// meant check <written.did> --against <live.did> [--json]
|
|
7
|
-
//
|
|
8
|
-
// `project` writes a `.did` holding only the named methods and the types
|
|
9
|
-
// they reach; `check` exits 0 when the live interface is still a Candid
|
|
10
|
-
// subtype of the written one and 1 otherwise. Both read their inputs as
|
|
11
|
-
// `gen` does (the file and every `.did` beneath its directory) and never
|
|
12
|
-
// touch the network: fetching a live interface is the caller's job.
|
|
13
|
-
//
|
|
14
|
-
// The JS host does all the I/O: for each entry it reads the entry file and
|
|
15
|
-
// every `.did` beneath the entry's directory, hands them to the wasm
|
|
16
|
-
// compiler as data, and writes the two artifacts — `<stem>.ts` (the
|
|
17
|
-
// generated module) and `<stem>.envelope.json` (the one-document contract
|
|
18
|
-
// envelope with field names) — printing the content-addressed identities on
|
|
19
|
-
// stdout. Nothing is read from stdin, ever, and nothing is written outside
|
|
20
|
-
// `-o`.
|
|
21
|
-
//
|
|
22
|
-
// Conventions follow the native `meant` binary: anything outside the
|
|
23
|
-
// grammar is a usage error (exit 64, usage on stderr, nothing on stdout);
|
|
24
|
-
// any entry that fails (a compile or generation failure, an unreadable
|
|
25
|
-
// entry, a determinism mismatch) makes the run exit 1 while the other
|
|
26
|
-
// entries still run. Determinism is enforced, not assumed: every generation
|
|
27
|
-
// runs twice and the entry refuses on any byte mismatch.
|
|
28
|
-
//
|
|
29
|
-
// A module that had to leave declarations or actor methods out is still
|
|
30
|
-
// usable, so the run succeeds (exit 0): each omission is printed as
|
|
31
|
-
// a `warning: omitted …` line on stderr, in the order and wording of the
|
|
32
|
-
// module's own `// Omitted:` header.
|
|
33
|
-
//
|
|
34
|
-
// `--json` replaces every human line with exactly one JSON document on
|
|
35
|
-
// stdout (shape: README, `CliReport` in lib/index.d.ts); stderr stays empty.
|
|
36
|
-
// `--check` generates in memory, compares byte-for-byte with the files on
|
|
37
|
-
// disk, writes nothing, and exits 1 on any drift.
|
|
38
|
-
|
|
39
|
-
import { readFile, readdir, readlink, mkdir, realpath, stat, writeFile } from "node:fs/promises";
|
|
40
|
-
import path from "node:path";
|
|
41
|
-
import process from "node:process";
|
|
42
|
-
|
|
43
|
-
import {
|
|
44
|
-
checkCompatible,
|
|
45
|
-
didToContract,
|
|
46
|
-
didToModule,
|
|
47
|
-
init,
|
|
48
|
-
projectDid,
|
|
49
|
-
} from "../lib/index.js";
|
|
50
|
-
|
|
51
|
-
const USAGE = [
|
|
52
|
-
"usage: meant gen <service.did>... [-o <dir>] [--json] [--check]",
|
|
53
|
-
" meant project <in.did> --methods <a,b,...> -o <out.did> [--json]",
|
|
54
|
-
" meant check <written.did> --against <live.did> [--json]",
|
|
55
|
-
].join("\n");
|
|
56
|
-
|
|
57
|
-
// The `--json` document's version. It changes only when an existing field's
|
|
58
|
-
// meaning or shape changes; a consumer refuses a version it does not know.
|
|
59
|
-
const SCHEMA_VERSION = 1;
|
|
60
|
-
|
|
61
|
-
// The compiler's own source bounds (`Limits::default()` — the values the
|
|
62
|
-
// root README documents), enforced *while walking*: the entry's whole
|
|
63
|
-
// directory tree is the bundle this CLI hands over, so an oversized tree
|
|
64
|
-
// must fail with the structured resource diagnostic the compiler would
|
|
65
|
-
// produce, never by exhausting the JS heap before the wasm side can check.
|
|
66
|
-
const MAX_SOURCE_BYTES = 1_048_576; // max_source_bytes, per file
|
|
67
|
-
const MAX_BUNDLE_BYTES = 8_388_608; // max_bundle_bytes, aggregate
|
|
68
|
-
const MAX_SOURCES = 256; // max_sources, file count
|
|
69
|
-
|
|
70
|
-
function usage(problem) {
|
|
71
|
-
if (problem !== undefined) {
|
|
72
|
-
console.error(problem);
|
|
73
|
-
}
|
|
74
|
-
console.error(USAGE);
|
|
75
|
-
process.exit(64);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The output stem an entry generates, e.g. `service` for `a/service.did`. */
|
|
79
|
-
function stemOf(entry) {
|
|
80
|
-
return path.basename(entry).replace(/\.did$/, "");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function parseArguments(argv) {
|
|
84
|
-
if (argv[0] === "project") {
|
|
85
|
-
return parseProject(argv);
|
|
86
|
-
}
|
|
87
|
-
if (argv[0] === "check") {
|
|
88
|
-
return parseCheck(argv);
|
|
89
|
-
}
|
|
90
|
-
if (argv.length === 0 || argv[0] !== "gen") {
|
|
91
|
-
usage();
|
|
92
|
-
}
|
|
93
|
-
const entries = [];
|
|
94
|
-
let outDir;
|
|
95
|
-
let json = false;
|
|
96
|
-
let check = false;
|
|
97
|
-
for (let index = 1; index < argv.length; index += 1) {
|
|
98
|
-
const argument = argv[index];
|
|
99
|
-
if (argument === "-o") {
|
|
100
|
-
if (outDir !== undefined || index + 1 >= argv.length) {
|
|
101
|
-
usage();
|
|
102
|
-
}
|
|
103
|
-
index += 1;
|
|
104
|
-
outDir = argv[index];
|
|
105
|
-
} else if (argument === "--json") {
|
|
106
|
-
json = true;
|
|
107
|
-
} else if (argument === "--check") {
|
|
108
|
-
check = true;
|
|
109
|
-
} else if (argument.startsWith("-")) {
|
|
110
|
-
usage();
|
|
111
|
-
} else {
|
|
112
|
-
entries.push(argument);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (entries.length === 0) {
|
|
116
|
-
usage();
|
|
117
|
-
}
|
|
118
|
-
// Decision D2 (a recommendation the maintainer can overturn): every entry
|
|
119
|
-
// writes `<stem>.ts` into the one `-o`, so two entries with one stem would
|
|
120
|
-
// overwrite each other. Refuse, as a usage error, before any work. Stems
|
|
121
|
-
// compare case-insensitively: the output must check out the same on a
|
|
122
|
-
// case-folding filesystem.
|
|
123
|
-
const seen = new Map();
|
|
124
|
-
for (const entry of entries) {
|
|
125
|
-
const key = stemOf(entry).toLowerCase();
|
|
126
|
-
if (seen.has(key)) {
|
|
127
|
-
usage(
|
|
128
|
-
`error: ${seen.get(key)} and ${entry} would both write ${stemOf(entry)}.ts; ` +
|
|
129
|
-
"give each entry a distinct file name",
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
seen.set(key, entry);
|
|
133
|
-
}
|
|
134
|
-
return { command: "gen", entries, outDir: outDir ?? ".", json, check };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Read a command's options: each `--name` in `valued` takes the next
|
|
139
|
-
* argument, at most once; `--json` is a flag; anything else starting with
|
|
140
|
-
* `-` is a usage error, and the rest are positionals.
|
|
141
|
-
*/
|
|
142
|
-
function parseOptions(argv, valued) {
|
|
143
|
-
const options = {};
|
|
144
|
-
const positionals = [];
|
|
145
|
-
let json = false;
|
|
146
|
-
for (let index = 1; index < argv.length; index += 1) {
|
|
147
|
-
const argument = argv[index];
|
|
148
|
-
if (valued.includes(argument)) {
|
|
149
|
-
if (options[argument] !== undefined || index + 1 >= argv.length) {
|
|
150
|
-
usage();
|
|
151
|
-
}
|
|
152
|
-
index += 1;
|
|
153
|
-
options[argument] = argv[index];
|
|
154
|
-
} else if (argument === "--json") {
|
|
155
|
-
json = true;
|
|
156
|
-
} else if (argument.startsWith("-")) {
|
|
157
|
-
usage();
|
|
158
|
-
} else {
|
|
159
|
-
positionals.push(argument);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return { options, positionals, json };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function parseProject(argv) {
|
|
166
|
-
const { options, positionals, json } = parseOptions(argv, ["--methods", "-o"]);
|
|
167
|
-
if (positionals.length !== 1 || options["--methods"] === undefined || options["-o"] === undefined) {
|
|
168
|
-
usage();
|
|
169
|
-
}
|
|
170
|
-
const entry = positionals[0];
|
|
171
|
-
const output = options["-o"];
|
|
172
|
-
// `--methods ""` is the empty list, which the library refuses with a
|
|
173
|
-
// diagnostic; any other value is split on commas, verbatim.
|
|
174
|
-
const methods = options["--methods"] === "" ? [] : options["--methods"].split(",");
|
|
175
|
-
return { command: "project", entry, output, methods, json };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function parseCheck(argv) {
|
|
179
|
-
const { options, positionals, json } = parseOptions(argv, ["--against"]);
|
|
180
|
-
if (positionals.length !== 1 || options["--against"] === undefined) {
|
|
181
|
-
usage();
|
|
182
|
-
}
|
|
183
|
-
return { command: "check", written: positionals[0], live: options["--against"], json };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** A diagnostic in the compiler's own item shape. */
|
|
187
|
-
function diagnostic(code, phase, message, extra = {}) {
|
|
188
|
-
return { code, phase, severity: "error", message, ...extra };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* One entry's failure. `diagnostics` is what `--json` reports. Without
|
|
193
|
-
* `--json` it prints as the `{ ok: false, diagnostics }` document on stdout
|
|
194
|
-
* — the native binary's convention — unless `plain` is set, in which case
|
|
195
|
-
* the failure has no document and `plain` is the stderr line it always had.
|
|
196
|
-
*/
|
|
197
|
-
class EntryFailure extends Error {
|
|
198
|
-
constructor(diagnostics, plain) {
|
|
199
|
-
super(diagnostics[0].message);
|
|
200
|
-
this.diagnostics = diagnostics;
|
|
201
|
-
this.plain = plain;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function resourceFailure(resource, limit, observed, message) {
|
|
206
|
-
return new EntryFailure([
|
|
207
|
-
diagnostic("resource_limit_exceeded", "load", message, {
|
|
208
|
-
resource_limit: { resource, limit, observed },
|
|
209
|
-
}),
|
|
210
|
-
]);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Every `.did` under `root`, keyed by `/`-separated path relative to it —
|
|
215
|
-
* bounded before anything is read: file count, per-file bytes (by `stat`),
|
|
216
|
-
* and aggregate bytes are all checked against the compiler's limits first,
|
|
217
|
-
* so the refusal is a diagnostic, not an out-of-memory abort.
|
|
218
|
-
*/
|
|
219
|
-
async function didFiles(root) {
|
|
220
|
-
const candidates = [];
|
|
221
|
-
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
222
|
-
for (const item of entries) {
|
|
223
|
-
if (!item.isFile() || !item.name.endsWith(".did")) {
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
const absolute = path.join(item.parentPath ?? item.path, item.name);
|
|
227
|
-
const relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
228
|
-
candidates.push({ absolute, relative });
|
|
229
|
-
}
|
|
230
|
-
if (candidates.length > MAX_SOURCES) {
|
|
231
|
-
throw resourceFailure(
|
|
232
|
-
"sources",
|
|
233
|
-
MAX_SOURCES,
|
|
234
|
-
candidates.length,
|
|
235
|
-
`the bundle directory holds ${candidates.length} .did files, over the ${MAX_SOURCES}-source limit`,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
let bundleBytes = 0;
|
|
239
|
-
for (const candidate of candidates) {
|
|
240
|
-
const { size } = await stat(candidate.absolute);
|
|
241
|
-
if (size > MAX_SOURCE_BYTES) {
|
|
242
|
-
throw resourceFailure(
|
|
243
|
-
"source_bytes",
|
|
244
|
-
MAX_SOURCE_BYTES,
|
|
245
|
-
size,
|
|
246
|
-
`${candidate.relative} is ${size} bytes, over the ${MAX_SOURCE_BYTES}-byte source limit`,
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
bundleBytes += size;
|
|
250
|
-
if (bundleBytes > MAX_BUNDLE_BYTES) {
|
|
251
|
-
throw resourceFailure(
|
|
252
|
-
"bundle_bytes",
|
|
253
|
-
MAX_BUNDLE_BYTES,
|
|
254
|
-
bundleBytes,
|
|
255
|
-
`the bundle directory exceeds the ${MAX_BUNDLE_BYTES}-byte aggregate limit`,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
const files = {};
|
|
260
|
-
for (const candidate of candidates) {
|
|
261
|
-
files[candidate.relative] = await readFile(candidate.absolute, "utf8");
|
|
262
|
-
}
|
|
263
|
-
return files;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Decision D1 (a recommendation the maintainer can overturn): the bundle
|
|
267
|
-
// root of an entry is the entry's own directory, and the 256-file / 1 MiB /
|
|
268
|
-
// 8 MiB bounds apply to each entry's bundle on its own. Entries that share a
|
|
269
|
-
// directory share one walk here — the tree is read and counted once, not
|
|
270
|
-
// once per entry — so they never double-count against each other.
|
|
271
|
-
//
|
|
272
|
-
// Every path this CLI reports is spelled as the user passed it (an entry, its
|
|
273
|
-
// directory, `-o` joined with a file name), never `path.resolve`d: the same
|
|
274
|
-
// invocation must print the same bytes from any working directory. So the
|
|
275
|
-
// walk reads through the directory as given, and only the memo key resolves.
|
|
276
|
-
const bundles = new Map();
|
|
277
|
-
function bundleOf(directory) {
|
|
278
|
-
const key = path.resolve(directory);
|
|
279
|
-
if (!bundles.has(key)) {
|
|
280
|
-
bundles.set(key, didFiles(directory));
|
|
281
|
-
}
|
|
282
|
-
return bundles.get(key);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* The sources request for one `.did` file: the file and every `.did`
|
|
287
|
-
* beneath its directory, as `gen` reads an entry. Throws `EntryFailure`.
|
|
288
|
-
*/
|
|
289
|
-
async function sourcesFor(file) {
|
|
290
|
-
const directory = path.dirname(file);
|
|
291
|
-
const name = path.basename(file);
|
|
292
|
-
let files;
|
|
293
|
-
try {
|
|
294
|
-
files = await bundleOf(directory);
|
|
295
|
-
} catch (error) {
|
|
296
|
-
if (error instanceof EntryFailure) {
|
|
297
|
-
throw error;
|
|
298
|
-
}
|
|
299
|
-
const message = `cannot read ${directory}: ${error.message}`;
|
|
300
|
-
throw new EntryFailure([diagnostic("did_file_read_error", "load", message)], message);
|
|
301
|
-
}
|
|
302
|
-
if (files[name] === undefined) {
|
|
303
|
-
const message = `cannot read ${file}: no such .did file`;
|
|
304
|
-
throw new EntryFailure([diagnostic("did_source_not_found", "load", message)], message);
|
|
305
|
-
}
|
|
306
|
-
return { entry: name, files };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Where a write to `file` lands, with every symlink resolved: the file's
|
|
311
|
-
* real path when it exists; else a dangling symlink's target, followed; else
|
|
312
|
-
* the deepest existing ancestor's real path joined with the rest. On a
|
|
313
|
-
* case-insensitive file system the real path is the on-disk spelling.
|
|
314
|
-
*/
|
|
315
|
-
async function landing(file) {
|
|
316
|
-
let current = path.resolve(file);
|
|
317
|
-
for (let hops = 0; hops < 40; hops += 1) {
|
|
318
|
-
try {
|
|
319
|
-
return await realpath(current);
|
|
320
|
-
} catch {
|
|
321
|
-
// Not there: a dangling symlink, or a file not written yet.
|
|
322
|
-
}
|
|
323
|
-
let target;
|
|
324
|
-
try {
|
|
325
|
-
target = await readlink(current);
|
|
326
|
-
} catch {
|
|
327
|
-
break;
|
|
328
|
-
}
|
|
329
|
-
current = path.resolve(path.dirname(current), target);
|
|
330
|
-
}
|
|
331
|
-
const rest = [];
|
|
332
|
-
let directory = current;
|
|
333
|
-
while (directory !== path.dirname(directory)) {
|
|
334
|
-
rest.unshift(path.basename(directory));
|
|
335
|
-
directory = path.dirname(directory);
|
|
336
|
-
try {
|
|
337
|
-
return path.join(await realpath(directory), ...rest);
|
|
338
|
-
} catch {
|
|
339
|
-
// Keep climbing to an ancestor that exists.
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
return current;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* What names a file on disk, as far as it can be told without writing:
|
|
347
|
-
* the lexical absolute path, where a write to it lands (`landing`), and the
|
|
348
|
-
* device and inode when the file exists — which is what catches a hard link,
|
|
349
|
-
* and a different spelling on a case-insensitive file system.
|
|
350
|
-
*/
|
|
351
|
-
async function fileIdentity(file) {
|
|
352
|
-
const resolved = path.resolve(file);
|
|
353
|
-
let inode;
|
|
354
|
-
try {
|
|
355
|
-
const found = await stat(file, { bigint: true });
|
|
356
|
-
// A file system without inode numbers reports 0 for every file, which
|
|
357
|
-
// would make every file the same one.
|
|
358
|
-
if (found.ino !== 0n) {
|
|
359
|
-
inode = `${found.dev}:${found.ino}`;
|
|
360
|
-
}
|
|
361
|
-
} catch {
|
|
362
|
-
// Not there (or not statable): only the path forms below can match.
|
|
363
|
-
}
|
|
364
|
-
let real;
|
|
365
|
-
try {
|
|
366
|
-
real = await landing(file);
|
|
367
|
-
} catch {
|
|
368
|
-
real = undefined;
|
|
369
|
-
}
|
|
370
|
-
return { resolved, real, inode };
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function sameFile(a, b) {
|
|
374
|
-
return (
|
|
375
|
-
a.resolved === b.resolved ||
|
|
376
|
-
(a.real !== undefined && a.real === b.real) ||
|
|
377
|
-
(a.inode !== undefined && a.inode === b.inode)
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** Whether `inner` is strictly beneath `outer`; both absolute. */
|
|
382
|
-
function beneath(outer, inner) {
|
|
383
|
-
const relative = path.relative(outer, inner);
|
|
384
|
-
return (
|
|
385
|
-
relative !== "" && !path.isAbsolute(relative) && relative.split(path.sep)[0] !== ".."
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Refuse an output that is, or would become, a source of the input bundle,
|
|
391
|
-
* under any name. `sourcesFor` hands the compiler the entry and every `.did`
|
|
392
|
-
* beneath the entry's directory, so writing one of those files would replace
|
|
393
|
-
* a file the projection was cut from, and writing a new `.did` beneath that
|
|
394
|
-
* directory would make the projection a source of every later run. Throws
|
|
395
|
-
* `EntryFailure` (`output_is_input`), whose `notes` list the existing
|
|
396
|
-
* sources the output names — empty for a file not written yet.
|
|
397
|
-
*/
|
|
398
|
-
async function refuseOutputInBundle(output, directory, files) {
|
|
399
|
-
const target = await fileIdentity(output);
|
|
400
|
-
const sources = [];
|
|
401
|
-
for (const relative of Object.keys(files).sort()) {
|
|
402
|
-
const source = path.join(directory, ...relative.split("/"));
|
|
403
|
-
if (sameFile(target, await fileIdentity(source))) {
|
|
404
|
-
sources.push(source);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
let message;
|
|
408
|
-
if (sources.length > 0) {
|
|
409
|
-
message = `-o ${output} is ${sources.join(", ")}, a source of the input`;
|
|
410
|
-
} else if (
|
|
411
|
-
target.real !== undefined &&
|
|
412
|
-
target.real.endsWith(".did") &&
|
|
413
|
-
beneath(await realpath(directory), target.real)
|
|
414
|
-
) {
|
|
415
|
-
message = `-o ${output} is a .did beneath ${directory}, so it would be a source of the input`;
|
|
416
|
-
}
|
|
417
|
-
if (message !== undefined) {
|
|
418
|
-
throw new EntryFailure([
|
|
419
|
-
diagnostic(
|
|
420
|
-
"output_is_input",
|
|
421
|
-
"write",
|
|
422
|
-
`${message}; write the projection outside ${directory}`,
|
|
423
|
-
{ path: output, notes: sources },
|
|
424
|
-
),
|
|
425
|
-
]);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* An output that could not be read or written, as a failure: the path as
|
|
431
|
-
* given, and the system error code (such as `EISDIR` or `EACCES`) in `notes`.
|
|
432
|
-
*/
|
|
433
|
-
function writeFailure(output, error) {
|
|
434
|
-
const message = `cannot write ${output}: ${error.message}`;
|
|
435
|
-
const notes = typeof error.code === "string" ? { notes: [error.code] } : {};
|
|
436
|
-
return new EntryFailure(
|
|
437
|
-
[diagnostic("output_write_failed", "write", message, { path: output, ...notes })],
|
|
438
|
-
message,
|
|
439
|
-
);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/** An unexpected error as the one failure shape. */
|
|
443
|
-
function asFailure(error) {
|
|
444
|
-
return error instanceof EntryFailure
|
|
445
|
-
? error
|
|
446
|
-
: new EntryFailure([
|
|
447
|
-
{ code: "internal_error", severity: "error", message: String(error.message ?? error) },
|
|
448
|
-
]);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Print a failure the way `gen` does: the `{ ok: false, diagnostics }`
|
|
453
|
-
* document on stdout, or the plain stderr line of a read error; with
|
|
454
|
-
* `--json`, always the document.
|
|
455
|
-
*/
|
|
456
|
-
function printFailure(failure, json, extra = {}) {
|
|
457
|
-
if (failure.plain !== undefined && !json) {
|
|
458
|
-
console.error(failure.plain);
|
|
459
|
-
} else {
|
|
460
|
-
console.log(JSON.stringify({ ok: false, ...extra, diagnostics: failure.diagnostics }, null, 2));
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** `meant project`. Returns the exit code. */
|
|
465
|
-
async function runProject({ entry, output, methods, json }) {
|
|
466
|
-
let result;
|
|
467
|
-
try {
|
|
468
|
-
const sources = await sourcesFor(entry);
|
|
469
|
-
await refuseOutputInBundle(output, path.dirname(entry), sources.files);
|
|
470
|
-
result = await deterministic("projection", () => projectDid(sources, methods));
|
|
471
|
-
} catch (error) {
|
|
472
|
-
printFailure(asFailure(error), json);
|
|
473
|
-
return 1;
|
|
474
|
-
}
|
|
475
|
-
if (!result.ok) {
|
|
476
|
-
console.log(JSON.stringify(result, null, 2));
|
|
477
|
-
return 1;
|
|
478
|
-
}
|
|
479
|
-
const bytes = Buffer.from(result.did);
|
|
480
|
-
let status;
|
|
481
|
-
try {
|
|
482
|
-
status = (await holds(output, bytes)) ? "unchanged" : "written";
|
|
483
|
-
if (status === "written") {
|
|
484
|
-
await mkdir(path.dirname(output), { recursive: true });
|
|
485
|
-
await writeFile(output, bytes);
|
|
486
|
-
}
|
|
487
|
-
} catch (error) {
|
|
488
|
-
printFailure(writeFailure(output, error), json);
|
|
489
|
-
return 1;
|
|
490
|
-
}
|
|
491
|
-
if (json) {
|
|
492
|
-
const { ok, methods: kept, input, projection } = result;
|
|
493
|
-
console.log(JSON.stringify({ ok, output, status, methods: kept, input, projection }, null, 2));
|
|
494
|
-
} else {
|
|
495
|
-
console.log(`input: ${result.input.interface_id}`);
|
|
496
|
-
console.log(`projection: ${result.projection.interface_id}`);
|
|
497
|
-
console.log(`${status === "written" ? "wrote" : "unchanged"} ${output}`);
|
|
498
|
-
}
|
|
499
|
-
return 0;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/** `meant check`. Returns the exit code. */
|
|
503
|
-
async function runCheck({ written, live, json }) {
|
|
504
|
-
let report;
|
|
505
|
-
try {
|
|
506
|
-
const sides = [await sourcesFor(written), await sourcesFor(live)];
|
|
507
|
-
report = await deterministic("check", () => checkCompatible(...sides));
|
|
508
|
-
} catch (error) {
|
|
509
|
-
printFailure(asFailure(error), json);
|
|
510
|
-
return 1;
|
|
511
|
-
}
|
|
512
|
-
if (json || !report.ok) {
|
|
513
|
-
console.log(JSON.stringify(report, null, 2));
|
|
514
|
-
return report.ok && report.compatible ? 0 : 1;
|
|
515
|
-
}
|
|
516
|
-
console.log(`written: ${report.written.interface_id}`);
|
|
517
|
-
console.log(`live: ${report.live.interface_id}`);
|
|
518
|
-
let errors = 0;
|
|
519
|
-
let warnings = 0;
|
|
520
|
-
for (const item of report.diagnostics) {
|
|
521
|
-
const at = item.path === undefined ? "" : ` at ${item.path}`;
|
|
522
|
-
// The one diagnostic that names no method: the methods left once the
|
|
523
|
-
// report reached its byte bound, counted.
|
|
524
|
-
const method = item.method === undefined ? "" : `${listedName(item.method)}: `;
|
|
525
|
-
console.error(`${item.severity}: ${method}${item.code}${at}: ${item.message}`);
|
|
526
|
-
if (item.severity === "error") {
|
|
527
|
-
errors += 1;
|
|
528
|
-
} else {
|
|
529
|
-
warnings += 1;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
console.log(
|
|
533
|
-
`${report.compatible ? "compatible" : "incompatible"}: ${errors} error(s), ${warnings} warning(s)`,
|
|
534
|
-
);
|
|
535
|
-
return report.compatible ? 0 : 1;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* A name as the module header lists it, character for character: bare when
|
|
540
|
-
* identifier-shaped, else quoted exactly as the generator quotes it — `"`,
|
|
541
|
-
* `\\`, `\n`, `\r` and `\t` escaped, every other control character as a
|
|
542
|
-
* lowercase `\u00XX`, and U+2028 / U+2029 as `\u2028` / `\u2029`.
|
|
543
|
-
* `JSON.stringify` is not that: it leaves U+2028 and U+2029 raw — both end
|
|
544
|
-
* a line — and writes `\b` and `\f`. A quoted Candid method name can hold
|
|
545
|
-
* any of them, and each warning must stay one line equal to its header line.
|
|
546
|
-
*/
|
|
547
|
-
function listedName(name) {
|
|
548
|
-
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
|
|
549
|
-
return name;
|
|
550
|
-
}
|
|
551
|
-
let quoted = '"';
|
|
552
|
-
for (const character of name) {
|
|
553
|
-
const code = character.codePointAt(0);
|
|
554
|
-
if (character === '"') {
|
|
555
|
-
quoted += '\\"';
|
|
556
|
-
} else if (character === "\\") {
|
|
557
|
-
quoted += "\\\\";
|
|
558
|
-
} else if (character === "\n") {
|
|
559
|
-
quoted += "\\n";
|
|
560
|
-
} else if (character === "\r") {
|
|
561
|
-
quoted += "\\r";
|
|
562
|
-
} else if (character === "\t") {
|
|
563
|
-
quoted += "\\t";
|
|
564
|
-
} else if (code < 0x20 || code === 0x2028 || code === 0x2029) {
|
|
565
|
-
quoted += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
566
|
-
} else {
|
|
567
|
-
quoted += character;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
return `${quoted}"`;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/** One omission, worded as the module header's `// Omitted:` line. */
|
|
574
|
-
function describeOmission(entry) {
|
|
575
|
-
const kind = entry.kind === "method" ? "method" : "type";
|
|
576
|
-
const via = entry.via === undefined ? "" : ` via ${listedName(entry.via)}`;
|
|
577
|
-
return `${kind} ${listedName(entry.name)} (${entry.reason}${via})`;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* The envelope's identities under the key names `project` and `check` use.
|
|
582
|
-
* `interface_id` is left out, not null, when the source has no actor.
|
|
583
|
-
*/
|
|
584
|
-
function cliIdentities(identities) {
|
|
585
|
-
const out = { contract_id: identities.contract };
|
|
586
|
-
if (identities.interface !== undefined) {
|
|
587
|
-
out.interface_id = identities.interface;
|
|
588
|
-
}
|
|
589
|
-
return out;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/** Run a generation twice and refuse on any byte mismatch. */
|
|
593
|
-
async function deterministic(label, produce) {
|
|
594
|
-
const first = await produce();
|
|
595
|
-
const second = await produce();
|
|
596
|
-
if (JSON.stringify(first) !== JSON.stringify(second)) {
|
|
597
|
-
const message = `determinism check failed: two ${label} runs disagreed; refusing to write`;
|
|
598
|
-
throw new EntryFailure([diagnostic("nondeterministic_output", "generate", message)], message);
|
|
599
|
-
}
|
|
600
|
-
return first;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Whether `file` already holds exactly `bytes`. A file of another size is
|
|
605
|
-
* not read, so comparing costs at most `bytes.length`, however large the
|
|
606
|
-
* file on disk is; a file that does not exist holds nothing.
|
|
607
|
-
*/
|
|
608
|
-
async function holds(file, bytes) {
|
|
609
|
-
let size;
|
|
610
|
-
try {
|
|
611
|
-
({ size } = await stat(file));
|
|
612
|
-
} catch (error) {
|
|
613
|
-
if (error.code === "ENOENT") {
|
|
614
|
-
return false;
|
|
615
|
-
}
|
|
616
|
-
throw error;
|
|
617
|
-
}
|
|
618
|
-
return size === bytes.length && (await readFile(file)).equals(bytes);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/** The file's bytes, or `null` when it does not exist. */
|
|
622
|
-
async function onDisk(file) {
|
|
623
|
-
try {
|
|
624
|
-
return await readFile(file);
|
|
625
|
-
} catch (error) {
|
|
626
|
-
if (error.code === "ENOENT") {
|
|
627
|
-
return null;
|
|
628
|
-
}
|
|
629
|
-
throw error;
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/** Generate one entry and write it, or (with `check`) compare it. Never throws. */
|
|
634
|
-
async function processEntry(entry, { outDir, check }) {
|
|
635
|
-
const stem = stemOf(entry);
|
|
636
|
-
const modulePath = path.join(outDir, `${stem}.ts`);
|
|
637
|
-
const envelopePath = path.join(outDir, `${stem}.envelope.json`);
|
|
638
|
-
const report = {
|
|
639
|
-
entry,
|
|
640
|
-
status: "failed",
|
|
641
|
-
module: modulePath,
|
|
642
|
-
envelope: envelopePath,
|
|
643
|
-
// Set once the entry compiles. Until then it is undefined, which
|
|
644
|
-
// `JSON.stringify` leaves out, so a failed entry has no `identities`; the
|
|
645
|
-
// key is declared here so a compiled entry prints it before `omitted`.
|
|
646
|
-
identities: undefined,
|
|
647
|
-
omitted: [],
|
|
648
|
-
diagnostics: [],
|
|
649
|
-
};
|
|
650
|
-
// What the human path prints for this entry, in order; `--json` drops it.
|
|
651
|
-
const human = { out: [], err: [] };
|
|
652
|
-
const drift = [];
|
|
653
|
-
try {
|
|
654
|
-
// The directory and file name as given: `dirname("a.did")` is ".".
|
|
655
|
-
const sources = await sourcesFor(entry);
|
|
656
|
-
const envelope = await deterministic("contract", () => didToContract(sources));
|
|
657
|
-
if (!("contract" in envelope)) {
|
|
658
|
-
throw new EntryFailure(envelope.diagnostics);
|
|
659
|
-
}
|
|
660
|
-
const generated = await deterministic("module", () => didToModule(sources));
|
|
661
|
-
if (!generated.ok) {
|
|
662
|
-
throw new EntryFailure(generated.diagnostics);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
const artifacts = [
|
|
666
|
-
[modulePath, Buffer.from(generated.module)],
|
|
667
|
-
[envelopePath, Buffer.from(`${JSON.stringify(envelope, null, 2)}\n`)],
|
|
668
|
-
];
|
|
669
|
-
let wrote = false;
|
|
670
|
-
for (const [file, bytes] of artifacts) {
|
|
671
|
-
const existing = await onDisk(file);
|
|
672
|
-
if (existing !== null && existing.equals(bytes)) {
|
|
673
|
-
human.out.push(`unchanged ${file}`);
|
|
674
|
-
} else if (check) {
|
|
675
|
-
drift.push(file);
|
|
676
|
-
human.err.push(`${existing === null ? "missing" : "drifted"} ${file}`);
|
|
677
|
-
} else {
|
|
678
|
-
await mkdir(outDir, { recursive: true });
|
|
679
|
-
await writeFile(file, bytes);
|
|
680
|
-
wrote = true;
|
|
681
|
-
human.out.push(`wrote ${file}`);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
if (!check) {
|
|
685
|
-
const identities = envelope.contract.identities ?? {};
|
|
686
|
-
const lines = [];
|
|
687
|
-
if (identities.contract !== undefined) {
|
|
688
|
-
lines.push(`contract: ${identities.contract}`);
|
|
689
|
-
}
|
|
690
|
-
if (identities.interface !== undefined) {
|
|
691
|
-
lines.push(`interface: ${identities.interface}`);
|
|
692
|
-
}
|
|
693
|
-
human.out.unshift(...lines);
|
|
694
|
-
}
|
|
695
|
-
for (const omission of generated.omitted) {
|
|
696
|
-
human.err.push(`warning: omitted ${describeOmission(omission)}`);
|
|
697
|
-
}
|
|
698
|
-
report.identities = cliIdentities(envelope.contract.identities);
|
|
699
|
-
report.omitted = generated.omitted;
|
|
700
|
-
report.status = drift.length > 0 ? "drifted" : wrote ? "written" : "unchanged";
|
|
701
|
-
} catch (error) {
|
|
702
|
-
const failure = asFailure(error);
|
|
703
|
-
report.diagnostics = failure.diagnostics;
|
|
704
|
-
human.out.length = 0;
|
|
705
|
-
human.err.length = 0;
|
|
706
|
-
drift.length = 0;
|
|
707
|
-
if (failure.plain !== undefined) {
|
|
708
|
-
human.err.push(failure.plain);
|
|
709
|
-
} else {
|
|
710
|
-
human.out.push(JSON.stringify({ ok: false, diagnostics: failure.diagnostics }, null, 2));
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
return { report, human, drift };
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const parsed = parseArguments(process.argv.slice(2));
|
|
717
|
-
|
|
718
|
-
await init();
|
|
719
|
-
|
|
720
|
-
if (parsed.command === "project") {
|
|
721
|
-
process.exitCode = await runProject(parsed);
|
|
722
|
-
} else if (parsed.command === "check") {
|
|
723
|
-
process.exitCode = await runCheck(parsed);
|
|
724
|
-
} else {
|
|
725
|
-
process.exitCode = await runGen(parsed);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/** `meant gen`. Returns the exit code. */
|
|
729
|
-
async function runGen({ entries, outDir, json, check }) {
|
|
730
|
-
const reports = [];
|
|
731
|
-
const drift = [];
|
|
732
|
-
for (const entry of entries) {
|
|
733
|
-
const result = await processEntry(entry, { outDir, check });
|
|
734
|
-
reports.push(result.report);
|
|
735
|
-
drift.push(...result.drift);
|
|
736
|
-
if (!json) {
|
|
737
|
-
for (const line of result.human.out) {
|
|
738
|
-
console.log(line);
|
|
739
|
-
}
|
|
740
|
-
for (const line of result.human.err) {
|
|
741
|
-
console.error(line);
|
|
742
|
-
}
|
|
743
|
-
if (result.report.status === "failed" && result.human.err.length === 0 && entries.length > 1) {
|
|
744
|
-
console.error(`error: ${entry}: failed; its diagnostics are on stdout`);
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
const ok = reports.every((report) => report.status !== "failed" && report.status !== "drifted");
|
|
750
|
-
if (json) {
|
|
751
|
-
const document = { schemaVersion: SCHEMA_VERSION, ok, check, entries: reports, drift };
|
|
752
|
-
console.log(JSON.stringify(document, null, 2));
|
|
753
|
-
} else if (drift.length > 0) {
|
|
754
|
-
console.error(
|
|
755
|
-
`${drift.length} file(s) differ from what the current sources generate; ` +
|
|
756
|
-
"run without --check to regenerate",
|
|
757
|
-
);
|
|
758
|
-
}
|
|
759
|
-
// Not `process.exit`: on some platforms a pipe drains asynchronously, and the
|
|
760
|
-
// document must reach the reader whole.
|
|
761
|
-
return ok ? 0 : 1;
|
|
762
|
-
}
|