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
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ managed/
# never gets committed — including under examples/, where the walkthrough
# expects you to compile the contract yourself before deploying.
artifacts/
# compact-compiler's per-source circuit-info cache, written next to the
# .compact sources it measures.
.circuit-info.json
midnight-level-db
compactc

Expand Down
7 changes: 7 additions & 0 deletions packages/builder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ const builder = new CompactBuilder({
await builder.build();
```

## Artifacts

Each contract compiles into `<outDir>/<Contract>/` (flattened) or
`<outDir>/<subdir>/<Contract>/` (`hierarchical: true`). Beside the `compiler/`
output, every ZK compile writes `circuit-info.json` with the parsed `k`/`rows`
per circuit. `--skip-zk` writes none.

## Public API

```ts
Expand Down
12 changes: 8 additions & 4 deletions packages/builder/src/Compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import {
type ExecFunction,
} from './types/options.ts';
import {
artifactDir,
cleanForDisplay,
parseCircuitConstraints,
writeCircuitInfoJson,
writeCircuitInfo,
} from './utils.ts';

// Re-export public types and services so consumers keep importing them
Expand Down Expand Up @@ -291,7 +292,7 @@ export class CompactCompiler {
*
* When circuit constraint data is available in the compile output (i.e.,
* compiling without `--skip-zk`), prints a clean summary and writes a
* `.circuit-info.json` file in the source directory.
* `circuit-info.json` file into the contract's artifact directory.
*
* @param file - Relative path to the .compact file
* @param index - Current file index (0-based) for progress tracking
Expand Down Expand Up @@ -327,8 +328,11 @@ export class CompactCompiler {
.join('\n');
UIService.printOutput(summary, chalk.cyan);

// Write .circuit-info.json in the source directory
writeCircuitInfoJson(file, this.options.srcDir, circuits);
writeCircuitInfo(
artifactDir(this.options.outDir, file, this.options.hierarchical),
file,
circuits,
);
}

const cleanStderr = cleanForDisplay(result.stderr);
Expand Down
17 changes: 7 additions & 10 deletions packages/builder/src/services/CompilerService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn } from 'node:child_process';
import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs';
import { platform, tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { join } from 'node:path';
import { parse as parseShellArgs } from 'shell-quote';
import { CompilationError } from '../types/errors.ts';
import {
Expand All @@ -10,6 +10,7 @@ import {
DEFAULT_SRC_DIR,
type ExecFunction,
} from '../types/options.ts';
import { artifactDir } from '../utils.ts';

/** Resolved options for CompilerService with defaults applied */
type ResolvedCompilerServiceOptions = Required<CompilerServiceOptions>;
Expand Down Expand Up @@ -172,15 +173,11 @@ export class CompilerService {
version?: string,
): Promise<{ stdout: string; stderr: string }> {
const inputPath = join(this.options.srcDir, file);
const fileDir = dirname(file);
const fileName = basename(file, '.compact');

// Flattened (default): <outDir>/<ContractName>/
// Hierarchical: <outDir>/<subdir>/<ContractName>/
const outputDir =
this.options.hierarchical && fileDir !== '.'
? join(this.options.outDir, fileDir, fileName)
: join(this.options.outDir, fileName);
const outputDir = artifactDir(
this.options.outDir,
file,
this.options.hierarchical,
);

const args: string[] = [
'compile',
Expand Down
91 changes: 56 additions & 35 deletions packages/builder/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
* {@link parseCircuitConstraints}) — strips ANSI codes, spinner artifacts,
* and cursor-movement sequences from `compact compile` PTY output and
* extracts circuit constraint data.
* - **Circuit info persistence** ({@link writeCircuitInfoJson}) — writes
* `.circuit-info.json` files with parsed circuit constraints.
* - **Artifact layout** ({@link artifactDir}) — resolves the per-contract
* output directory `compact compile` writes into.
* - **Circuit info persistence** ({@link writeCircuitInfo}) — writes
* `circuit-info.json` into a contract's artifact directory.
*/

import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { mkdirSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
/**
* Converts a simple glob pattern to a regular expression.
* Supports `*` (any sequence) and `?` (single char). All other glob features
Expand Down Expand Up @@ -242,51 +244,70 @@ export function parseCircuitConstraints(
return [...circuits.values()];
}

// ─── Artifact layout ────────────────────────────────────────────────────

/**
* Resolve the artifact directory `compact compile` writes a contract into.
*
* Flattened (default): `<outDir>/<Contract>/`. Hierarchical:
* `<outDir>/<subdir>/<Contract>/`, except for sources at the root of `srcDir`,
* which have no subdir and stay flattened.
*
* @param outDir - Base artifacts directory
* @param file - Relative path to the .compact file (from srcDir)
* @param hierarchical - Whether to mirror the source directory structure
* @returns The contract's artifact directory, under `outDir`
*/
export function artifactDir(
outDir: string,
file: string,
hierarchical: boolean,
): string {
const fileDir = dirname(file);
const contract = basename(file, '.compact');
return hierarchical && fileDir !== '.'
? join(outDir, fileDir, contract)
: join(outDir, contract);
}

// ─── Circuit info file ──────────────────────────────────────────────────

/**
* Shape of the `.circuit-info.json` file written per source directory.
* Shape of the `circuit-info.json` file written per compiled contract.
*/
export interface CircuitInfoFile {
/** ISO timestamp of when this file was last generated */
export interface CircuitInfo {
/** ISO timestamp of when this file was generated */
generatedAt: string;
/** Map of compiled filename → circuit constraints */
files: Record<string, CircuitConstraint[]>;
/** Path of the compiled .compact file relative to srcDir, forward slashes */
source: string;
/** Circuit constraints in the compiler's output order */
circuits: CircuitConstraint[];
}

/**
* Write or merge circuit constraint data into a `.circuit-info.json` file
* in the source directory of the compiled file.
* Write circuit constraint data to `circuit-info.json` in a contract's
* artifact directory, beside the compiler's own output.
*
* If the file already exists, the entry for the given file is updated and
* other entries are preserved. The `generatedAt` timestamp is always updated.
* Each compile replaces the file; nothing is merged from a previous run.
*
* @param file - Relative path to the compiled .compact file (from srcDir)
* @param srcDir - Base source directory
* @param circuits - Parsed circuit constraints to write
* @returns The absolute path to the written `.circuit-info.json` file
* @param outputDir - The contract's artifact directory, created if missing
* @param file - Relative path to the compiled .compact file (from srcDir)
* @param circuits - Parsed circuit constraints to write
* @returns The absolute path to the written `circuit-info.json` file
*/
export function writeCircuitInfoJson(
export function writeCircuitInfo(
outputDir: string,
file: string,
srcDir: string,
circuits: CircuitConstraint[],
): string {
const srcFilePath = resolve(srcDir, file);
const dir = dirname(srcFilePath);
const jsonPath = resolve(dir, '.circuit-info.json');

let existing: CircuitInfoFile = { generatedAt: '', files: {} };
try {
existing = JSON.parse(readFileSync(jsonPath, 'utf-8'));
} catch {
// File doesn't exist or is invalid, start fresh
}

const fileName = file.split('/').pop() ?? file;
existing.generatedAt = new Date().toISOString();
existing.files[fileName] = circuits;
const jsonPath = resolve(outputDir, 'circuit-info.json');
const info: CircuitInfo = {
generatedAt: new Date().toISOString(),
source: file.replaceAll('\\', '/'),
circuits,
};

mkdirSync(dir, { recursive: true });
writeFileSync(jsonPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf-8');
mkdirSync(outputDir, { recursive: true });
writeFileSync(jsonPath, `${JSON.stringify(info, null, 2)}\n`, 'utf-8');
return jsonPath;
}
45 changes: 44 additions & 1 deletion packages/builder/test/Compiler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync } from 'node:fs';
import { existsSync, writeFileSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
beforeEach,
describe,
Expand Down Expand Up @@ -51,6 +52,7 @@ vi.mock('ora', () => ({

const mockExistsSync = vi.mocked(existsSync);
const mockReaddir = vi.mocked(readdir);
const mockWriteFileSync = vi.mocked(writeFileSync);

describe('EnvironmentValidator', () => {
let mockExec: MockedFunction<ExecFunction>;
Expand Down Expand Up @@ -1278,6 +1280,47 @@ describe('CompactCompiler', () => {
);
});

it('writes circuit-info.json into the artifact dir and nowhere else', async () => {
mockReaddir.mockResolvedValue([
{
name: 'MockOwnable.compact',
isFile: () => true,
isDirectory: () => false,
},
] as any);
mockExistsSync.mockReturnValue(true);

const testMockExec = vi
.fn()
.mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' })
.mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Compactc 0.26.0', stderr: '' })
.mockResolvedValueOnce({
stdout:
'Compiling 1 circuits:\n circuit "transferOwnership" (k=10, rows=626)\n',
stderr: '',
});

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
compiler = new CompactCompiler({}, testMockExec);

await compiler.compile();

const written = mockWriteFileSync.mock.calls.map(([path]) =>
String(path),
);
expect(written).toEqual([
resolve('artifacts', 'MockOwnable', 'circuit-info.json'),
]);
expect(JSON.parse(String(mockWriteFileSync.mock.calls[0][1]))).toEqual({
generatedAt: expect.any(String),
source: 'MockOwnable.compact',
circuits: [{ name: 'transferOwnership', k: 10, rows: 626 }],
});

logSpy.mockRestore();
});

it('should handle compilation errors gracefully', async () => {
const brokenDirent = {
name: 'Broken.compact',
Expand Down
Loading
Loading