Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mighty-feet-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@dojoengine/core": patch
---

feat(core): add ABI tooling for schema generation
227 changes: 194 additions & 33 deletions packages/core/src/cli/compile-abi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
#!/usr/bin/env node

import { readFileSync, writeFileSync, readdirSync, existsSync } from "fs";
import { join } from "path";
import {
readFileSync,
writeFileSync,
readdirSync,
existsSync,
mkdirSync,
} from "fs";
import { join, isAbsolute, dirname, resolve } from "path";
import { fileURLToPath } from "url";
import type { Dirent } from "fs";

type AbiEntry = {
[key: string]: any;
Expand All @@ -26,13 +34,41 @@ type TargetFile = {
[key: string]: any;
};

type OutputPaths = {
json: string;
ts: string;
};

type CollectOptions = {
generateTypes: boolean;
outputPath?: string;
};

/**
* Generate a TypeScript file from compiled-abi.json with proper const assertions
* This allows TypeScript to extract literal types from the ABI
*/
function generateAbiTypes(dojoRoot: string): void {
const inputPath = join(dojoRoot, "compiled-abi.json");
const outputPath = join(dojoRoot, "compiled-abi.ts");
function ensureDirectory(path: string): void {
const directory = dirname(path);
mkdirSync(directory, { recursive: true });
}

export function resolveOutputPaths(outputOption?: string): OutputPaths {
const jsonPath = outputOption
? isAbsolute(outputOption)
? outputOption
: join(process.cwd(), outputOption)
: join(process.cwd(), "compiled-abi.json");

const tsPath = jsonPath.endsWith(".json")
? `${jsonPath.slice(0, -5)}.ts`
: `${jsonPath}.ts`;

return { json: jsonPath, ts: tsPath };
}

function generateAbiTypes(paths: OutputPaths): void {
const { json: inputPath, ts: outputPath } = paths;

try {
// Read the compiled ABI
Expand All @@ -49,14 +85,18 @@ export type CompiledAbi = typeof compiledAbi;
`;

// Write the TypeScript file
ensureDirectory(outputPath);
writeFileSync(outputPath, tsContent);

console.log(`✅ Generated TypeScript types!`);
console.log(`📄 Output written to: ${outputPath}`);
console.log(`\nUsage in your code:`);
console.log(`\nimport { compiledAbi } from './compiled-abi';`);
console.log(`📄 Type output written to: ${outputPath}`);
console.log(`
Usage in your code:`);
console.log(`
import { compiledAbi } from './compiled-abi';`);
console.log(`import { ExtractAbiTypes } from '@dojoengine/core';`);
console.log(`\ntype MyAbi = ExtractAbiTypes<typeof compiledAbi>;`);
console.log(`
type MyAbi = ExtractAbiTypes<typeof compiledAbi>;`);
console.log(
`type Position = MyAbi["structs"]["dojo_starter::models::Position"];`
);
Expand All @@ -66,14 +106,35 @@ export type CompiledAbi = typeof compiledAbi;
}
}

function collectAbis(generateTypes: boolean): void {
function walkJsonFiles(root: string, entries: Dirent[] = []): string[] {
const collected: string[] = [];

for (const entry of entries) {
const fullPath = join(root, entry.name);

if (entry.isDirectory()) {
const childEntries = readdirSync(fullPath, { withFileTypes: true });
collected.push(...walkJsonFiles(fullPath, childEntries));
continue;
}

if (entry.isFile() && entry.name.endsWith(".json")) {
collected.push(fullPath);
}
}

return collected;
}

function collectAbis(options: CollectOptions): void {
const dojoRoot = process.env.DOJO_ROOT || process.cwd();
const dojoEnv = process.env.DOJO_ENV || "dev";

const manifestPath = join(dojoRoot, `manifest_${dojoEnv}.json`);
const targetDir = join(dojoRoot, "target", dojoEnv);

const allAbis: AbiEntry[] = [];
let manifest: Manifest | null = null;

// Read manifest file
if (!existsSync(manifestPath)) {
Expand All @@ -83,7 +144,7 @@ function collectAbis(generateTypes: boolean): void {

try {
const manifestContent = readFileSync(manifestPath, "utf-8");
const manifest: Manifest = JSON.parse(manifestContent);
manifest = JSON.parse(manifestContent) as Manifest;

// Extract ABIs from world
if (manifest.world?.abi) {
Expand All @@ -108,12 +169,10 @@ function collectAbis(generateTypes: boolean): void {
console.warn(`Target directory not found: ${targetDir}`);
} else {
try {
const files = readdirSync(targetDir).filter((file) =>
file.endsWith(".json")
);
const dirEntries = readdirSync(targetDir, { withFileTypes: true });
const files = walkJsonFiles(targetDir, dirEntries);

for (const file of files) {
const filePath = join(targetDir, file);
for (const filePath of files) {
try {
const fileContent = readFileSync(filePath, "utf-8");
const targetFile: TargetFile = JSON.parse(fileContent);
Expand All @@ -123,40 +182,142 @@ function collectAbis(generateTypes: boolean): void {
allAbis.push(...targetFile.abi);
}
} catch (error) {
console.error(`Error reading file ${file}: ${error}`);
console.error(`Error reading file ${filePath}: ${error}`);
}
}
} catch (error) {
console.error(`Error reading target directory: ${error}`);
}
}

const dedupedAbis = new Map<string, AbiEntry>();
const duplicateCounts: Record<string, number> = {};

for (const entry of allAbis) {
const type = typeof entry.type === "string" ? entry.type : "unknown";
const name =
typeof (entry as { name?: string }).name === "string"
? (entry as { name: string }).name
: "";
const interfaceName =
typeof (entry as { interface_name?: string }).interface_name ===
"string"
? (entry as { interface_name: string }).interface_name
: "";

const key = `${type}::${name}::${interfaceName}`;

if (dedupedAbis.has(key)) {
duplicateCounts[key] = (duplicateCounts[key] ?? 1) + 1;
continue;
}

dedupedAbis.set(key, entry);
}

const mergedAbis = Array.from(dedupedAbis.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([, value]) => value);

if (Object.keys(duplicateCounts).length > 0) {
console.warn("! Duplicate ABI entries detected and ignored:");
for (const [key, count] of Object.entries(duplicateCounts)) {
console.warn(` • ${key} (${count} occurrences)`);
}
}

// Write output
const output = {
abi: allAbis,
abi: mergedAbis,
manifest: manifest && {
world: manifest.world,
base: manifest.base,
contracts: manifest.contracts ?? [],
models: manifest.models ?? [],
},
};

const outputPath = join(dojoRoot, "compiled-abi.json");
writeFileSync(outputPath, JSON.stringify(output, null, 2));
const paths = resolveOutputPaths(options.outputPath);
ensureDirectory(paths.json);
writeFileSync(paths.json, JSON.stringify(output, null, 2));
Comment on lines +240 to +242
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Wire resolveOutputPaths to DOJO_ROOT.

Pass dojoRoot so relative outputs are anchored correctly.

-    const paths = resolveOutputPaths(options.outputPath);
+    const paths = resolveOutputPaths(dojoRoot, options.outputPath);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const paths = resolveOutputPaths(options.outputPath);
ensureDirectory(paths.json);
writeFileSync(paths.json, JSON.stringify(output, null, 2));
const paths = resolveOutputPaths(dojoRoot, options.outputPath);
ensureDirectory(paths.json);
writeFileSync(paths.json, JSON.stringify(output, null, 2));
🤖 Prompt for AI Agents
In packages/core/src/cli/compile-abi.ts around lines 240 to 242,
resolveOutputPaths is being called without the Dojo root so relative output
paths may be incorrect; update the call to pass the dojoRoot (e.g.,
resolveOutputPaths(options.outputPath, options.dojoRoot) or the local dojoRoot
variable) so outputs are anchored to DOJO_ROOT, and adjust any
resolveOutputPaths signature/usage accordingly to accept and use the dojoRoot
when building paths.


console.log(`✅ ABI compilation complete!`);
console.log(`📄 Output written to: ${outputPath}`);
console.log(`📊 Total ABI entries: ${allAbis.length}`);
console.log(`📄 Output written to: ${paths.json}`);
console.log(`📊 Total ABI entries: ${mergedAbis.length}`);

const typeStats = mergedAbis.reduce<Record<string, number>>((acc, item) => {
const key = typeof item.type === "string" ? item.type : "unknown";
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {});

for (const [abiType, count] of Object.entries(typeStats)) {
console.log(` • ${abiType}: ${count}`);
}

// Generate TypeScript types if requested
if (generateTypes) {
generateAbiTypes(dojoRoot);
if (options.generateTypes) {
generateAbiTypes(paths);
}
}

function parseArgs(argv: string[]): CollectOptions {
let generateTypes = false;
let outputPath: string | undefined;
let index = 0;

while (index < argv.length) {
const arg = argv[index];

if (arg === "--generate-types") {
generateTypes = true;
index += 1;
continue;
}

if (arg === "--output") {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
console.error("Missing value for --output option");
process.exit(1);
}
outputPath = value;
index += 2;
continue;
}

if (arg.startsWith("--output=")) {
const value = arg.slice("--output=".length);
if (!value) {
console.error("Missing value for --output option");
process.exit(1);
}
outputPath = value;
index += 1;
continue;
}

console.warn(`! Unknown argument ignored: ${arg}`);
index += 1;
}

return {
generateTypes,
outputPath,
};
}

// Parse command line arguments
const args = process.argv.slice(2);
const generateTypes = args.includes("--generate-types");
const __filename = resolve(fileURLToPath(import.meta.url));
const entryPoint = process.argv[1] ? resolve(process.argv[1]) : undefined;
const isDirectExecution = entryPoint === __filename;

if (isDirectExecution) {
const options = parseArgs(process.argv.slice(2));

// Run the compilation
try {
collectAbis(generateTypes);
} catch (error) {
console.error(`Unexpected error: ${error}`);
process.exit(1);
try {
collectAbis(options);
} catch (error) {
console.error(`Unexpected error: ${error}`);
process.exit(1);
}
}
Loading
Loading