@candid-core/cli

The same compiler as WebAssembly: a JavaScript-only on-ramp for Node and the browser.

Published on npm

@candid-core/cli 0.4.0 is on the npm registry under the latest tag, published from this repository by its dispatch-only release workflow, beside the earlier 0.1.0, 0.2.0 and 0.3.0 and, under the beta tag, 0.2.0-beta.1. The registry also holds 0.0.0-bootstrap under the bootstrap tag, which exists only so a trusted publisher could be attached to the name; do not install it. The candid-core binary does the same compilation from Rust.

Upgrading from an earlier release

This page describes the package the repository builds, published on 2026-10-27 as 0.4.0 under the npm latest dist-tag, with @candid-core/schema 0.5.0, which it peers exactly. The previous stable release, 0.3.0, lacks what this page marks as new in 0.4.0: didToDocAttributes and each entry’s identities in gen --json. It also names every use of a blob, record {} or variant {} after the first alias of that shape (the code generator), and writes envelopes whose producer is candid-core 0.1.0-beta.3, where 0.4.0's is meant 0.1.0, the compiler crate published as candid-core before its rename, so 0.4.0's gen --check reports every envelope 0.3.0 wrote as drifted. Migrating from 0.4.0 says what the upgrade means. The stable release before that, 0.2.0, also lacks what this page marks as new in 0.3.0: the project and check commands, with projectDid and checkCompatible, and generated modules that declare each type under its Candid name and each Actor method with its mode. Migrating from 0.3.0 says what that upgrade means, and the package changelog records it under 0.3.0. The stable release before those, 0.1.0, has the old behaviour: it emits modules in the old layout with an object-shaped principal type, exits 1 with ts_generation_refused for an interface with one declaration it cannot represent, and returns no omitted list. Migrating from 0.2.0 shows what each break means, with before and after code, and each is recorded in the package changelog under 0.2.0 and 0.2.0-beta.1. The beta dist-tag still names 0.2.0-beta.1, which lacks the changes recorded under 0.2.0 and every stable release since.

bash
# 0.4.0, the release this page describes (see the note above)
npx @candid-core/[email protected] gen ./service.did -o ./generated

Here is what the package is and what it does. Everything candid-core does to a .did file normally happens in Rust. @candid-core/cli is that same Rust code — the compiler's compiler feature surface plus the candid-core-ts TypeScript generator — compiled once to WebAssembly and wrapped in a small Node CLI and an ES module. A JavaScript project can therefore go from a Candid interface to typed, validated schemas with npm as its only toolchain.

It is not a reimplementation, and that is the point of its test suites: the same crates are compiled into one wasm artifact, and CI compares the artifact's output byte-for-byte against the reviewed goldens and against a document the native binary actually produced. The crate that produces it sets publish = false and is its own workspace root, so it never appears in the published Rust crate's dependency graph; the npm package is the deliverable.

What is in the package#

Field Value
Name and version @candid-core/cli, 0.4.0 — published on npm under latest
Binary candid-core-cli → ./bin/cli.js
Library entry point "." → ./lib/index.js, under a types condition. ./package.json is the only other entry in the map
Module format "type": "module" — ESM only, no CommonJS require path
Runtime dependencies None. @candid-core/schema is declared as an optional peer at exactly 0.5.0 (new in 0.4.0) — every module the CLI emits imports from it, but a consumer calling only didToContract never loads it. The modules this page's generator emits compile against 0.4.0 and 0.5.0 alike and need the WithMode export that 0.3.0 lacks; while the two packages move in lockstep every schema release is paired with a CLI release that names it exactly. The published 0.3.0 declares exactly 0.4.0, 0.2.0 exactly 0.3.0, 0.2.0-beta.1 exactly 0.3.0-beta.1, and 0.1.0 still declares ^0.2.0. The one devDependency, playwright-core, drives the browser test
Types A hand-written lib/index.d.ts, reached through the types condition. The wasm-pack generated declarations are deliberately not shipped: they name BufferSource, URL, Response and WebAssembly, which do not resolve without the DOM library
Licence Apache-2.0

