import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import ts from "typescript";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const wasmTypesPath = path.join(
rootDir,
"dist",
"st",
"crates",
"miden_client_web.d.ts"
);
const indexJsPath = path.join(rootDir, "js", "index.js");
const requiredFiles = [wasmTypesPath, indexJsPath];
const missingFiles = [];
for (const filePath of requiredFiles) {
try {
await access(filePath);
} catch {
missingFiles.push(filePath);
}
}
if (missingFiles.length > 0) {
console.error(
"Method classification check failed because expected files are missing. Run `make build-web-client` first."
);
for (const filePath of missingFiles) {
console.error(`- ${filePath}`);
}
process.exit(1);
}
function extractWasmMethods(sourceText, filePath) {
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
const methods = new Set();
const visit = (node) => {
if (
ts.isClassDeclaration(node) &&
node.name &&
node.name.text === "WebClient"
) {
for (const member of node.members) {
if (
ts.isMethodDeclaration(member) &&
member.name &&
ts.isIdentifier(member.name)
) {
methods.add(member.name.text);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return methods;
}
function extractClassifications(sourceText, filePath) {
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const wanted = new Set(["SYNC_METHODS", "WRITE_METHODS", "READ_METHODS"]);
const sets = {};
const visit = (node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
wanted.has(node.name.text) &&
node.initializer &&
ts.isNewExpression(node.initializer) &&
ts.isIdentifier(node.initializer.expression) &&
node.initializer.expression.text === "Set" &&
node.initializer.arguments?.length === 1 &&
ts.isArrayLiteralExpression(node.initializer.arguments[0])
) {
const entries = new Set();
for (const element of node.initializer.arguments[0].elements) {
if (ts.isStringLiteral(element)) {
entries.add(element.text);
continue;
}
console.error(
`${node.name.text} in ${filePath} contains an entry that is not a ` +
`plain string literal: ${element.getText(sourceFile)}\n\n` +
"This check reads the three sets statically, so it cannot see the " +
"real membership of a spread or a computed entry. Skipping it " +
'would let `...["someMethod"]` place a borrow-holding method in ' +
"SYNC_METHODS with this check still reporting green. List every " +
"entry as a literal."
);
process.exit(1);
}
sets[node.name.text] = entries;
return;
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return {
syncMethods: sets.SYNC_METHODS || new Set(),
writeMethods: sets.WRITE_METHODS || new Set(),
readMethods: sets.READ_METHODS || new Set(),
};
}
const rustSrcDir = path.join(rootDir, "src");
const DIRECT_BORROW =
/get_mut_inner|self\s*\.\s*inner\s*\.\s*(?:lock|borrow(?:_mut)?)\s*\(/;
const SIBLING_BORROW =
/self\s*\.\s*(?!inner\b)[a-z_][a-z0-9_]*\s*\.\s*(?:lock|borrow(?:_mut)?)\s*\(/i;
const SELF_CALL = /self\s*\.\s*([a-z_][a-z0-9_]*)\s*\(/gi;
async function collectRustFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectRustFiles(full)));
} else if (entry.name.endsWith(".rs")) {
files.push(full);
}
}
return files;
}
function extractBlock(source, from) {
const open = source.indexOf("{", from);
if (open === -1) return null;
let depth = 0;
for (let i = open; i < source.length; i++) {
const ch = source[i];
const next = source[i + 1];
if (ch === "/" && next === "/") {
i = source.indexOf("\n", i);
if (i === -1) break;
continue;
}
if (ch === "/" && next === "*") {
const end = source.indexOf("*/", i + 2);
if (end === -1) break;
i = end + 1;
continue;
}
if (ch === '"') {
i++;
while (i < source.length && source[i] !== '"') {
if (source[i] === "\\") i++;
i++;
}
continue;
}
if (ch === "'") {
const isCharLiteral = /^'(\\.|[^\\'])'/.test(source.slice(i));
if (isCharLiteral) {
i += source[i + 1] === "\\" ? 3 : 2;
}
continue;
}
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return source.slice(open, i + 1);
}
}
return null;
}
function selfCallees(body) {
const callees = new Set();
SELF_CALL.lastIndex = 0;
let match;
while ((match = SELF_CALL.exec(body)) !== null) callees.add(match[1]);
return callees;
}
function hasBody(source, start) {
let depth = 0;
for (let i = start; i < source.length; i += 1) {
const char = source[i];
if (char === "(" || char === "[") depth += 1;
else if (char === ")" || char === "]") depth -= 1;
else if (depth === 0) {
if (char === "{") return true;
if (char === ";") return false;
}
}
return false;
}
function collectFnBodies(sources) {
const bodies = new Map();
for (const source of sources) {
const fnPattern = /\bfn\s+([a-z_][a-z0-9_]*)\s*[(<]/gi;
let match;
while ((match = fnPattern.exec(source)) !== null) {
const from = match.index + match[0].length;
if (!hasBody(source, match.index)) continue;
const body = extractBlock(source, from);
if (body === null) continue;
if (!bodies.has(match[1])) bodies.set(match[1], []);
bodies.get(match[1]).push(body);
}
}
return bodies;
}
function resolveBorrowingFns(fnBodies, directRegex) {
const borrowing = new Set();
for (const [name, bodies] of fnBodies) {
if (bodies.some((body) => directRegex.test(body))) borrowing.add(name);
}
for (let changed = true; changed; ) {
changed = false;
for (const [name, bodies] of fnBodies) {
if (borrowing.has(name)) continue;
const reachesBorrower = bodies.some((body) =>
[...selfCallees(body)].some((callee) => borrowing.has(callee))
);
if (reachesBorrower) {
borrowing.add(name);
changed = true;
}
}
}
return borrowing;
}
function bodyBorrows(body, borrowingFns, directRegex) {
if (directRegex.test(body)) return true;
return [...selfCallees(body)].some((callee) => borrowingFns.has(callee));
}
async function extractBorrowingExports() {
const files = await collectRustFiles(rustSrcDir);
const sources = await Promise.all(
files.map((file) => readFile(file, "utf8"))
);
const fnBodies = collectFnBodies(sources);
const borrowingFns = resolveBorrowingFns(fnBodies, DIRECT_BORROW);
const siblingBorrowingFns = resolveBorrowingFns(fnBodies, SIBLING_BORROW);
const borrows = new Map();
for (const [index, file] of files.entries()) {
const source = sources[index];
const attrPattern = /#\[js_export\([^)]*js_name\s*=\s*"([^"]+)"[^)]*\)\]/g;
let match;
while ((match = attrPattern.exec(source)) !== null) {
const jsName = match[1];
const body = extractBlock(source, match.index + match[0].length);
if (body === null) continue;
const seen = borrows.get(jsName);
const thisBorrows = bodyBorrows(body, borrowingFns, DIRECT_BORROW);
const thisBorrowsSibling = bodyBorrows(
body,
siblingBorrowingFns,
SIBLING_BORROW
);
if (seen?.borrows && !thisBorrows) continue;
borrows.set(jsName, {
borrows: thisBorrows,
borrowsSibling: thisBorrowsSibling,
file: path.relative(rootDir, file),
});
}
}
return borrows;
}
function extractExplicitMethods(sourceText, filePath) {
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const methods = new Set();
const visit = (node) => {
if (
ts.isClassDeclaration(node) &&
node.name &&
(node.name.text === "WebClient" || node.name.text === "MockWebClient")
) {
for (const member of node.members) {
if (
ts.isMethodDeclaration(member) &&
member.name &&
ts.isIdentifier(member.name)
) {
methods.add(member.name.text);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return methods;
}
const wasmTypesSource = await readFile(wasmTypesPath, "utf8");
const indexJsSource = await readFile(indexJsPath, "utf8");
const wasmMethods = extractWasmMethods(wasmTypesSource, wasmTypesPath);
const { syncMethods, writeMethods, readMethods } = extractClassifications(
indexJsSource,
indexJsPath
);
const explicitMethods = extractExplicitMethods(indexJsSource, indexJsPath);
const classified = new Set([
...syncMethods,
...writeMethods,
...readMethods,
...explicitMethods,
]);
const allowedUnclassified = new Set([
"new",
"free",
"serialize",
"deserialize",
"createClient",
"createClientWithExternalKeystore",
"createMockClient",
"syncStateImpl",
"syncChainImpl",
"syncNoteTransportImpl",
]);
const unclassified = [...wasmMethods].filter(
(name) => !classified.has(name) && !allowedUnclassified.has(name)
);
if (unclassified.length > 0) {
console.error(
"The following WASM methods are not classified in SYNC_METHODS, WRITE_METHODS, READ_METHODS, or as explicit wrapper methods in index.js:"
);
unclassified.sort().forEach((name) => console.error(` - ${name}`));
console.error(
"\nAdd each method to the appropriate set in js/index.js, or add an explicit wrapper method on the WebClient class."
);
process.exit(1);
}
const membership = new Map();
for (const [setName, names] of [
["SYNC_METHODS", syncMethods],
["WRITE_METHODS", writeMethods],
["READ_METHODS", readMethods],
]) {
for (const name of names) {
if (!membership.has(name)) membership.set(name, []);
membership.get(name).push(setName);
}
}
const duplicates = [...membership.entries()].filter(
([, sets]) => sets.length > 1
);
if (duplicates.length > 0) {
console.error(
"The following methods appear in more than one classification set in js/index.js:"
);
duplicates
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([name, sets]) =>
console.error(` - ${name}: ${sets.join(", ")}`)
);
console.error(
"\nEach method belongs to exactly one set. Only SYNC_METHODS is consulted at runtime, so a duplicate there silently keeps the raw binding."
);
process.exit(1);
}
const borrowingExports = await extractBorrowingExports();
const allowedRawBorrowers = new Set(["lastAuthError"]);
const allowedSiblingBorrowers = new Set([
"proveBlock",
"serializeMockChain",
"serializeMockNoteTransportNode",
"usesMockChain",
]);
const checkableSyncMethods = [...syncMethods].filter(
(name) => !allowedRawBorrowers.has(name)
);
const unresolvedSyncMethods = checkableSyncMethods.filter(
(name) => !borrowingExports.has(name)
);
if (unresolvedSyncMethods.length > 0) {
console.error(
"The following SYNC_METHODS entries could not be found in the Rust sources, so whether they borrow the client is unknown:"
);
unresolvedSyncMethods.sort().forEach((name) => console.error(` - ${name}`));
console.error(
`\nEach entry should correspond to a #[js_export(js_name = "...")] method under crates/web-client/src/. If one was renamed or removed, update SYNC_METHODS; if it is exported some other way, add it to \`allowedRawBorrowers\` with the reason it cannot borrow.`
);
process.exit(1);
}
const rawBorrowers = checkableSyncMethods
.map((name) => [name, borrowingExports.get(name)])
.filter(([, info]) => info.borrows);
if (rawBorrowers.length > 0) {
console.error(
"The following SYNC_METHODS entries borrow the client — via `get_mut_inner`, a direct `self.inner.lock()`/`borrow()`, or a helper that does one of those — but SYNC_METHODS is bound raw by the Proxy and skips serialization:"
);
rawBorrowers
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([name, info]) => console.error(` - ${name} (${info.file})`));
console.error(
"\nA raw-bound method that borrows can be polled while another call holds the borrow, which aborts with `already borrowed: BorrowMutError` on browser and deadlocks on node. Move each into WRITE_METHODS or READ_METHODS — both are serialized, and the choice between them is the store-mutation annotation described above SYNC_METHODS in js/index.js."
);
process.exit(1);
}
const rawSiblingBorrowers = checkableSyncMethods
.map((name) => [name, borrowingExports.get(name)])
.filter(
([name, info]) => info.borrowsSibling && !allowedSiblingBorrowers.has(name)
);
if (rawSiblingBorrowers.length > 0) {
console.error(
"The following SYNC_METHODS entries hold a borrow of one of WebClient's sibling `AsyncCell`s across an await, and SYNC_METHODS is bound raw by the Proxy:"
);
rawSiblingBorrowers
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([name, info]) => console.error(` - ${name} (${info.file})`));
console.error(
"\nTwo overlapping calls abort with `already borrowed: BorrowMutError` on browser. Serialize the method by moving it into WRITE_METHODS or READ_METHODS, or — if it is called from inside `_serializeWasmCall` and so would deadlock if serialized again — add it to `allowedSiblingBorrowers` with that established."
);
process.exit(1);
}
console.log(
`Method classification check passed: all WASM WebClient methods are classified, each in exactly one set, and no raw-bound method borrows the client (${borrowingExports.size} exported methods scanned).`
);