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
50 changes: 36 additions & 14 deletions packages/builder/src/Compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import {
DEFAULT_SRC_DIR,
type ExecFunction,
} from './types/options.ts';
import {
cleanForDisplay,
parseCircuitConstraints,
writeCircuitInfoJson,
} from './utils.ts';

// Re-export public types and services so consumers keep importing them
// from './Compiler.js' regardless of the internal file layout.
Expand Down Expand Up @@ -271,6 +276,10 @@ export class CompactCompiler {
/**
* Compiles a single file with progress reporting and error handling.
*
* 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.
*
* @param file - Relative path to the .compact file
* @param index - Current file index (0-based) for progress tracking
* @param total - Total number of files being compiled
Expand All @@ -294,29 +303,42 @@ export class CompactCompiler {
);

spinner.succeed(chalk.green(`[COMPILE] ${step} Compiled ${file}`));
// Filter out compactc version output from compact compile
const filteredOutput = result.stdout.split('\n').slice(1).join('\n');

if (filteredOutput) {
UIService.printOutput(filteredOutput, chalk.cyan);
// Parse circuit constraints from the captured PTY output and print
// a clean summary. When --skip-zk is used, no constraints are
// available and this is a no-op.
const circuits = parseCircuitConstraints(result.stdout);
if (circuits.length > 0) {
const summary = circuits
.map((c) => ` "${c.name}" (k=${c.k}, rows=${c.rows})`)
.join('\n');
UIService.printOutput(summary, chalk.cyan);

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

const cleanStderr = cleanForDisplay(result.stderr);
if (cleanStderr) {
UIService.printOutput(cleanStderr, chalk.yellow);
}
UIService.printOutput(result.stderr, chalk.yellow);
} catch (error) {
spinner.fail(chalk.red(`[COMPILE] ${step} Failed ${file}`));

// CompilationError wraps the underlying child-process error in `.cause`.
// The previous guard `isPromisifiedChildProcessError(error)` on a
// CompilationError instance was unreachable — unwrap via `.cause` to
// surface compactc's stdout/stderr to the user.
const execError = error instanceof CompilationError ? error.cause : error;
if (isPromisifiedChildProcessError(execError)) {
// Filter out compactc version output from compact compile
const filteredOutput = execError.stdout.split('\n').slice(1).join('\n');
const circuits = parseCircuitConstraints(execError.stdout);
if (circuits.length > 0) {
const summary = circuits
.map((c) => ` "${c.name}" (k=${c.k}, rows=${c.rows})`)
.join('\n');
UIService.printOutput(summary, chalk.cyan);
}

if (filteredOutput) {
UIService.printOutput(filteredOutput, chalk.cyan);
const cleanStderr = cleanForDisplay(execError.stderr);
if (cleanStderr) {
UIService.printOutput(cleanStderr, chalk.red);
}
UIService.printOutput(execError.stderr, chalk.red);
}

throw error;
Expand Down
129 changes: 105 additions & 24 deletions packages/builder/src/services/CompilerService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execFile as execFileCallback } from 'node:child_process';
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 { promisify } from 'node:util';
import { parse as parseShellArgs } from 'shell-quote';
import { CompilationError } from '../types/errors.ts';
import {
Expand All @@ -13,9 +14,6 @@ import {
/** Resolved options for CompilerService with defaults applied */
type ResolvedCompilerServiceOptions = Required<CompilerServiceOptions>;

const defaultExecFn: ExecFunction = (file, args) =>
promisify(execFileCallback)(file, [...args]);

/**
* Tokenizes a user-supplied `flags` string into discrete argv entries using
* `shell-quote` (the same rules a shell would apply for splitting). Any
Expand All @@ -32,37 +30,115 @@ function tokenizeFlags(flags: string): string[] {
);
}

/**
* Spawn `compact` under a pseudo-terminal using the `script` command so that
* `compactc` emits its full progress UI (circuit constraint lines with k/rows
* values).
*
* Output is silenced by redirecting stdout/stderr to `/dev/null` file
* descriptors. The `script` command still captures the full PTY output to
* a temp file, which is read after the process exits.
*
* Handles both macOS and Linux `script` syntax:
* - macOS: `script -q <file> <command> [args...]`
* - Linux: `script -qc "<command> [args...]" <file>`
*
* Note: Circuit constraint output is only available when compiling WITHOUT
* `--skip-zk`, as the k/rows values come from the ZK proving pass. When
* `--skip-zk` is used, the returned stdout will contain only the
* `Compiling N circuits:` header.
*/
async function spawnWithPty(
args: string[],
): Promise<{ stdout: string; stderr: string }> {
const tmpFile = join(tmpdir(), `compact-pty-${Date.now()}.txt`);
const devNull = openSync('/dev/null', 'w');

return new Promise((resolve, reject) => {
let scriptArgs: string[];

if (platform() === 'darwin') {
// macOS: script -q <file> <command> [args...]
scriptArgs = ['-q', tmpFile, 'compact', ...args];
} else {
// Linux: script -qc "<command>" <file>
const cmd = ['compact', ...args]
.map((a) => (a.includes(' ') ? `'${a}'` : a))
.join(' ');
scriptArgs = ['-qc', cmd, tmpFile];
}

const proc = spawn('script', scriptArgs, {
stdio: ['ignore', devNull, devNull],
});
closeSync(devNull);

proc.on('error', (err) => {
try {
unlinkSync(tmpFile);
} catch {}
reject(err);
});

proc.on('close', (code) => {
let output = '';
try {
output = readFileSync(tmpFile, 'utf-8');
} catch {}
try {
unlinkSync(tmpFile);
} catch {}

if (code !== 0) {
const error = new Error(`compact exited with code ${code}`) as Error & {
stdout: string;
stderr: string;
};
error.stdout = output;
error.stderr = '';
reject(error);
} else {
resolve({ stdout: output, stderr: '' });
}
});
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Service responsible for compiling individual .compact files.
* Builds argv arrays and invokes the Compact CLI via `child_process.execFile`
* (no shell), so user-supplied values cannot inject extra commands.
*
* When no custom `execFn` is provided (production), spawns `compact` under a
* pseudo-terminal via the `script` command so the full progress output
* (including per-circuit constraint lines) is silently captured. The output
* is written to a temp file, read after compilation, and cleaned up.
*
* When a custom `execFn` is injected (testing), uses that function instead,
* preserving full testability without `script` or PTY.
*
* @example
* ```typescript
* // Production — uses script/PTY (silent capture)
* const compiler = new CompilerService();
* const result = await compiler.compileFile(
* 'contracts/Token.compact',
* '--skip-zk --verbose',
* '0.26.0'
* );
*
* // Testing — uses injected mock
* const mockExec = vi.fn();
* const compiler = new CompilerService(mockExec);
* ```
*/
export class CompilerService {
private execFn: ExecFunction;
private execFn: ExecFunction | null;
private options: ResolvedCompilerServiceOptions;

/**
* Creates a new CompilerService instance.
*
* @param execFn - Function to invoke the Compact CLI binary (defaults to
* a promisified `child_process.execFile` — argv array, no shell).
* @param execFn - Optional exec function for dependency injection (testing).
* When provided, used instead of PTY. When omitted or
* undefined, uses `script` for full TTY output capture.
* @param options - Compiler service options
*/
constructor(
execFn: ExecFunction = defaultExecFn,
options: CompilerServiceOptions = {},
) {
this.execFn = execFn;
constructor(execFn?: ExecFunction, options: CompilerServiceOptions = {}) {
this.execFn = execFn ?? null;
this.options = {
hierarchical: options.hierarchical ?? false,
srcDir: options.srcDir ?? DEFAULT_SRC_DIR,
Expand All @@ -72,10 +148,12 @@ export class CompilerService {

/**
* Compiles a single .compact file using the Compact CLI.
* Builds the argv array (no shell interpolation) and invokes the binary.
*
* By default, uses flattened output structure where all artifacts go to `<outDir>/<ContractName>/`.
* When `hierarchical` is true, preserves source directory structure: `<outDir>/<subdir>/<ContractName>/`.
* In production (no injected execFn): spawns under a PTY via `script`
* to silently capture the full progress output including circuit constraint
* lines. No terminal output is produced.
*
* In testing (injected execFn): calls the provided function directly.
*
* @param file - Relative path to the .compact file from srcDir
* @param flags - Space-separated compiler flags (e.g., '--skip-zk --verbose').
Expand Down Expand Up @@ -110,7 +188,10 @@ export class CompilerService {
];

try {
return await this.execFn('compact', args);
if (this.execFn) {
return await this.execFn('compact', args);
}
return await spawnWithPty(args);
} catch (error: unknown) {
let message: string;

Expand Down
Loading
Loading