The command#

gen, and these options, nothing else (the usage's first line; project and check, new in 0.3.0, are under Projection and compatibility):

textcrates/candid-core-wasm/npm/bin/cli.js
usage: candid-core-cli gen <service.did>... [-o <dir>] [--json] [--check]
Several entries, --json and --check are not in 0.1.0

The first stable release, 0.1.0, takes exactly one entry and neither flag: gen <service.did> [-o <dir>]. Several entries, --json and --check, described in their own section below, shipped in 0.2.0-beta.1 and are in every stable release since 0.2.0. Everything else on this page describes both.

It writes two files and prints the identities:

  • <stem>.ts — the generated @candid-core/schema module: one reviewed type alias and one schema builder per declaration.
  • <stem>.envelope.json — the one-document ContractEnvelope: the canonical Contract plus its field-name table under the org.candid-core.field-names/v1 extension, ready to hand whole to schemaFromContract.
  • the content-addressed identities, on stdout. The interface: line appears only when the interface identity is present, which means only when the source has an actor.

Run over the repository's two-line basic.did fixture — the same one shown on the candid-core binary page — with -o generated, stdout is four lines and the exit status is 0:

text
contract:  candid-core:contract:v1:sha256:0b553cb65eb436a7ac5d35869d0016d043867bab0a97e844350ae72a5b4aea7d
interface: candid-core:interface:v1:sha256:99f400820c0ca2fb7c32cc3ab1df6d6c000bc614cffe0e350b6a9a576da18d48
wrote generated/basic.ts
wrote generated/basic.envelope.json

Those are the real identities for that fixture, and they are the same addresses the Rust binary computes — a test compares the emitted envelope against the native binary's committed output byte for byte. The stem comes from the entry file's name, so service.did would produce service.ts and service.envelope.json. -o defaults to "." — the current working directory, not the entry file's directory — and the output directory is created if it does not exist.

How imports are found#

The wasm side never touches a filesystem, so the Node host does all the I/O. gen walks the entry file's directory recursively, collects every .did beneath it keyed by its /-separated relative path, and hands that map to the compiler as one in-memory bundle. Relative imports then resolve inside the bundle with no filesystem access from wasm. The consequence to plan for: unrelated sibling .did files under the same directory are pulled in too. The parity tests copy each fixture into an otherwise-empty temporary directory precisely so the walk cannot smuggle unrelated sources into provenance.

The walk is bounded before anything is read. File count, per-file size (through stat) and aggregate size are checked against the compiler's own default limits — 256 sources, 1 MiB per file, 8 MiB in total — so an oversized tree fails with a structured diagnostic rather than by exhausting the JavaScript heap:

json
{
  "ok": false,
  "diagnostics": [
    {
      "code": "resource_limit_exceeded",
      "phase": "load",
      "severity": "error",
      "message": "huge.did is 1048577 bytes, over the 1048576-byte source limit",
      "resource_limit": { "resource": "source_bytes", "limit": 1048576, "observed": 1048577 }
    }
  ]
}

Inside a file, the compiler refuses a run of more than 256 consecutive comments between two tokens, line and block comments alike, with resource_limit_exceeded and resource source_nesting (limit 256, observed 257): the upstream tokenizer spends a stack frame on each comment it skips, so the run is counted first. This is new in 0.2.0; earlier releases compiled such a run up to a stack-dependent length and then failed (internal_error in 0.2.0-beta.1, an uncaught RangeError in 0.1.0). A license header written as hundreds of // lines is the likely case: write it as one /* */ block, which counts as one comment. The CLI has no flag to raise the limit. Limits lists it with the others.

Exit codes#

They follow the native binary's convention exactly.

Exit When Where the output goes
0 Both files written, including a module that omits declarations Identities and two wrote lines on stdout; one warning: omitted … line per omission on stderr
1 Compile or generation failure The { "ok": false, "diagnostics": […] } document on stdout
1 Entry file missing or unreadable, or a determinism mismatch A plain message on stderr — the two paths that print no JSON
1 With several entries, any one entry failed; with --check, a file is missing or has drifted As above, per entry; with --json, everything is in the one document on stdout
64 Usage error, including two entries that would write the same output stem The usage line on stderr, nothing on stdout

That determinism path is unusual enough to name. Every generation is run twice and the results compared; on any byte mismatch the tool prints determinism check failed: two <label> runs disagreed; refusing to write and exits without writing anything. Determinism is enforced at run time, not only asserted in CI.

Several entries, --json and --check#

Since 0.2.0-beta.1 (0.1.0 takes one entry and neither flag). Each entry generates its own <stem>.ts and <stem>.envelope.json into the one -o, byte for byte what a run of that entry alone writes, and a failing entry does not stop the others: every entry is attempted, and the exit status is 1 if any failed. Omissions never fail an entry. Two rules are recommendations the maintainer may still overturn: an entry's bundle is the .did files beneath its own directory, with the limits above applied per entry (entries sharing a directory share one read of it); and two entries with the same stem, compared case-insensitively, are a usage error before any work. An output that already holds the generated bytes is not rewritten, and is reported unchanged. The CLI never reads stdin and never writes outside -o.

--check generates in memory and compares byte for byte with the files on disk. It writes nothing, not even the output directory, and exits 1 if any file is missing or differs, which makes it the CI gate for committed output.

--json prints exactly one document on stdout, with stderr empty; combine it with --check. Each compiled entry carries its content-addressed identities. A usage error prints no document.

identities in --json are new in 0.4.0

0.3.0 and earlier leave the identities out of the --json document; read them from the envelope's contract.identities there. 0.4.0, published on 2026-10-27, adds each entry's identities. Every other field below is the same in 0.3.0.

json
{
  "schemaVersion": 1,
  "ok": true,
  "check": false,
  "entries": [
    {
      "entry": "a/service.did",
      "status": "written",
      "module": "generated/service.ts",
      "envelope": "generated/service.envelope.json",
      "identities": {
        "contract_id": "candid-core:contract:v1:sha256:…",
        "interface_id": "candid-core:interface:v1:sha256:…"
      },
      "omitted": [],
      "diagnostics": []
    }
  ],
  "drift": []
}

status is written, unchanged, drifted (--check only) or failed. identities is { contract_id, interface_id }, equal to the envelope's contract.identities under the key names project and check use; interface_id is absent when the source has no actor, and a failed entry has no identities. omitted is the list didToModule returns, and diagnostics is the compiler's own, unchanged in shape; ok is true exactly when the exit status is 0; drift lists the paths --check found missing or different. schemaVersion changes only when an existing field's meaning or shape does. The types ship as CliReport, CliEntryReport and CliIdentities.

The library API#

Six exports, and the whole surface: didToContract, didToModule, didToDocAttributes and init here, and projectDid and checkCompatible under Projection and compatibility (didToDocAttributes is new in 0.4.0; projectDid and checkCompatible are new in 0.3.0; 0.2.0 has didToContract, didToModule and init). didToContract, didToModule and didToDocAttributes each take one JSON-serializable request; projectDid and checkCompatible take two arguments, described in that section. Every operation returns one parsed JSON document, and nothing is thrown for a data error.

didToContract
jscrates/candid-core-wasm/npm/lib/index.js
export async function didToContract(sources)

Compiles Candid sources into a one-document ContractEnvelope carrying the org.candid-core.field-names/v1 extension — the same document the native compiler's compile <path> --envelope emits. sources is either a string of Candid text or { entry, files: { name: text } } for a bundle. On success it returns the envelope itself, so you test "contract" in result; on failure it returns { ok: false, diagnostics }.

didToModule
jscrates/candid-core-wasm/npm/lib/index.js
export async function didToModule(sources)

Generates the @candid-core/schema TypeScript module for the same two source shapes. Returns { ok: true, module, omitted }, where module is the generated text and omitted lists what it left out (empty when nothing; see Omissions), or { ok: false, diagnostics }.

didToDocAttributes
jscrates/candid-core-wasm/npm/lib/index.js
export async function didToDocAttributes(sources)

New in 0.4.0. Compiles the same two source shapes and reads the @name(key = value, …) attributes in their doc comments. Returns { ok: true, contract, source_info, doc_attributes } — the Contract, the SourceInfo sidecar, which no other function of the package exposes, and every documented item's prose and attributes (see Doc attributes) — or { ok: false, diagnostics }.

init
jscrates/candid-core-wasm/npm/lib/index.js
export function init(input)

Initializes the embedded wasm module, memoized, so calling it again is a no-op. Under Node with no argument it reads the .wasm bytes from inside the package; in a browser with no argument the artifact is fetched relative to the module, and a browser may instead pass its own BufferSource, URL or Response. The functions above await it internally, so calling it yourself is optional.

The two functions signal success differently

didToModule returns { ok: true, module, omitted }. didToContract does not carry an ok key on success — it returns the envelope document, and you check for "contract" in result. This mirrors the native binary, where compile --envelope prints the envelope rather than an ok-wrapped response. Only failures share one shape.

The examples below import from the published package; from a clone, import crates/candid-core-wasm/npm/lib/index.js directly.

js
import { didToContract, didToModule } from "@candid-core/cli";

const envelope = await didToContract("service : { ping : () -> () };");
// → the ContractEnvelope document ("contract" in envelope), or
//   { ok: false, diagnostics } with the compiler's diagnostics verbatim.

const generated = await didToModule({
  entry: "main.did",
  files: {
    "main.did": 'import "types.did";\nservice : { get : () -> (Item) query };',
    "types.did": "type Item = record { id : nat };",
  },
});
// → { ok: true, module, omitted } with the generated TypeScript text and
//   what it left out (empty here), or { ok: false, diagnostics }.

The envelope is designed to need no glue on the way out. schemaFromContract accepts either a bare Contract or an envelope — it recognises the envelope by its contract key — and consumes the field-name table itself, which is what lets record and variant fields come back with their source spellings instead of numeric label hashes:

js
import { schemaFromContract } from "@candid-core/schema/contract";

const didText = "type Payload = record { owner : principal; amount : nat };";
const built = schemaFromContract(await didToContract(didText));

Omissions#

A declaration the generator cannot represent costs only itself and what depends on it. It is left out together with every declaration and actor method that references it — through nested func and service types too, up to the containing declaration — and the module is still a success: gen writes it and exits 0, printing one line per omission on stderr, worded exactly as the module header lists it:

text
warning: omitted type Bad (reserved_field_name)
warning: omitted type Registry (references_omitted via Bad)
warning: omitted method bad (references_omitted via Bad)

didToModule returns the same list as omitted: an array of { kind, name, reason, via? }, declarations first, then methods, each sorted by name. kind is "declaration" or "method" (a method of the actor's service, dropped from both actor and Actor). reason is one of a closed set — reserved_field_name, ambiguous_variant_arm, reserved_export_name, invalid_declaration_name (Contract documents only) and references_omitted — explained on the code generator page; via, present only for references_omitted, names the omitted declaration referenced. Everything the module still emits is exactly what it would be had the omitted declarations never been written, and schemaFromContract omits the same entries from the envelope.

Doc attributes#

New in 0.4.0

didToDocAttributes and the types it returns are new in 0.4.0, published on 2026-10-27; the package changelog records them under 0.4.0. 0.3.0 has no function that reads doc comments or exposes the SourceInfo sidecar.

A doc comment line that starts with @, a lowercase name, then ( or nothing is an attribute, and the rest of the comment stays prose. The syntax is specified in docs/doc-attributes.md (doc attributes v1):

candidcrates/candid-core-wasm/npm/README.md
service : {
  /// Send tokens to another principal.
  /// @intent(title = "Send", template = "Send {amount} to {to}")
  /// @amount(at = "amount", decimals = query(decimals))
  send : (record { to : principal; amount : nat }) -> ();
}
js
import { didToDocAttributes } from "@candid-core/cli";

const didText = `service : {
  /// Send tokens to another principal.
  /// @amount(at = "amount", decimals = query(decimals))
  send : (record { to : principal; amount : nat }) -> ();
}`;

const result = await didToDocAttributes(didText);
if (result.ok) {
  for (const item of result.doc_attributes.items) {
    // item.path is "actor.methods[0]"; item.prose is ["Send tokens to another principal."]
    for (const attribute of item.attributes) console.log(item.path, attribute.name, attribute.args);
  }
}

Each documented item — the actor, a declaration, a field or variant case, a method — is keyed by its path (actor, type:<Name>, or the occurrence path SourceInfo records) and carries its prose lines and its attributes in order. An attribute is { name, doc_line, args }, and each argument value is tagged: text, number (the digits as written, never a float), bool, ident, call (query(decimals)), list or record. Once a line is an attribute it must parse to its end, so a malformed one fails the call with doc_attribute_malformed, and a repeated key with doc_attribute_duplicate_key; each names its item's path. The compiler gives no name a meaning: the tool that reads the attributes decides which names it knows. Comments never move contract_id or interface_id.

Failures#

Data errors never throw and never half-succeed. Every failure is { ok: false, diagnostics: [...] }. Compiler diagnostics pass through verbatim with their stable codes — did_parse_error, resolver refusals such as did_source_scheme_mismatch, resource bounds. Two codes originate in this layer: invalid_request (phase load) when the request document is neither of the two accepted shapes, and ts_generation_refused (phase generate), kept for a generator refusal of an invalid Contract graph — which a Contract compiled from Candid source never is, so no .did reaches it: what cannot be represented is omitted instead. A matrix of nine malformed requests — including "not json", "{}", and a request carrying both source and entry — is pinned to return invalid_request rather than panicking. The codes projectDid and checkCompatible add are listed with them below.

Projection and compatibility#

New in 0.3.0

project, check, projectDid and checkCompatible are new in 0.3.0, published on 2026-10-10; the package changelog records them under 0.3.0. The previous release, 0.2.0, has none of them: it exits 64 with its usage for either command.

Two commands for an interface you do not own, such as a canister's published .did. Both read files already on disk; neither fetches anything, so getting the live interface is the caller's job.

text
candid-core-cli project &lt;in.did&gt; --methods &lt;a,b,...&gt; -o &lt;out.did&gt; [--json]
candid-core-cli check &lt;written.did&gt; --against &lt;live.did&gt; [--json]

project writes a self-contained .did holding only the named methods and every declaration they reach, with their doc comments and argument names, and prints the interface identities of the input and of the projection. The same input and the same set of names give the same bytes. gen on the result emits an Actor listing only those methods, each with its mode. Each distinct unknown name fails with one unknown_method, and the failure's first diagnostic lists the service's methods in its notes, once; an empty list fails with empty_method_list. The unknown-method diagnostics are bounded at 4 MiB in all, each counted as the JSON writes it, the list of methods aside: past that bound the names left are counted in one resource_limit_exceeded diagnostic (projection_diagnostic_bytes) instead of reported. A projection must compile again, so it is at most 1 MiB, the compiler's bound on one source; a larger one fails with resource_limit_exceeded (projection_bytes) as soon as writing it reaches that bound. Every name, requested or reached, is found by binary search among names sorted once. -o may not be, or become, a source of the input (the file and every .did beneath its directory): naming one by any path or link, or a new .did beneath that directory, fails with output_is_input and writes nothing, so write the projection outside the input's directory. An output that cannot be written fails with output_write_failed.

check exits 0 when the live service is still a Candid subtype of the written one and 1 otherwise: every written method must exist in the live service with the same mode, arguments contravariant and results covariant. Methods only the live service has are ignored. Each finding names its method, a stable code (method_missing, mode_changed, method_incompatible, the warning special_opt_rule, or resource_limit_exceeded) and the path into the type where the check failed, such as $results[0].Err.TooOld. A special_opt_rule warning is reported at every path whose value decodes as null along which no pair of types repeats. A hand-written subset .did is as valid a written side as a projection.

The check is bounded per method (384 pairs on one path, 1,000,000 steps, 1,000 reported warnings), a step being one unit of work done: a pair of types visited, a field, arm, method or value examined or passed over, or one path segment a warning or a failure copies, or a warning re-examines. A step reads no name: service methods are paired by ranks computed once per check, and a field's name is read by its position. So a step's cost does not grow with the length of the names: it does a bounded amount of work, and its lookups in the maps of type pairs the walk keeps take time logarithmic in their size. It is bounded too, so that a service with many methods cannot multiply those bounds, as a whole: 10,000,000 steps, one more per method decided (check_total_steps), 10,000 special_opt_rule warnings (check_total_warnings) and 4 MiB of reported diagnostics, each counted as the JSON response writes it, keys and indentation included (check_output_bytes), measured only up to the first string that passes that bound. Methods are checked in name order. When the check reaches its step or warning bound, the method at which it did and every method after it fail closed: each gets one resource_limit_exceeded error naming the bound, with its limit and observed value, and nothing else. Those count against the 4 MiB like any other diagnostic: once a method's diagnostics would pass it, that method and every method after it are counted in one last resource_limit_exceeded error (check_output_bytes), which names no method and holds the count in unreported_methods. Either way the check exits 1. Below these bounds a method's findings do not depend on the other methods; once one is reached, they do. Without them, a 10-level shared two-field type reused by 100 methods turned a 5.3 KB request into a 27.8 MB response; with them it is 2.8 MB, and however many methods the written side has, a response is at most 4 MiB and 1,016 bytes. So the walk's time grows with its steps (times that logarithm) and the reported diagnostics' with their bytes; compiling both sides, within the compiler's own limits, and ranking the method names are done once per check, before the walk.

The check is one Rust implementation, compiled into the same WebAssembly. Its tests compare it with the subtype check of the pinned candid crate on hand-written cases and a seeded random campaign, and with the subtype relation @candid-core/schema's decoder runs on a reference. The one recorded disagreement is a candid crate defect: its coinductive memo can keep a pair proven under an assumption it later retracted, and accept a pair that is not a subtype. This check and the decoder refuse it.

In a browser#

The artifact is built for the web target, not nodejs, and one artifact serves both hosts — under Node the wrapper supplies the bytes itself. The wasm side evaluates no code, opens no network connection and touches no filesystem: it sees only the strings handed to it. The wrapper's one node: import is a dynamic node:fs/promises inside init, reached only when it detects Node and reads the packaged .wasm; a page never takes that branch, which is what the headless-Chrome test below exercises. Candid imports are resolved from the map you hand over, so a page that already fetched its .did sources can compile them in place.

A headless-Chrome test in the repository serves the wasm and the schema runtime over HTTP and runs this, asserting that the envelope-carried names really do become the schema's keys — a value keyed owner/amount validates:

jscrates/candid-core-wasm/npm/test/browser/smoke.test.js
import init, { didToContract } from "/wasm/candid_core_wasm.js";
import { schemaFromContract } from "/schema/contract.js";
import { validate } from "/schema/validate.js";

await init();
const envelope = JSON.parse(didToContract(JSON.stringify({ source: didText })));
const built = schemaFromContract(envelope);
// built.schemas.Payload now validates { owner: <a principal>, amount: 5n }

Note the shapes there: importing the wasm module directly gives you the raw wasm-bindgen exports, which are string in, string out. The whole wasm ABI is five functions wide in 0.4.0, four in 0.3.0 and two in 0.2.0 (didToDocAttributes is new in 0.4.0, projectDid and checkCompatible in 0.3.0) — didToContract(request: string): string, didToModule(request: string): string, didToDocAttributes(request: string): string, projectDid(request: string): string and checkCompatible(request: string): string — and every richer shape lives in the JSON documents, which is what keeps the boundary reviewable. Importing from the package instead gives you the wrapper that does the JSON encoding for you.

Each request is one JSON document. didToContract and didToModule take a sources request: { "source": "<did text>" }, or { "entry": "<name>", "files": { "<name>": "<did text>" } } for a bundle. projectDid takes a sources request with "methods": ["a", "b"] added. checkCompatible takes { "written": <sources request>, "live": <sources request> }. Each response is one JSON document too, the same documents the wrapper returns parsed: the envelope from didToContract; { ok: true, module, omitted } from didToModule; { ok: true, did, methods, input, projection } from projectDid, where input and projection each hold a contract_id and an interface_id; and { ok: true, compatible, written, live, diagnostics } from checkCompatible. Every failure is { ok: false, diagnostics }. A checkCompatible failure caused by one side adds input, naming that side, "written" or "live", when it does not compile or declares no service; a malformed request carries no input.

Building it from a clone#

The wasm/ directory is a build output and is not checked in, so the build comes first. The package's own build script is the exact command:

jsoncrates/candid-core-wasm/npm/package.json
"scripts": {
  "build": "cd .. && wasm-pack build --target web --out-dir npm/wasm --no-pack --release",
  "test": "node --test test/*.test.js",
  "test:browser": "node --test test/browser/*.test.js"
}
  1. Install the pinned wasm-pack and the wasm target#

    bash
    cargo install wasm-pack --version 0.14.0 --locked
    rustup target add wasm32-unknown-unknown
  2. Build the artifact#

    bash
    cd crates/candid-core-wasm/npm
    npm run build
  3. Run the CLI and its suite#

    bash
    npm ci
    node bin/cli.js gen ./service.did -o ./out
    npm test

    node bin/cli.js gen … is exactly how the tests invoke it, so it is the verified local entry point. npm test runs the byte-for-byte parity suite.

CI runs the same build with -- --locked appended, under the exact-pinned release toolchain rather than the rolling stable of ordinary jobs. wasm-opt is deliberately disabled so the artifact's bytes are a pure function of the pinned rustc and wasm-bindgen, with no binaryen download anywhere in the build path.

What the parity gate actually asserts#

The wasm CLI workflow runs three jobs, and it is worth being precise about what each one establishes, because "there is a gate" and "there is a released package" are different claims.

Job What it asserts
host Format, clippy with warnings denied, and the Rust parity tests: every golden fixture reproduces its reviewed .ts module byte for byte; the emitted envelope equals the committed fixture the native candid-core compile --envelope binary produced, byte for byte; the field-name triples equal the generator's reviewed *.names.json goldens for five fixtures; identical requests produce identical responses.
artifact The same comparisons again through the real WebAssembly under Node — the npm suite runs bin/cli.js, which loads the compiled wasm — plus artifact determinism: hash the build, cargo clean, delete the output, rebuild, and require the hashes to match.
browser didToContract inside a pinned headless Chrome build, feeding schemaFromContract, with envelope-carried field names rendering as schema keys.

What that buys you is specific: for those fixtures, at that commit, the WebAssembly path and the native Rust path emit the same bytes, and the artifact is reproducible from its pinned inputs. What it does not buy you is a published version: the gates run on a commit and publish nothing, and a version reaches the registry only when its release workflow is dispatched, as 0.1.0 was. Nor does it buy a promise about versions beyond the tested set. The version pairing is a revision pairing: the package embeds the compiler and generator from the exact commit a release is dispatched from, and the changelog names the revisions per release. Read the changelog for the pairing rather than assuming a crate version.

Pre-1.0, on both sides

Until 1.0 any release may change the CLI grammar, the library API, and the request and response shapes — the same warning the Rust crate carries. Pin an exact version.

Next#

Schemas from a Contract document shows the TypeScript side consuming the envelope this package writes. The candid-core binary produces the same envelope document from Rust. For where each unit stands, see Status, versions and releases.