diff --git a/README.md b/README.md index 64b3e25..bfd3111 100644 --- a/README.md +++ b/README.md @@ -85,58 +85,43 @@ yarn clean ## Packages -### `@openzeppelin/compact-tools-cli` (packages/cli) +### `@openzeppelin/compact-tools-cli` ([packages/cli](./packages/cli)) -Utilities and CLIs around the Compact compiler and builder. +CLI utilities for compiling and building Compact smart contracts. -- Binaries provided: - - `compact-compiler` → `packages/cli/dist/runCompiler.js` - - `compact-builder` → `packages/cli/dist/runBuilder.js` - -Useful commands: +**Quickstart:** ```bash -# From repo root (via Turbo filters) -yarn compact - -# Or inside the package -cd packages/cli -yarn build # compile TypeScript -yarn test # run unit tests -yarn types # type-check only -``` +# Compile all .compact files +compact-compiler -After building, you can invoke the CLIs directly, for example: +# Skip ZK proofs for faster development builds +compact-compiler --skip-zk -```bash -node dist/runCompiler.js --help -node dist/runBuilder.js --help -``` +# Compile specific directory +compact-compiler --dir security -### `@openzeppelin/compact-tools-simulator` (packages/simulator) +# Full build (compile + TypeScript + copy artifacts) +compact-builder +``` -A local simulator to execute Compact contracts in tests. +See [packages/cli/README.md](./packages/cli/README.md) for full documentation including all options, programmatic API, and examples. -Build and test: +### `@openzeppelin/compact-tools-simulator` ([packages/simulator](./packages/simulator)) -```bash -cd packages/simulator -yarn build -yarn test -``` +TypeScript simulator for testing Compact contracts locally. -Minimal usage example: +**Quickstart:** ```ts import { createSimulator } from '@openzeppelin/compact-tools-simulator'; -// Create a simulator instance (see package docs and tests for full examples) const simulator = createSimulator({}); - -// Use simulator to deploy/execute contract circuits, inspect state, etc. -// (Refer to `packages/simulator/src/integration` and `src/unit` tests.) +// Deploy and execute contract circuits, inspect state, etc. ``` +See package tests in `packages/simulator/src/integration` and `src/unit` for full examples. + ## Contributing Before opening a PR, please read `CODE_OF_CONDUCT.md`. Use the root scripts to build, test, and format. For targeted work inside a package, run the scripts in that package directory. @@ -144,5 +129,3 @@ Before opening a PR, please read `CODE_OF_CONDUCT.md`. Use the root scripts to b ## License MIT - - diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..60ab235 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,309 @@ +# @openzeppelin/compact-tools-cli + +CLI utilities for compiling and building Compact smart contracts. + +## Installation + +Until published to npm, use via git submodule or local path: + +```bash +# As a local dependency +yarn add @openzeppelin/compact-tools-cli@file:./compact-tools/packages/cli + +# Or invoke directly after building +node compact-tools/packages/cli/dist/runCompiler.js +``` + +## Requirements + +- Node.js >= 20 +- Midnight Compact toolchain installed and available in `PATH` + +Verify your Compact installation: + +```bash +$ compact compile --version +Compactc version: 0.26.0 +``` + +## Binaries + +This package provides two CLI binaries: + +| Binary | Script | Description | +|--------|--------|-------------| +| `compact-compiler` | `dist/runCompiler.js` | Compile `.compact` files to artifacts | +| `compact-builder` | `dist/runBuilder.js` | Compile + build TypeScript + copy artifacts | + +## Compiler CLI + +### Usage + +```bash +compact-compiler [options] +``` + +### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--dir ` | Compile specific subdirectory within src | (all) | +| `--src ` | Source directory containing `.compact` files | `src` | +| `--out ` | Output directory for compiled artifacts | `artifacts` | +| `--hierarchical` | Preserve source directory structure in output | `false` | +| `--force`, `-f` | Force delete existing artifacts on structure mismatch | `false` | +| `--skip-zk` | Skip zero-knowledge proof generation | `false` | +| `+` | Use specific toolchain version (e.g., `+0.26.0`) | (default) | + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `SKIP_ZK=true` | Equivalent to `--skip-zk` flag | + +### Artifact Output Structure + +**Default (flattened):** All contract artifacts go directly under the output directory. + +``` +src/ + access/ + AccessControl.compact + token/ + Token.compact + +artifacts/ # Flattened output + AccessControl/ + Token/ +``` + +**Hierarchical (`--hierarchical`):** Preserves source directory structure. + +``` +artifacts/ # Hierarchical output + access/ + AccessControl/ + token/ + Token/ +``` + +### Structure Mismatch Detection + +The compiler tracks which structure type was used via a `manifest.json` file in the output directory. When switching between flattened and hierarchical structures: + +- **Interactive mode (TTY):** Prompts for confirmation before deleting existing artifacts +- **Non-interactive mode (CI/CD):** Requires `--force` flag to proceed + +```bash +$ compact-compiler --hierarchical + +⚠ [COMPILE] Existing artifacts use "flattened" structure. +⚠ [COMPILE] You are compiling with "hierarchical" structure. +? Delete existing artifacts and recompile? (y/N) +``` + +To skip the prompt in scripts or CI/CD: + +```bash +compact-compiler --hierarchical --force +``` + +### Manifest File + +The compiler generates a `manifest.json` in the output directory with build metadata: + +```json +{ + "structure": "hierarchical", + "compactcVersion": "0.26.0", + "compactToolVersion": "0.3.0", + "createdAt": "2025-12-11T10:35:09.916Z", + "buildDuration": 2445, + "nodeVersion": "22", + "platform": "linux-x64", + "sourcePath": "src", + "outputPath": "artifacts", + "compilerFlags": ["--skip-zk"], + "artifacts": { + "ledger": ["Counter"], + "reference": ["Boolean", "Bytes", "Field"] + } +} +``` + +### Examples + +```bash +# Compile all contracts (flattened output) +compact-compiler + +# Compile with hierarchical artifact structure +compact-compiler --hierarchical + +# Compile specific directory only +compact-compiler --dir security + +# Skip ZK proof generation (faster, for development) +compact-compiler --skip-zk + +# Use specific toolchain version +compact-compiler +0.26.0 + +# Custom source and output directories +compact-compiler --src contracts --out build + +# Combine options +compact-compiler --dir access --skip-zk --hierarchical + +# Force structure change without prompt +compact-compiler --hierarchical --force + +# Use environment variable +SKIP_ZK=true compact-compiler +``` + +## Builder CLI + +The builder runs the compiler as a prerequisite, then executes additional build steps: + +1. Compile `.compact` files (via `compact-compiler`) +2. Compile TypeScript (`tsc --project tsconfig.build.json`) +3. Copy artifacts to `dist/artifacts/` +4. Copy and clean `.compact` files to `dist/` + +### Usage + +```bash +compact-builder [options] +``` + +Accepts all compiler options except `--skip-zk` (builds always include ZK proofs). + +### Examples + +```bash +# Full build +compact-builder + +# Build specific directory +compact-builder --dir token + +# Build with custom directories +compact-builder --src contracts --out build +``` + +## Programmatic API + +The compiler can be used programmatically: + +```typescript +import { CompactCompiler } from '@openzeppelin/compact-tools-cli'; + +// Using options object +const compiler = new CompactCompiler({ + flags: ['--skip-zk'], + targetDir: 'security', + version: '0.26.0', + hierarchical: true, + srcDir: 'src', + outDir: 'artifacts', +}); + +await compiler.compile(); + +// Using factory method (parses CLI-style args) +const compiler = CompactCompiler.fromArgs([ + '--dir', 'security', + '--skip-zk', + '+0.26.0' +]); + +await compiler.compile(); +``` + +### Classes and Types + +```typescript +// Main compiler class +class CompactCompiler { + constructor(options?: CompilerOptions, execFn?: ExecFunction); + static fromArgs(args: string[], env?: NodeJS.ProcessEnv): CompactCompiler; + static parseArgs(args: string[], env?: NodeJS.ProcessEnv): CompilerOptions; + compile(): Promise; + validateEnvironment(): Promise; +} + +// Builder class +class CompactBuilder { + constructor(options?: CompilerOptions); + static fromArgs(args: string[], env?: NodeJS.ProcessEnv): CompactBuilder; + build(): Promise; +} + +// Options interface +interface CompilerOptions { + flags?: CompilerFlag[]; // Compiler flags (e.g., ['--skip-zk']) + targetDir?: string; // Subdirectory within srcDir to compile + version?: CompactcVersion; // Toolchain version (e.g., '0.26.0') + hierarchical?: boolean; // Preserve directory structure in output + srcDir?: string; // Source directory (default: 'src') + outDir?: string; // Output directory (default: 'artifacts') + force?: boolean; // Force delete on structure mismatch +} + +// Compiler flags (passed to compactc) +type CompilerFlag = + | '--skip-zk' + | '--vscode' + | '--no-communications-commitment' + | '--trace-passes' + | `--sourceRoot ${string}`; + +// Supported compactc versions +type CompactcVersion = '0.23.0' | '0.24.0' | '0.25.0' | '0.26.0'; +``` + +### Error Types + +```typescript +import { + CompactCliNotFoundError, // Compact CLI not in PATH + CompilationError, // Compilation failed (includes file path) + DirectoryNotFoundError, // Target directory doesn't exist + StructureMismatchError, // Artifact structure mismatch (flattened vs hierarchical) +} from '@openzeppelin/compact-tools-cli'; +``` + +## Development + +```bash +cd packages/cli + +# Build +yarn build + +# Type-check only +yarn types + +# Run tests +yarn test + +# Clean +yarn clean +``` + +## Output Example + +``` +ℹ [COMPILE] Compact compiler started +ℹ [COMPILE] compact-tools: 0.3.0 +ℹ [COMPILE] compactc: 0.26.0 +ℹ [COMPILE] Found 2 .compact file(s) to compile +✔ [COMPILE] [1/2] Compiled AccessControl.compact +✔ [COMPILE] [2/2] Compiled Token.compact +``` + +## License + +MIT + diff --git a/packages/cli/src/Builder.ts b/packages/cli/src/Builder.ts index 6b0204e..f94027f 100755 --- a/packages/cli/src/Builder.ts +++ b/packages/cli/src/Builder.ts @@ -4,12 +4,19 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import chalk from 'chalk'; import ora, { type Ora } from 'ora'; -import { CompactCompiler } from './Compiler.ts'; +import { CompactCompiler, type CompilerOptions } from './Compiler.ts'; import { isPromisifiedChildProcessError } from './types/errors.ts'; // Promisified exec for async execution const execAsync = promisify(exec); +/** + * Configuration options for the Builder CLI. + * Inherits from CompilerOptions but excludes `flags` (which would allow --skip-zk). + * Builds should always include ZK proofs. + */ +export type BuilderOptions = Omit; + /** * A class to handle the build process for a project. * Runs CompactCompiler as a prerequisite, then executes build steps (TypeScript compilation, @@ -21,7 +28,7 @@ const execAsync = promisify(exec); * * @example * ```typescript - * const builder = new ProjectBuilder('--skip-zk'); // Optional flags for compactc + * const builder = new CompactBuilder({ flags: '--skip-zk' }); * builder.build().catch(err => console.error(err)); * ``` * @@ -56,7 +63,7 @@ const execAsync = promisify(exec); * ``` */ export class CompactBuilder { - private readonly compilerFlags: string; + private readonly options: BuilderOptions; private readonly steps: Array<{ cmd: string; msg: string; shell?: string }> = [ { @@ -76,11 +83,27 @@ export class CompactBuilder { ]; /** - * Constructs a new ProjectBuilder instance. - * @param compilerFlags - Optional space-separated string of `compactc` flags (e.g., "--skip-zk") + * Constructs a new CompactBuilder instance. + * @param options - Compiler options (flags, srcDir, outDir, hierarchical, etc.) + */ + constructor(options: CompilerOptions = {}) { + this.options = options; + } + + /** + * Factory method to create a CompactBuilder from command-line arguments. + * Reuses CompactCompiler.parseArgs for consistent argument parsing. + * + * @param args - Array of command-line arguments + * @param env - Environment variables (defaults to process.env) + * @returns New CompactBuilder instance configured from arguments */ - constructor(compilerFlags = '') { - this.compilerFlags = compilerFlags; + static fromArgs( + args: string[], + env: NodeJS.ProcessEnv = process.env, + ): CompactBuilder { + const options = CompactCompiler.parseArgs(args, env); + return new CompactBuilder(options); } /** @@ -92,7 +115,7 @@ export class CompactBuilder { */ public async build(): Promise { // Run compact compilation as a prerequisite - const compiler = new CompactCompiler(this.compilerFlags); + const compiler = new CompactCompiler(this.options); await compiler.compile(); // Proceed with build steps diff --git a/packages/cli/src/Compiler.ts b/packages/cli/src/Compiler.ts index de57236..9ab7a5f 100755 --- a/packages/cli/src/Compiler.ts +++ b/packages/cli/src/Compiler.ts @@ -1,407 +1,105 @@ #!/usr/bin/env node -import { exec as execCallback } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { readdir } from 'node:fs/promises'; -import { basename, join, relative } from 'node:path'; -import { promisify } from 'node:util'; +import { writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; import chalk from 'chalk'; import ora from 'ora'; import { - CompactCliNotFoundError, + type CompactcVersion, + type CompactToolVersion, + DEFAULT_OUT_DIR, + DEFAULT_SRC_DIR, +} from './config.ts'; +import { CompilerService } from './services/CompilerService.ts'; +import { + EnvironmentValidator, + type ExecFunction, +} from './services/EnvironmentValidator.ts'; +import { FileDiscovery } from './services/FileDiscovery.ts'; +import { ManifestService } from './services/ManifestService.ts'; +import { UIService } from './services/UIService.ts'; +import { CompilationError, DirectoryNotFoundError, isPromisifiedChildProcessError, } from './types/errors.ts'; - -/** Source directory containing .compact files */ -const SRC_DIR: string = 'src'; -/** Output directory for compiled artifacts */ -const ARTIFACTS_DIR: string = 'artifacts'; - -/** - * Function type for executing shell commands. - * Allows dependency injection for testing and customization. - * - * @param command - The shell command to execute - * @returns Promise resolving to command output - */ -export type ExecFunction = ( - command: string, -) => Promise<{ stdout: string; stderr: string }>; +import type { + BenchmarkContract, + BenchmarkReport, + ContractMetadata, + HierarchicalBenchmarkNode, + HierarchicalBenchmarks, +} from './types/benchmark.ts'; +import { + type CompilerFlag, + type HierarchicalArtifactNode, + type HierarchicalArtifacts, + type NodeVersion, + type Platform, + StructureMismatchError, +} from './types/manifest.ts'; /** - * Service responsible for validating the Compact CLI environment. - * Checks CLI availability, retrieves version information, and ensures - * the toolchain is properly configured before compilation. + * Configuration options for the Compact compiler CLI. * - * @class EnvironmentValidator + * @interface CompilerOptions * @example * ```typescript - * const validator = new EnvironmentValidator(); - * await validator.validate('0.26.0'); - * const version = await validator.getDevToolsVersion(); + * const options: CompilerOptions = { + * flags: ['--skip-zk'], + * targetDir: 'security', + * version: '0.26.0', + * hierarchical: false, + * }; * ``` */ -export class EnvironmentValidator { - private execFn: ExecFunction; - - /** - * Creates a new EnvironmentValidator instance. - * - * @param execFn - Function to execute shell commands (defaults to promisified child_process.exec) - */ - constructor(execFn: ExecFunction = promisify(execCallback)) { - this.execFn = execFn; - } - - /** - * Checks if the Compact CLI is available in the system PATH. - * - * @returns Promise resolving to true if CLI is available, false otherwise - * @example - * ```typescript - * const isAvailable = await validator.checkCompactAvailable(); - * if (!isAvailable) { - * throw new Error('Compact CLI not found'); - * } - * ``` - */ - async checkCompactAvailable(): Promise { - try { - await this.execFn('compact --version'); - return true; - } catch { - return false; - } - } - +export interface CompilerOptions { + /** Compiler flags to pass to the Compact CLI */ + flags?: CompilerFlag[]; + /** Optional subdirectory within srcDir to compile (e.g., 'security', 'token') */ + targetDir?: string; + /** Optional compactc toolchain version to use */ + version?: CompactcVersion; /** - * Retrieves the version of the Compact developer tools. - * - * @returns Promise resolving to the version string - * @throws {Error} If the CLI is not available or command fails - * @example - * ```typescript - * const version = await validator.getDevToolsVersion(); - * console.log(`Using Compact ${version}`); - * ``` + * Whether to preserve directory structure in artifacts output. + * - `false` (default): Flattened output - `//` + * - `true`: Hierarchical output - `///` */ - async getDevToolsVersion(): Promise { - const { stdout } = await this.execFn('compact --version'); - return stdout.trim(); - } - + hierarchical?: boolean; + /** Source directory containing .compact files (default: 'src') */ + srcDir?: string; + /** Output directory for compiled artifacts (default: 'artifacts') */ + outDir?: string; /** - * Retrieves the version of the Compact toolchain/compiler. - * - * @param version - Optional specific toolchain version to query - * @returns Promise resolving to the toolchain version string - * @throws {Error} If the CLI is not available or command fails - * @example - * ```typescript - * const toolchainVersion = await validator.getToolchainVersion('0.26.0'); - * console.log(`Toolchain: ${toolchainVersion}`); - * ``` + * Force deletion of existing artifacts on structure mismatch. + * When true, skips the confirmation prompt and auto-deletes. */ - async getToolchainVersion(version?: string): Promise { - const versionFlag = version ? `+${version}` : ''; - const { stdout } = await this.execFn( - `compact compile ${versionFlag} --version`, - ); - return stdout.trim(); - } - + force?: boolean; /** - * Validates the entire Compact environment and ensures it's ready for compilation. - * Checks CLI availability and retrieves version information. - * - * @param version - Optional specific toolchain version to validate - * @throws {CompactCliNotFoundError} If the Compact CLI is not available - * @throws {Error} If version commands fail - * @example - * ```typescript - * try { - * await validator.validate('0.26.0'); - * console.log('Environment validated successfully'); - * } catch (error) { - * if (error instanceof CompactCliNotFoundError) { - * console.error('Please install Compact CLI'); - * } - * } - * ``` + * Path to write benchmarks report JSON file. + * When specified, generates a separate benchmarks.json with circuit metadata. + * This file is designed to be committed to version control. + * Defaults to "./benchmarks.json" when --benchmarks flag is used without a path. + * @example "./benchmarks.json" */ - async validate( - version?: string, - ): Promise<{ devToolsVersion: string; toolchainVersion: string }> { - const isAvailable = await this.checkCompactAvailable(); - if (!isAvailable) { - throw new CompactCliNotFoundError( - "'compact' CLI not found in PATH. Please install the Compact developer tools.", - ); - } - - const devToolsVersion = await this.getDevToolsVersion(); - const toolchainVersion = await this.getToolchainVersion(version); - - return { devToolsVersion, toolchainVersion }; - } -} - -/** - * Service responsible for discovering .compact files in the source directory. - * Recursively scans directories and filters for .compact file extensions. - * - * @class FileDiscovery - * @example - * ```typescript - * const discovery = new FileDiscovery(); - * const files = await discovery.getCompactFiles('src/security'); - * console.log(`Found ${files.length} .compact files`); - * ``` - */ -export class FileDiscovery { + benchmarksPath?: string; /** - * Recursively discovers all .compact files in a directory. - * Returns relative paths from the SRC_DIR for consistent processing. - * - * @param dir - Directory path to search (relative or absolute) - * @returns Promise resolving to array of relative file paths - * @example - * ```typescript - * const files = await discovery.getCompactFiles('src'); - * // Returns: ['contracts/Token.compact', 'security/AccessControl.compact'] - * ``` + * Show detailed compiler output (circuit progress, etc.). + * When false, only shows compile progress messages. + * @default false */ - async getCompactFiles(dir: string): Promise { - try { - const dirents = await readdir(dir, { withFileTypes: true }); - const filePromises = dirents.map(async (entry) => { - const fullPath = join(dir, entry.name); - try { - if (entry.isDirectory()) { - return await this.getCompactFiles(fullPath); - } - - if (entry.isFile() && fullPath.endsWith('.compact')) { - return [relative(SRC_DIR, fullPath)]; - } - return []; - } catch (err) { - // biome-ignore lint/suspicious/noConsole: Needed to display error and file path - console.warn(`Error accessing ${fullPath}:`, err); - return []; - } - }); - - const results = await Promise.all(filePromises); - return results.flat(); - } catch (err) { - // biome-ignore lint/suspicious/noConsole: Needed to display error and dir path - console.error(`Failed to read dir: ${dir}`, err); - return []; - } - } + verbose?: boolean; } -/** - * Service responsible for compiling individual .compact files. - * Handles command construction, execution, and error processing. - * - * @class CompilerService - * @example - * ```typescript - * const compiler = new CompilerService(); - * const result = await compiler.compileFile( - * 'contracts/Token.compact', - * '--skip-zk --verbose', - * '0.26.0' - * ); - * console.log('Compilation output:', result.stdout); - * ``` - */ -export class CompilerService { - private execFn: ExecFunction; - - /** - * Creates a new CompilerService instance. - * - * @param execFn - Function to execute shell commands (defaults to promisified child_process.exec) - */ - constructor(execFn: ExecFunction = promisify(execCallback)) { - this.execFn = execFn; - } - - /** - * Compiles a single .compact file using the Compact CLI. - * Constructs the appropriate command with flags and version, then executes it. - * - * @param file - Relative path to the .compact file from SRC_DIR - * @param flags - Space-separated compiler flags (e.g., '--skip-zk --verbose') - * @param version - Optional specific toolchain version to use - * @returns Promise resolving to compilation output (stdout/stderr) - * @throws {CompilationError} If compilation fails for any reason - * @example - * ```typescript - * try { - * const result = await compiler.compileFile( - * 'security/AccessControl.compact', - * '--skip-zk', - * '0.26.0' - * ); - * console.log('Success:', result.stdout); - * } catch (error) { - * if (error instanceof CompilationError) { - * console.error('Compilation failed for', error.file); - * } - * } - * ``` - */ - async compileFile( - file: string, - flags: string, - version?: string, - ): Promise<{ stdout: string; stderr: string }> { - const inputPath = join(SRC_DIR, file); - const outputDir = join(ARTIFACTS_DIR, basename(file, '.compact')); - - const versionFlag = version ? `+${version}` : ''; - const flagsStr = flags ? ` ${flags}` : ''; - const command = `compact compile${versionFlag ? ` ${versionFlag}` : ''}${flagsStr} "${inputPath}" "${outputDir}"`; - - try { - return await this.execFn(command); - } catch (error: unknown) { - let message: string; - - if (error instanceof Error) { - message = error.message; - } else { - message = String(error); // fallback for strings, objects, numbers, etc. - } - - throw new CompilationError( - `Failed to compile ${file}: ${message}`, - file, - error, - ); - } - } -} - -/** - * Utility service for handling user interface output and formatting. - * Provides consistent styling and formatting for compiler messages and output. - * - * @class UIService - * @example - * ```typescript - * UIService.displayEnvInfo('compact 0.1.0', 'Compactc 0.26.0', 'security'); - * UIService.printOutput('Compilation successful', chalk.green); - * ``` - */ -export const UIService = { - /** - * Prints formatted output with consistent indentation and coloring. - * Filters empty lines and adds consistent indentation for readability. - * - * @param output - Raw output text to format - * @param colorFn - Chalk color function for styling - * @example - * ```typescript - * UIService.printOutput(stdout, chalk.cyan); - * UIService.printOutput(stderr, chalk.red); - * ``` - */ - printOutput(output: string, colorFn: (text: string) => string): void { - const lines = output - .split('\n') - .filter((line) => line.trim() !== '') - .map((line) => ` ${line}`); - console.log(colorFn(lines.join('\n'))); - }, - - /** - * Displays environment information including tool versions and configuration. - * Shows developer tools version, toolchain version, and optional settings. - * - * @param devToolsVersion - Version string of the Compact developer tools - * @param toolchainVersion - Version string of the Compact toolchain/compiler - * @param targetDir - Optional target directory being compiled - * @param version - Optional specific version being used - * @example - * ```typescript - * UIService.displayEnvInfo( - * 'compact 0.1.0', - * 'Compactc version: 0.26.0', - * 'security', - * '0.26.0' - * ); - * ``` - */ - displayEnvInfo( - devToolsVersion: string, - toolchainVersion: string, - targetDir?: string, - version?: string, - ): void { - const spinner = ora(); - - if (targetDir) { - spinner.info(chalk.blue(`[COMPILE] TARGET_DIR: ${targetDir}`)); - } - - spinner.info( - chalk.blue(`[COMPILE] Compact developer tools: ${devToolsVersion}`), - ); - spinner.info( - chalk.blue(`[COMPILE] Compact toolchain: ${toolchainVersion}`), - ); - - if (version) { - spinner.info(chalk.blue(`[COMPILE] Using toolchain version: ${version}`)); - } - }, - - /** - * Displays compilation start message with file count and optional location. - * - * @param fileCount - Number of files to be compiled - * @param targetDir - Optional target directory being compiled - * @example - * ```typescript - * UIService.showCompilationStart(5, 'security'); - * // Output: "Found 5 .compact file(s) to compile in security/" - * ``` - */ - showCompilationStart(fileCount: number, targetDir?: string): void { - const searchLocation = targetDir ? ` in ${targetDir}/` : ''; - const spinner = ora(); - spinner.info( - chalk.blue( - `[COMPILE] Found ${fileCount} .compact file(s) to compile${searchLocation}`, - ), - ); - }, - - /** - * Displays a warning message when no .compact files are found. - * - * @param targetDir - Optional target directory that was searched - * @example - * ```typescript - * UIService.showNoFiles('security'); - * // Output: "No .compact files found in security/." - * ``` - */ - showNoFiles(targetDir?: string): void { - const searchLocation = targetDir ? `${targetDir}/` : ''; - const spinner = ora(); - spinner.warn( - chalk.yellow(`[COMPILE] No .compact files found in ${searchLocation}.`), - ); - }, -}; +/** Resolved compiler options with defaults applied */ +type ResolvedCompilerOptions = Required< + Pick< + CompilerOptions, + 'flags' | 'hierarchical' | 'srcDir' | 'outDir' | 'force' | 'verbose' + > +> & + Pick; /** * Main compiler class that orchestrates the compilation process. @@ -414,18 +112,27 @@ export const UIService = { * - Progress reporting and user feedback * - Support for compiler flags and toolchain versions * - Environment variable integration + * - Configurable artifact output structure (flattened or hierarchical) * * @class CompactCompiler * @example * ```typescript - * // Basic usage - * const compiler = new CompactCompiler('--skip-zk', 'security', '0.26.0'); + * // Basic usage with options object (flattened artifacts by default) + * const compiler = new CompactCompiler({ + * flags: '--skip-zk', + * targetDir: 'security', + * version: '0.26.0', + * }); * await compiler.compile(); * * // Factory method usage * const compiler = CompactCompiler.fromArgs(['--dir', 'security', '--skip-zk']); * await compiler.compile(); * + * // With hierarchical artifacts structure + * const compiler = CompactCompiler.fromArgs(['--hierarchical', '--skip-zk']); + * await compiler.compile(); + * * // With environment variables * process.env.SKIP_ZK = 'true'; * const compiler = CompactCompiler.fromArgs(['--dir', 'token']); @@ -439,49 +146,149 @@ export class CompactCompiler { private readonly fileDiscovery: FileDiscovery; /** Compilation execution service */ private readonly compilerService: CompilerService; - - /** Compiler flags to pass to the Compact CLI */ - private readonly flags: string; - /** Optional target directory to limit compilation scope */ - private readonly targetDir?: string; - /** Optional specific toolchain version to use */ - private readonly version?: string; + /** Manifest management service */ + private readonly manifestService: ManifestService; + /** Compiler options */ + private readonly options: ResolvedCompilerOptions; /** * Creates a new CompactCompiler instance with specified configuration. * - * @param flags - Space-separated compiler flags (e.g., '--skip-zk --verbose') - * @param targetDir - Optional subdirectory within src/ to compile (e.g., 'security', 'token') - * @param version - Optional toolchain version to use (e.g., '0.26.0') + * @param options - Compiler configuration options * @param execFn - Optional custom exec function for dependency injection * @example * ```typescript - * // Compile all files with flags - * const compiler = new CompactCompiler('--skip-zk --verbose'); + * // Compile all files with flags (flattened artifacts) + * const compiler = new CompactCompiler({ flags: '--skip-zk --verbose' }); * * // Compile specific directory - * const compiler = new CompactCompiler('', 'security'); + * const compiler = new CompactCompiler({ targetDir: 'security' }); * * // Compile with specific version - * const compiler = new CompactCompiler('--skip-zk', undefined, '0.26.0'); + * const compiler = new CompactCompiler({ flags: '--skip-zk', version: '0.26.0' }); + * + * // Compile with hierarchical artifacts structure + * const compiler = new CompactCompiler({ flags: '--skip-zk', hierarchical: true }); * * // For testing with custom exec function * const mockExec = vi.fn(); - * const compiler = new CompactCompiler('', undefined, undefined, mockExec); + * const compiler = new CompactCompiler({}, mockExec); * ``` */ - constructor( - flags = '', - targetDir?: string, - version?: string, - execFn?: ExecFunction, - ) { - this.flags = flags.trim(); - this.targetDir = targetDir; - this.version = version; + constructor(options: CompilerOptions = {}, execFn?: ExecFunction) { + // Automatically enable verbose when benchmarks are requested + const verbose = options.verbose ?? !!options.benchmarksPath; + + this.options = { + flags: options.flags ?? [], + targetDir: options.targetDir, + version: options.version, + hierarchical: options.hierarchical ?? false, + srcDir: options.srcDir ?? DEFAULT_SRC_DIR, + outDir: options.outDir ?? DEFAULT_OUT_DIR, + force: options.force ?? false, + benchmarksPath: options.benchmarksPath, + verbose, + }; this.environmentValidator = new EnvironmentValidator(execFn); - this.fileDiscovery = new FileDiscovery(); - this.compilerService = new CompilerService(execFn); + this.fileDiscovery = new FileDiscovery(this.options.srcDir); + this.compilerService = new CompilerService(execFn, { + hierarchical: this.options.hierarchical, + srcDir: this.options.srcDir, + outDir: this.options.outDir, + verbose: this.options.verbose, + captureOutput: !!this.options.benchmarksPath, // Capture output when generating benchmarks + }); + this.manifestService = new ManifestService(this.options.outDir); + } + + /** + * Parses command-line arguments into a CompilerOptions object. + * + * Supported argument patterns: + * - `--dir ` - Target specific subdirectory within srcDir + * - `--src ` - Source directory containing .compact files (default: 'src') + * - `--out ` - Output directory for artifacts (default: 'artifacts') + * - `--hierarchical` - Preserve source directory structure in artifacts output + * - `+` - Use specific toolchain version + * - Other arguments - Treated as compiler flags + * - `SKIP_ZK=true` environment variable - Adds --skip-zk flag + * + * @param args - Array of command-line arguments + * @param env - Environment variables (defaults to process.env) + * @returns Parsed CompilerOptions object + * @throws {Error} If --dir, --src, or --out flag is provided without a value + */ + static parseArgs( + args: string[], + env: NodeJS.ProcessEnv = process.env, + ): CompilerOptions { + const options: CompilerOptions = { + hierarchical: false, + force: false, + }; + const flags: string[] = []; + + if (env.SKIP_ZK === 'true') { + flags.push('--skip-zk'); + } + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--dir') { + const valueExists = + i + 1 < args.length && !args[i + 1].startsWith('--'); + if (valueExists) { + options.targetDir = args[i + 1]; + i++; + } else { + throw new Error('--dir flag requires a directory name'); + } + } else if (args[i] === '--src') { + const valueExists = + i + 1 < args.length && !args[i + 1].startsWith('--'); + if (valueExists) { + options.srcDir = args[i + 1]; + i++; + } else { + throw new Error('--src flag requires a directory path'); + } + } else if (args[i] === '--out') { + const valueExists = + i + 1 < args.length && !args[i + 1].startsWith('--'); + if (valueExists) { + options.outDir = args[i + 1]; + i++; + } else { + throw new Error('--out flag requires a directory path'); + } + } else if (args[i] === '--hierarchical') { + options.hierarchical = true; + } else if (args[i] === '--force' || args[i] === '-f') { + options.force = true; + } else if (args[i] === '--verbose' || args[i] === '-v') { + options.verbose = true; + } else if (args[i] === '--benchmarks') { + const valueExists = + i + 1 < args.length && !args[i + 1].startsWith('--'); + if (valueExists) { + options.benchmarksPath = args[i + 1]; + i++; + } else { + // Default to ./benchmarks.json if no path is provided + options.benchmarksPath = './benchmarks.json'; + } + } else if (args[i].startsWith('+')) { + options.version = args[i].slice(1) as CompactcVersion; + } else { + // Only add flag if it's not already present + if (!flags.includes(args[i])) { + flags.push(args[i]); + } + } + } + + options.flags = flags as CompilerFlag[]; + return options; } /** @@ -489,7 +296,10 @@ export class CompactCompiler { * Parses various argument formats including flags, directories, versions, and environment variables. * * Supported argument patterns: - * - `--dir ` - Target specific directory + * - `--dir ` - Target specific subdirectory within srcDir + * - `--src ` - Source directory containing .compact files (default: 'src') + * - `--out ` - Output directory for artifacts (default: 'artifacts') + * - `--hierarchical` - Preserve source directory structure in artifacts output * - `+` - Use specific toolchain version * - Other arguments - Treated as compiler flags * - `SKIP_ZK=true` environment variable - Adds --skip-zk flag @@ -497,7 +307,7 @@ export class CompactCompiler { * @param args - Array of command-line arguments * @param env - Environment variables (defaults to process.env) * @returns New CompactCompiler instance configured from arguments - * @throws {Error} If --dir flag is provided without a directory name + * @throws {Error} If --dir, --src, or --out flag is provided without a value * @example * ```typescript * // Parse command line: compact-compiler --dir security --skip-zk +0.26.0 @@ -507,6 +317,19 @@ export class CompactCompiler { * '+0.26.0' * ]); * + * // With custom source and output directories + * const compiler = CompactCompiler.fromArgs([ + * '--src', 'contracts', + * '--out', 'build/artifacts', + * '--skip-zk' + * ]); + * + * // With hierarchical artifacts structure + * const compiler = CompactCompiler.fromArgs([ + * '--hierarchical', + * '--skip-zk' + * ]); + * * // With environment variable * const compiler = CompactCompiler.fromArgs( * ['--dir', 'token'], @@ -521,35 +344,8 @@ export class CompactCompiler { args: string[], env: NodeJS.ProcessEnv = process.env, ): CompactCompiler { - let targetDir: string | undefined; - const flags: string[] = []; - let version: string | undefined; - - if (env.SKIP_ZK === 'true') { - flags.push('--skip-zk'); - } - - for (let i = 0; i < args.length; i++) { - if (args[i] === '--dir') { - const dirNameExists = - i + 1 < args.length && !args[i + 1].startsWith('--'); - if (dirNameExists) { - targetDir = args[i + 1]; - i++; - } else { - throw new Error('--dir flag requires a directory name'); - } - } else if (args[i].startsWith('+')) { - version = args[i].slice(1); - } else { - // Only add flag if it's not already present - if (!flags.includes(args[i])) { - flags.push(args[i]); - } - } - } - - return new CompactCompiler(flags.join(' '), targetDir, version); + const options = CompactCompiler.parseArgs(args, env); + return new CompactCompiler(options); } /** @@ -576,15 +372,19 @@ export class CompactCompiler { * } * ``` */ - async validateEnvironment(): Promise { - const { devToolsVersion, toolchainVersion } = - await this.environmentValidator.validate(this.version); + async validateEnvironment(): Promise<{ + compactToolVersion: CompactToolVersion; + compactcVersion: CompactcVersion; + }> { + const { compactToolVersion, compactcVersion } = + await this.environmentValidator.validate(this.options.version); UIService.displayEnvInfo( - devToolsVersion, - toolchainVersion, - this.targetDir, - this.version, + compactToolVersion, + compactcVersion, + this.options.targetDir, + this.options.version, ); + return { compactToolVersion, compactcVersion }; } /** @@ -616,12 +416,42 @@ export class CompactCompiler { * ``` */ async compile(): Promise { - await this.validateEnvironment(); + const startTime = Date.now(); + const { compactToolVersion, compactcVersion } = + await this.validateEnvironment(); + + // Check for structure mismatch + const requestedStructure = this.options.hierarchical + ? 'hierarchical' + : 'flattened'; + const existingManifest = + await this.manifestService.checkMismatch(requestedStructure); + + if (existingManifest) { + if (this.options.force) { + // Auto-clean with --force flag + const spinner = ora(); + spinner.info( + chalk.yellow( + `[COMPILE] Cleaning existing "${existingManifest.structure}" artifacts (--force)`, + ), + ); + await this.manifestService.cleanOutputDirectory(); + } else { + // Throw error to be handled by CLI for interactive prompt + throw new StructureMismatchError( + existingManifest.structure, + requestedStructure, + ); + } + } - const searchDir = this.targetDir ? join(SRC_DIR, this.targetDir) : SRC_DIR; + const searchDir = this.options.targetDir + ? join(this.options.srcDir, this.options.targetDir) + : this.options.srcDir; // Validate target directory exists - if (this.targetDir && !existsSync(searchDir)) { + if (this.options.targetDir && !existsSync(searchDir)) { throw new DirectoryNotFoundError( `Target directory ${searchDir} does not exist`, searchDir, @@ -631,14 +461,93 @@ export class CompactCompiler { const compactFiles = await this.fileDiscovery.getCompactFiles(searchDir); if (compactFiles.length === 0) { - UIService.showNoFiles(this.targetDir); + UIService.showNoFiles(this.options.targetDir); return; } - UIService.showCompilationStart(compactFiles.length, this.targetDir); + UIService.showCompilationStart(compactFiles.length, this.options.targetDir); + + // Track artifacts: hierarchical uses nested tree, flattened uses string[] + const hierarchicalArtifacts: HierarchicalArtifacts = {}; + const flatArtifacts: string[] = []; + // Track benchmarks: hierarchical uses nested tree, flattened uses flat record + const hierarchicalBenchmarks: HierarchicalBenchmarks = {}; + const flatBenchmarks: Record = {}; for (const [index, file] of compactFiles.entries()) { - await this.compileFile(file, index, compactFiles.length); + const metadata = await this.compileFile(file, index, compactFiles.length); + const artifactName = basename(file, '.compact'); + + if (requestedStructure === 'hierarchical') { + this.addArtifactToTree(hierarchicalArtifacts, file); + this.addBenchmarkToTree(hierarchicalBenchmarks, file, { + type: metadata.type, + circuits: metadata.circuits, + }); + } else { + flatArtifacts.push(artifactName); + flatBenchmarks[artifactName] = { + type: metadata.type, + circuits: metadata.circuits, + }; + } + } + + // Write manifest after successful compilation + const buildDuration = Date.now() - startTime; + + // Get compiler flags (undefined if empty array) + const compilerFlags = + this.options.flags.length > 0 ? this.options.flags : undefined; + + // Get Node.js major version + const nodeVersion = process.version.match(/^v(\d+)/)?.[1] as + | NodeVersion + | undefined; + + // Get platform identifier + const platform = `${process.platform}-${process.arch}` as Platform; + + await this.manifestService.write({ + structure: requestedStructure, + compactcVersion: compactcVersion as CompactcVersion, + compactToolVersion: compactToolVersion as CompactToolVersion, + createdAt: new Date().toISOString(), + buildDuration, + nodeVersion, + platform, + sourcePath: this.options.srcDir, + outputPath: this.options.outDir, + compilerFlags, + artifacts: + requestedStructure === 'hierarchical' + ? hierarchicalArtifacts + : flatArtifacts, + }); + + // Write benchmarks file if path is specified + if (this.options.benchmarksPath) { + const benchmarkReport: BenchmarkReport = { + structure: requestedStructure, + compactcVersion: compactcVersion as string, + compactToolVersion: compactToolVersion as CompactToolVersion, + contracts: + requestedStructure === 'hierarchical' + ? hierarchicalBenchmarks + : flatBenchmarks, + }; + + await writeFile( + this.options.benchmarksPath, + JSON.stringify(benchmarkReport, null, 2), + ); + + const spinner = ora(); + spinner.succeed( + chalk.green( + `[COMPILE] Benchmarks written to ${this.options.benchmarksPath}`, + ), + ); } } @@ -649,6 +558,7 @@ export class CompactCompiler { * @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 + * @returns Contract metadata (type and circuits info) * @throws {CompilationError} If compilation fails * @private */ @@ -656,29 +566,26 @@ export class CompactCompiler { file: string, index: number, total: number, - ): Promise { + ): Promise { const step = `[${index + 1}/${total}]`; const spinner = ora( chalk.blue(`[COMPILE] ${step} Compiling ${file}`), ).start(); + spinner.stopAndPersist({ symbol: '-' }); try { const result = await this.compilerService.compileFile( file, - this.flags, - this.version, + this.options.flags, + this.options.version, ); - 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'); + // Show success message (output was already streamed by spawn) + ora().succeed(chalk.green(`[COMPILE] ${step} Compiled ${file}`)); - if (filteredOutput) { - UIService.printOutput(filteredOutput, chalk.cyan); - } - UIService.printOutput(result.stderr, chalk.yellow); + return result.metadata; } catch (error) { - spinner.fail(chalk.red(`[COMPILE] ${step} Failed ${file}`)); + ora().fail(chalk.red(`[COMPILE] ${step} Failed ${file}`)); if ( error instanceof CompilationError && @@ -699,15 +606,113 @@ export class CompactCompiler { } /** - * For testing + * Cleans the output directory by removing all artifacts. + * Used when user confirms deletion after structure mismatch. */ - get testFlags(): string { - return this.flags; + async cleanOutputDirectory(): Promise { + await this.manifestService.cleanOutputDirectory(); } - get testTargetDir(): string | undefined { - return this.targetDir; + + /** + * Compiles after cleaning the output directory. + * Used when user confirms deletion after structure mismatch. + */ + async cleanAndCompile(): Promise { + const spinner = ora(); + spinner.info(chalk.yellow('[COMPILE] Cleaning existing artifacts...')); + await this.cleanOutputDirectory(); + await this.compile(); + } + + /** + * For testing - returns the resolved options object + */ + get testOptions(): ResolvedCompilerOptions { + return this.options; + } + + /** + * Adds an artifact to the hierarchical tree structure. + * Creates nested nodes as needed based on the file path. + * + * @param tree - The hierarchical artifacts tree to add to + * @param file - The file path (e.g., 'math/test/Uint128.mock.compact') + */ + private addArtifactToTree(tree: HierarchicalArtifacts, file: string): void { + const artifactName = basename(file, '.compact'); + const subDir = dirname(file); + + if (subDir === '.') { + // Root level artifacts go into a 'root' node + if (!tree.root) { + tree.root = { artifacts: [] }; + } + (tree.root.artifacts as string[]).push(artifactName); + } else { + // Navigate/create nested structure + const pathParts = subDir.split('/'); + let current: HierarchicalArtifactNode = tree; + + for (const part of pathParts) { + if (!current[part]) { + current[part] = { artifacts: [] }; + } + current = current[part] as HierarchicalArtifactNode; + } + + // Add artifact to the current node + if (!current.artifacts) { + current.artifacts = []; + } + (current.artifacts as string[]).push(artifactName); + } } - get testVersion(): string | undefined { - return this.version; + + /** + * Adds a benchmark to the hierarchical tree structure. + * Creates nested nodes as needed based on the file path. + * Mirrors the structure of addArtifactToTree but stores BenchmarkContract. + * + * @param tree - The hierarchical benchmarks tree to add to + * @param file - The file path (e.g., 'math/test/Uint128.mock.compact') + * @param benchmark - The benchmark contract metadata (type and circuits) + */ + private addBenchmarkToTree( + tree: HierarchicalBenchmarks, + file: string, + benchmark: BenchmarkContract, + ): void { + const contractName = basename(file, '.compact'); + const subDir = dirname(file); + + if (subDir === '.') { + // Root level contracts go into a 'root' node + if (!tree.root) { + tree.root = { contracts: {} }; + } + if (!tree.root.contracts) { + tree.root.contracts = {}; + } + (tree.root.contracts as Record)[contractName] = + benchmark; + } else { + // Navigate/create nested structure + const pathParts = subDir.split('/'); + let current: HierarchicalBenchmarkNode = tree; + + for (const part of pathParts) { + if (!current[part]) { + current[part] = { contracts: {} }; + } + current = current[part] as HierarchicalBenchmarkNode; + } + + // Add contract to the current node + if (!current.contracts) { + current.contracts = {}; + } + (current.contracts as Record)[contractName] = + benchmark; + } } } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..2b6b23c --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,41 @@ +/** + * Configuration constants for the Compact compiler CLI. + * This is the single source of truth for all version definitions. + */ + +/** Default source directory containing .compact files */ +export const DEFAULT_SRC_DIR = 'src'; + +/** Default output directory for compiled artifacts */ +export const DEFAULT_OUT_DIR = 'artifacts'; + +/** + * Supported compactc compiler versions. + * @note Update this array when new compiler versions are released. + */ +export const COMPACTC_VERSIONS = [ + '0.23.0', + '0.24.0', + '0.25.0', + '0.26.0', +] as const; + +/** + * Supported compact-tools CLI versions. + * @note Update this array when new CLI versions are released. + */ +export const COMPACT_TOOL_VERSIONS = ['0.1.0', '0.2.0', '0.3.0'] as const; + +/** Minimum supported compact-tools version */ +export const MIN_COMPACT_TOOL_VERSION = + COMPACT_TOOL_VERSIONS[COMPACT_TOOL_VERSIONS.length - 1]; + +/** Maximum supported compactc version */ +export const MAX_COMPACTC_VERSION = + COMPACTC_VERSIONS[COMPACTC_VERSIONS.length - 1]; + +/** Type derived from supported compactc versions */ +export type CompactcVersion = (typeof COMPACTC_VERSIONS)[number]; + +/** Type derived from supported compact-tools versions */ +export type CompactToolVersion = (typeof COMPACT_TOOL_VERSIONS)[number]; diff --git a/packages/cli/src/runBuilder.ts b/packages/cli/src/runBuilder.ts index c65e000..09e589a 100644 --- a/packages/cli/src/runBuilder.ts +++ b/packages/cli/src/runBuilder.ts @@ -6,17 +6,23 @@ import { CompactBuilder } from './Builder.ts'; /** * Executes the Compact builder CLI. - * Builds projects using the `CompactBuilder` class with provided flags, including compilation and additional steps. + * Builds projects using the `CompactBuilder` class with provided options, including compilation and additional steps. + * + * Supports compiler options: + * - `--dir ` - Compile specific subdirectory + * - `--src ` - Source directory (default: src) + * - `--out ` - Output directory (default: artifacts) + * - `--hierarchical` - Preserve directory structure in output + * - `+` - Use specific toolchain version * * @example * ```bash - * npx compact-builder --skip-zk + * npx compact-builder + * npx compact-builder --src contracts --out build * ``` * Expected output: * ``` * ℹ [BUILD] Compact builder started - * ℹ [COMPILE] COMPACT_HOME: /path/to/compactc - * ℹ [COMPILE] COMPACTC_PATH: /path/to/compactc/compactc * ℹ [COMPILE] Found 1 .compact file(s) to compile * ✔ [COMPILE] [1/1] Compiled Foo.compact * Compactc version: 0.26.0 @@ -29,8 +35,8 @@ async function runBuilder(): Promise { const spinner = ora(chalk.blue('[BUILD] Compact Builder started')).info(); try { - const compilerFlags = process.argv.slice(2).join(' '); - const builder = new CompactBuilder(compilerFlags); + const args = process.argv.slice(2); + const builder = CompactBuilder.fromArgs(args); await builder.build(); } catch (err) { spinner.fail( diff --git a/packages/cli/src/runCompiler.ts b/packages/cli/src/runCompiler.ts index b97200f..83cf782 100644 --- a/packages/cli/src/runCompiler.ts +++ b/packages/cli/src/runCompiler.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import * as readline from 'node:readline'; import chalk from 'chalk'; import ora, { type Ora } from 'ora'; import { CompactCompiler } from './Compiler.ts'; @@ -7,6 +8,7 @@ import { type CompilationError, isPromisifiedChildProcessError, } from './types/errors.ts'; +import { StructureMismatchError } from './types/manifest.ts'; /** * Executes the Compact compiler CLI with improved error handling and user feedback. @@ -51,11 +53,90 @@ async function runCompiler(): Promise { const compiler = CompactCompiler.fromArgs(args); await compiler.compile(); } catch (error) { + // Handle structure mismatch with interactive prompt + if (error instanceof StructureMismatchError) { + await handleStructureMismatch(error, spinner); + return; + } + handleError(error, spinner); process.exit(1); } } +/** + * Handles structure mismatch by prompting the user for confirmation. + * In non-interactive mode (non-TTY), exits with instructions to use --force. + * + * @param error - The StructureMismatchError that was thrown + * @param spinner - Ora spinner instance for consistent UI messaging + */ +async function handleStructureMismatch( + error: StructureMismatchError, + spinner: Ora, +): Promise { + spinner.warn( + chalk.yellow( + `[COMPILE] Existing artifacts use "${error.existingStructure}" structure.`, + ), + ); + spinner.warn( + chalk.yellow( + `[COMPILE] You are compiling with "${error.requestedStructure}" structure.`, + ), + ); + + // Check if we're in an interactive terminal + if (!process.stdin.isTTY) { + spinner.fail( + chalk.red( + '[COMPILE] Structure mismatch detected. Use --force to auto-delete existing artifacts.', + ), + ); + process.exit(1); + } + + // Prompt user for confirmation + const confirmed = await promptConfirmation( + 'Delete existing artifacts and recompile? (y/N) ', + ); + + if (confirmed) { + try { + const args = process.argv.slice(2); + const compiler = CompactCompiler.fromArgs(args); + await compiler.cleanAndCompile(); + } catch (retryError) { + handleError(retryError, spinner); + process.exit(1); + } + } else { + spinner.info(chalk.blue('[COMPILE] Compilation aborted by user.')); + process.exit(0); + } +} + +/** + * Prompts the user for a yes/no confirmation. + * + * @param question - The question to display + * @returns Promise resolving to true if user confirms, false otherwise + */ +function promptConfirmation(question: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(chalk.yellow(question), (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + resolve(normalized === 'y' || normalized === 'yes'); + }); + }); +} + /** * Centralized error handling with specific error types and user-friendly messages. * @@ -171,7 +252,23 @@ function showUsageHelp(): void { console.log(chalk.yellow('\nOptions:')); console.log( chalk.yellow( - ' --dir Compile specific directory (access, archive, security, token, utils)', + ' --dir Compile specific subdirectory within src', + ), + ); + console.log( + chalk.yellow(' --src Source directory (default: src)'), + ); + console.log( + chalk.yellow(' --out Output directory (default: artifacts)'), + ); + console.log( + chalk.yellow( + ' --hierarchical Preserve source directory structure in artifacts output', + ), + ); + console.log( + chalk.yellow( + ' --force, -f Force delete existing artifacts on structure mismatch', ), ); console.log( @@ -182,10 +279,20 @@ function showUsageHelp(): void { ' + Use specific toolchain version (e.g., +0.26.0)', ), ); + console.log(chalk.yellow('\nArtifact Output Structure:')); + console.log(chalk.yellow(' Default (flattened): //')); + console.log( + chalk.yellow(' With --hierarchical: ///'), + ); console.log(chalk.yellow('\nExamples:')); console.log( chalk.yellow( - ' compact-compiler # Compile all files', + ' compact-compiler # Compile all files (flattened)', + ), + ); + console.log( + chalk.yellow( + ' compact-compiler --hierarchical # Compile with nested structure', ), ); console.log( @@ -198,6 +305,11 @@ function showUsageHelp(): void { ' compact-compiler --dir access --skip-zk # Compile access with flags', ), ); + console.log( + chalk.yellow( + ' compact-compiler --src contracts --out build # Custom directories', + ), + ); console.log( chalk.yellow( ' SKIP_ZK=true compact-compiler --dir token # Use environment variable', diff --git a/packages/cli/src/services/CompilerService.ts b/packages/cli/src/services/CompilerService.ts new file mode 100644 index 0000000..a892476 --- /dev/null +++ b/packages/cli/src/services/CompilerService.ts @@ -0,0 +1,347 @@ +import { exec as execCallback, spawn } from 'node:child_process'; +import { basename, dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { DEFAULT_OUT_DIR, DEFAULT_SRC_DIR } from '../config.ts'; +import type { + CircuitInfo, + ContractMetadata, +} from '../types/benchmark.ts'; +import { CompilationError } from '../types/errors.ts'; +import type { CompilerFlag } from '../types/manifest.ts'; +import type { ExecFunction } from './EnvironmentValidator.ts'; + +/** + * Options for configuring the CompilerService. + */ +export interface CompilerServiceOptions { + /** Whether to use hierarchical output structure */ + hierarchical?: boolean; + /** Source directory containing .compact files */ + srcDir?: string; + /** Output directory for compiled artifacts */ + outDir?: string; + /** Show detailed compiler output (circuit progress, etc.) */ + verbose?: boolean; + /** Whether to capture output for parsing circuit details (needed for benchmarks) */ + captureOutput?: boolean; +} + +/** Resolved options for CompilerService with defaults applied */ +type ResolvedCompilerServiceOptions = Required; + +/** + * Result of compiling a file, including raw output and parsed metadata. + */ +export interface CompileResult { + /** Raw stdout from the compiler */ + stdout: string; + /** Raw stderr from the compiler */ + stderr: string; + /** Parsed contract metadata (type and circuits) */ + metadata: ContractMetadata; +} + +/** + * Regex pattern to match circuit information in compiler output. + * Matches lines like: ` circuit "gt" (k=12, rows=2639)` + */ +const CIRCUIT_PATTERN = /circuit\s+"([^"]+)"\s+\(k=(\d+),\s*rows=(\d+)\)/g; + +/** + * Parses circuit information from compiler output. + * Extracts circuit names, k values, and row counts. + * + * @param output - The compiler stdout/stderr output + * @returns Array of CircuitInfo objects, empty if no circuits found + * @example + * ```typescript + * const output = `Compiling 2 circuits: + * circuit "gt" (k=12, rows=2639) + * circuit "gte" (k=12, rows=2643) + * Overall progress [====================] 2/2`; + * + * const circuits = parseCircuitInfo(output); + * // Returns: [{ name: "gt", k: 12, rows: 2639 }, { name: "gte", k: 12, rows: 2643 }] + * ``` + */ +export function parseCircuitInfo(output: string): CircuitInfo[] { + // Use a Map to deduplicate circuits by name (spinner animation can cause duplicates) + const circuitMap = new Map(); + + // Reset regex state for multiple calls + CIRCUIT_PATTERN.lastIndex = 0; + + for ( + let match = CIRCUIT_PATTERN.exec(output); + match !== null; + match = CIRCUIT_PATTERN.exec(output) + ) { + const name = match[1]; + // Only add if not already seen (first occurrence wins) + if (!circuitMap.has(name)) { + circuitMap.set(name, { + name, + k: Number.parseInt(match[2], 10), + rows: Number.parseInt(match[3], 10), + }); + } + } + + return Array.from(circuitMap.values()); +} + +/** + * Determines contract metadata from compiler output. + * A contract is 'top-level' if it has circuits, 'module' otherwise. + * + * @param output - The compiler stdout/stderr output + * @returns ContractMetadata with type and optional circuits + */ +export function parseContractMetadata(output: string): ContractMetadata { + const circuits = parseCircuitInfo(output); + + if (circuits.length > 0) { + return { + type: 'top-level', + circuits, + }; + } + + return { + type: 'module', + }; +} + +/** + * Service responsible for compiling individual .compact files. + * Handles command construction, execution, and error processing. + * + * @class CompilerService + * @example + * ```typescript + * const compiler = new CompilerService(); + * const result = await compiler.compileFile( + * 'contracts/Token.compact', + * ['--skip-zk', '--verbose'], + * '0.26.0' + * ); + * console.log('Compilation output:', result.stdout); + * ``` + */ +export class CompilerService { + private execFn: ExecFunction; + private options: ResolvedCompilerServiceOptions; + private useSpawn: boolean; + + /** + * Creates a new CompilerService instance. + * + * @param execFn - Function to execute shell commands (defaults to promisified child_process.exec) + * @param options - Compiler service options + */ + constructor( + execFn?: ExecFunction, + options: CompilerServiceOptions = {}, + ) { + // If no custom execFn provided, use spawn for real compilation (streams output) + // If custom execFn provided (testing), use that instead + this.useSpawn = execFn === undefined; + this.execFn = execFn ?? promisify(execCallback); + this.options = { + hierarchical: options.hierarchical ?? false, + srcDir: options.srcDir ?? DEFAULT_SRC_DIR, + outDir: options.outDir ?? DEFAULT_OUT_DIR, + verbose: options.verbose ?? false, + captureOutput: options.captureOutput ?? false, + }; + } + + /** + * Compiles a single .compact file using the Compact CLI. + * Constructs the appropriate command with flags and version, then executes it. + * + * By default, uses flattened output structure where all artifacts go to `//`. + * When `hierarchical` is true, preserves source directory structure: `///`. + * + * @param file - Relative path to the .compact file from srcDir + * @param flags - Array of compiler flags (e.g., ['--skip-zk', '--verbose']) + * @param version - Optional specific toolchain version to use + * @returns Promise resolving to compilation output with parsed metadata + * @throws {CompilationError} If compilation fails for any reason + * @example + * ```typescript + * try { + * const result = await compiler.compileFile( + * 'security/AccessControl.compact', + * ['--skip-zk'], + * '0.26.0' + * ); + * console.log('Success:', result.stdout); + * console.log('Contract type:', result.metadata.type); + * if (result.metadata.circuits) { + * console.log('Circuits:', result.metadata.circuits); + * } + * } catch (error) { + * if (error instanceof CompilationError) { + * console.error('Compilation failed for', error.file); + * } + * } + * ``` + */ + async compileFile( + file: string, + flags: CompilerFlag[], + version?: string, + ): Promise { + const inputPath = join(this.options.srcDir, file); + const fileDir = dirname(file); + const fileName = basename(file, '.compact'); + + // Flattened (default): // + // Hierarchical: /// + const outputDir = + this.options.hierarchical && fileDir !== '.' + ? join(this.options.outDir, fileDir, fileName) + : join(this.options.outDir, fileName); + + const versionFlag = version ? `+${version}` : ''; + + const flagsStr = flags.length > 0 ? ` ${flags.join(' ')}` : ''; + const baseCommand = `compact compile${versionFlag ? ` ${versionFlag}` : ''}${flagsStr} "${inputPath}" "${outputDir}"`; + + // For testing, use the provided exec function directly + if (!this.useSpawn) { + try { + const { stdout, stderr } = await this.execFn(baseCommand); + const combinedOutput = `${stdout}\n${stderr}`; + const metadata = parseContractMetadata(combinedOutput); + return { stdout, stderr, metadata }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new CompilationError( + `Failed to compile ${file}: ${message}`, + file, + error, + ); + } + } + + // Build command arguments for spawn + const args: string[] = ['compile']; + if (versionFlag) { + args.push(versionFlag); + } + for (const flag of flags) { + args.push(flag); + } + args.push(inputPath, outputDir); + + // When capturing output for benchmarks, use 'script' command to create PTY + // This properly captures animated progress bar output (circuit details) + if (this.options.captureOutput) { + const baseCommand = `compact ${args.join(' ')}`; + let wrapperCommand: string; + let wrapperArgs: string[]; + + if (process.platform === 'darwin') { + // macOS: script -q /dev/null sh -c "command" + wrapperCommand = 'script'; + wrapperArgs = ['-q', '/dev/null', 'sh', '-c', baseCommand]; + } else { + // Linux: script -q -c "command" /dev/null + wrapperCommand = 'script'; + wrapperArgs = ['-q', '-c', baseCommand, '/dev/null']; + } + + return new Promise((resolve, reject) => { + const child = spawn(wrapperCommand, wrapperArgs, { + stdio: ['inherit', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data: Buffer) => { + const chunk = data.toString(); + stdout += chunk; + // Show output since verbose is enabled when capturing + process.stdout.write(chunk); + }); + + child.stderr?.on('data', (data: Buffer) => { + const chunk = data.toString(); + stderr += chunk; + // Show output since verbose is enabled when capturing + process.stderr.write(chunk); + }); + + child.on('close', async (code) => { + if (code === 0) { + const combinedOutput = `${stdout}\n${stderr}`; + const metadata = parseContractMetadata(combinedOutput); + resolve({ stdout, stderr, metadata }); + } else { + reject( + new CompilationError( + `Failed to compile ${file}: exit code ${code}`, + file, + new Error(stderr || stdout || `Compilation failed with exit code ${code}`), + ), + ); + } + }); + + child.on('error', (error) => { + reject( + new CompilationError( + `Failed to compile ${file}: ${error.message}`, + file, + error, + ), + ); + }); + }); + } + + // Use spawn with stdio based on verbose option + // - verbose: inherit stdio to show full animated output + // - quiet: ignore stdio for cleaner output + return new Promise((resolve, reject) => { + const child = spawn('compact', args, { + stdio: this.options.verbose ? 'inherit' : 'ignore', + }); + + child.on('close', async (code) => { + if (code === 0) { + // Check if compiled output contains circuits by looking for keys directory + // Top-level contracts have circuit keys, modules don't + const keysDir = join(outputDir, 'keys'); + const { existsSync } = await import('node:fs'); + const isTopLevel = existsSync(keysDir); + const metadata: ContractMetadata = isTopLevel + ? { type: 'top-level' } + : { type: 'module' }; + resolve({ stdout: '', stderr: '', metadata }); + } else { + reject( + new CompilationError( + `Failed to compile ${file}: exit code ${code}`, + file, + new Error(`Compilation failed with exit code ${code}`), + ), + ); + } + }); + + child.on('error', (error) => { + reject( + new CompilationError( + `Failed to compile ${file}: ${error.message}`, + file, + error, + ), + ); + }); + }); + } +} diff --git a/packages/cli/src/services/EnvironmentValidator.ts b/packages/cli/src/services/EnvironmentValidator.ts new file mode 100644 index 0000000..1b81108 --- /dev/null +++ b/packages/cli/src/services/EnvironmentValidator.ts @@ -0,0 +1,183 @@ +import { exec as execCallback } from 'node:child_process'; +import { promisify } from 'node:util'; +import chalk from 'chalk'; +import ora from 'ora'; +import { + type CompactcVersion, + type CompactToolVersion, + MAX_COMPACTC_VERSION, + MIN_COMPACT_TOOL_VERSION, +} from '../config.ts'; +import { CompactCliNotFoundError } from '../types/errors.ts'; + +/** + * Compares two semver version strings. + * @param a - First version string (e.g., "0.26.0") + * @param b - Second version string (e.g., "0.27.0") + * @returns -1 if a < b, 0 if a === b, 1 if a > b + */ +function compareVersions(a: string, b: string): number { + const partsA = a.split('.').map(Number); + const partsB = b.split('.').map(Number); + + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const numA = partsA[i] ?? 0; + const numB = partsB[i] ?? 0; + if (numA < numB) return -1; + if (numA > numB) return 1; + } + return 0; +} + +/** + * Function type for executing shell commands. + * Allows dependency injection for testing and customization. + * + * @param command - The shell command to execute + * @returns Promise resolving to command output + */ +export type ExecFunction = ( + command: string, +) => Promise<{ stdout: string; stderr: string }>; + +/** + * Service responsible for validating the Compact CLI environment. + * Checks CLI availability, retrieves version information, and ensures + * the toolchain is properly configured before compilation. + * + * @class EnvironmentValidator + * @example + * ```typescript + * const validator = new EnvironmentValidator(); + * await validator.validate('0.26.0'); + * const version = await validator.getCompactToolVersion(); + * ``` + */ +export class EnvironmentValidator { + private execFn: ExecFunction; + + /** + * Creates a new EnvironmentValidator instance. + * + * @param execFn - Function to execute shell commands (defaults to promisified child_process.exec) + */ + constructor(execFn: ExecFunction = promisify(execCallback)) { + this.execFn = execFn; + } + + /** + * Checks if the Compact CLI is available in the system PATH. + * + * @returns Promise resolving to true if CLI is available, false otherwise + * @example + * ```typescript + * const isAvailable = await validator.checkCompactAvailable(); + * if (!isAvailable) { + * throw new Error('Compact CLI not found'); + * } + * ``` + */ + async checkCompactAvailable(): Promise { + try { + await this.execFn('compact --version'); + return true; + } catch { + return false; + } + } + + /** + * Retrieves the version of the Compact developer tools. + * + * @returns Promise resolving to the version string + * @throws {Error} If the CLI is not available or command fails + * @example + * ```typescript + * const version = await validator.getCompactToolVersion(); + * console.log(`Using Compact ${version}`); + * ``` + */ + async getCompactToolVersion(): Promise { + const { stdout } = await this.execFn('compact --version'); + return stdout.trim(); + } + + /** + * Retrieves the version of the Compact toolchain/compiler. + * + * @param version - Optional specific toolchain version to query + * @returns Promise resolving to the compactc version + * @throws {Error} If the CLI is not available or command fails + * @example + * ```typescript + * const compactcVersion = await validator.getCompactcVersion('0.26.0'); + * console.log(`Compiler: ${compactcVersion}`); + * ``` + */ + async getCompactcVersion(version?: string): Promise { + const versionFlag = version ? `+${version}` : ''; + const { stdout } = await this.execFn( + `compact compile ${versionFlag} --version`, + ); + return stdout.trim(); + } + + /** + * Validates the entire Compact environment and ensures it's ready for compilation. + * Checks CLI availability and retrieves version information. + * + * @param version - Optional specific toolchain version to validate + * @throws {CompactCliNotFoundError} If the Compact CLI is not available + * @throws {Error} If version commands fail or compactc version is unsupported + * @example + * ```typescript + * try { + * await validator.validate('0.26.0'); + * console.log('Environment validated successfully'); + * } catch (error) { + * if (error instanceof CompactCliNotFoundError) { + * console.error('Please install Compact CLI'); + * } + * } + * ``` + */ + async validate(version?: string): Promise<{ + compactToolVersion: CompactToolVersion; + compactcVersion: CompactcVersion; + }> { + const isAvailable = await this.checkCompactAvailable(); + if (!isAvailable) { + throw new CompactCliNotFoundError( + "'compact' CLI not found in PATH. Please install the Compact developer tools.", + ); + } + + const compactToolVersion = await this.getCompactToolVersion(); + const compactcVersion = await this.getCompactcVersion(version); + + // Warn if compact-tools version is older than minimum + if (compareVersions(compactToolVersion, MIN_COMPACT_TOOL_VERSION) < 0) { + const spinner = ora(); + spinner.warn( + chalk.yellow( + `[COMPILE] compact-tools ${compactToolVersion} is outdated. ` + + `Run 'compact self update' to update to ${MIN_COMPACT_TOOL_VERSION} or later.`, + ), + ); + } + + // Error if compactc version is newer than supported + if (compareVersions(compactcVersion, MAX_COMPACTC_VERSION) > 0) { + throw new Error( + `compactc ${compactcVersion} is not yet supported. ` + + `Maximum supported version is ${MAX_COMPACTC_VERSION}. ` + + 'Please update compact-tools or use an older compiler version.', + ); + } + + return { + compactToolVersion: compactToolVersion as CompactToolVersion, + compactcVersion: compactcVersion as CompactcVersion, + }; + } +} diff --git a/packages/cli/src/services/FileDiscovery.ts b/packages/cli/src/services/FileDiscovery.ts new file mode 100644 index 0000000..139be39 --- /dev/null +++ b/packages/cli/src/services/FileDiscovery.ts @@ -0,0 +1,70 @@ +import { readdir } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { DEFAULT_SRC_DIR } from '../config.ts'; + +/** + * Service responsible for discovering .compact files in the source directory. + * Recursively scans directories and filters for .compact file extensions. + * + * @class FileDiscovery + * @example + * ```typescript + * const discovery = new FileDiscovery('src'); + * const files = await discovery.getCompactFiles('src/security'); + * console.log(`Found ${files.length} .compact files`); + * ``` + */ +export class FileDiscovery { + private srcDir: string; + + /** + * Creates a new FileDiscovery instance. + * + * @param srcDir - Base source directory for relative path calculation (default: 'src') + */ + constructor(srcDir: string = DEFAULT_SRC_DIR) { + this.srcDir = srcDir; + } + + /** + * Recursively discovers all .compact files in a directory. + * Returns relative paths from the srcDir for consistent processing. + * + * @param dir - Directory path to search (relative or absolute) + * @returns Promise resolving to array of relative file paths + * @example + * ```typescript + * const files = await discovery.getCompactFiles('src'); + * // Returns: ['contracts/Token.compact', 'security/AccessControl.compact'] + * ``` + */ + async getCompactFiles(dir: string): Promise { + try { + const dirents = await readdir(dir, { withFileTypes: true }); + const filePromises = dirents.map(async (entry) => { + const fullPath = join(dir, entry.name); + try { + if (entry.isDirectory()) { + return await this.getCompactFiles(fullPath); + } + + if (entry.isFile() && fullPath.endsWith('.compact')) { + return [relative(this.srcDir, fullPath)]; + } + return []; + } catch (err) { + // biome-ignore lint/suspicious/noConsole: Needed to display error and file path + console.warn(`Error accessing ${fullPath}:`, err); + return []; + } + }); + + const results = await Promise.all(filePromises); + return results.flat(); + } catch (err) { + // biome-ignore lint/suspicious/noConsole: Needed to display error and dir path + console.error(`Failed to read dir: ${dir}`, err); + return []; + } + } +} diff --git a/packages/cli/src/services/ManifestService.ts b/packages/cli/src/services/ManifestService.ts new file mode 100644 index 0000000..21d8a10 --- /dev/null +++ b/packages/cli/src/services/ManifestService.ts @@ -0,0 +1,90 @@ +import { existsSync } from 'node:fs'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { DEFAULT_OUT_DIR } from '../config.ts'; +import { type ArtifactManifest, MANIFEST_FILENAME } from '../types/manifest.ts'; + +/** + * Service responsible for managing the artifact manifest file. + * Handles reading, writing, and comparing manifest data to detect structure mismatches. + * + * @class ManifestService + * @example + * ```typescript + * const manifestService = new ManifestService('artifacts'); + * const manifest = await manifestService.read(); + * if (manifest && manifest.structure !== 'hierarchical') { + * // Structure mismatch detected + * } + * ``` + */ +export class ManifestService { + private outDir: string; + + /** + * Creates a new ManifestService instance. + * + * @param outDir - Output directory where the manifest is stored + */ + constructor(outDir: string = DEFAULT_OUT_DIR) { + this.outDir = outDir; + } + + /** + * Gets the full path to the manifest file. + */ + get manifestPath(): string { + return join(this.outDir, MANIFEST_FILENAME); + } + + /** + * Reads the artifact manifest from the output directory. + * + * @returns Promise resolving to the manifest or null if not found/invalid + */ + async read(): Promise { + try { + if (!existsSync(this.manifestPath)) { + return null; + } + const content = await readFile(this.manifestPath, 'utf-8'); + return JSON.parse(content) as ArtifactManifest; + } catch { + return null; + } + } + + /** + * Writes the artifact manifest to the output directory. + * + * @param manifest - The manifest to write + */ + async write(manifest: ArtifactManifest): Promise { + await writeFile(this.manifestPath, JSON.stringify(manifest, null, 2)); + } + + /** + * Checks if there's a structure mismatch between existing and requested structure. + * + * @param requestedStructure - The structure type being requested + * @returns Promise resolving to the existing manifest if mismatch, null otherwise + */ + async checkMismatch( + requestedStructure: 'flattened' | 'hierarchical', + ): Promise { + const existing = await this.read(); + if (existing && existing.structure !== requestedStructure) { + return existing; + } + return null; + } + + /** + * Deletes the output directory and all its contents. + */ + async cleanOutputDirectory(): Promise { + if (existsSync(this.outDir)) { + await rm(this.outDir, { recursive: true, force: true }); + } + } +} diff --git a/packages/cli/src/services/UIService.ts b/packages/cli/src/services/UIService.ts new file mode 100644 index 0000000..6a510a9 --- /dev/null +++ b/packages/cli/src/services/UIService.ts @@ -0,0 +1,107 @@ +import chalk from 'chalk'; +import ora from 'ora'; +import type { CompactcVersion, CompactToolVersion } from '../config.ts'; + +/** + * Utility service for handling user interface output and formatting. + * Provides consistent styling and formatting for compiler messages and output. + * + * @example + * ```typescript + * UIService.displayEnvInfo('0.3.0', '0.26.0', 'security'); + * UIService.printOutput('Compilation successful', chalk.green); + * ``` + */ +export const UIService = { + /** + * Prints formatted output with consistent indentation and coloring. + * Filters empty lines and adds consistent indentation for readability. + * + * @param output - Raw output text to format + * @param colorFn - Chalk color function for styling + * @example + * ```typescript + * UIService.printOutput(stdout, chalk.cyan); + * UIService.printOutput(stderr, chalk.red); + * ``` + */ + printOutput(output: string, colorFn: (text: string) => string): void { + const lines = output + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => ` ${line}`); + console.log(colorFn(lines.join('\n'))); + }, + + /** + * Displays environment information including tool versions and configuration. + * Shows compact-tools CLI version, compactc version, and optional settings. + * + * @param compactToolVersion - Version of the compact-tools CLI + * @param compactcVersion - Version of the compactc compiler + * @param targetDir - Optional target directory being compiled + * @param version - Optional specific version being used + * @example + * ```typescript + * UIService.displayEnvInfo('0.3.0', '0.26.0', 'security', '0.26.0'); + * ``` + */ + displayEnvInfo( + compactToolVersion: CompactToolVersion, + compactcVersion: CompactcVersion, + targetDir?: string, + version?: string, + ): void { + const spinner = ora(); + + if (targetDir) { + spinner.info(chalk.blue(`[COMPILE] TARGET_DIR: ${targetDir}`)); + } + + spinner.info(chalk.blue(`[COMPILE] compact-tools: ${compactToolVersion}`)); + spinner.info(chalk.blue(`[COMPILE] compactc: ${compactcVersion}`)); + + if (version) { + spinner.info(chalk.blue(`[COMPILE] Using compactc version: ${version}`)); + } + }, + + /** + * Displays compilation start message with file count and optional location. + * + * @param fileCount - Number of files to be compiled + * @param targetDir - Optional target directory being compiled + * @example + * ```typescript + * UIService.showCompilationStart(5, 'security'); + * // Output: "Found 5 .compact file(s) to compile in security/" + * ``` + */ + showCompilationStart(fileCount: number, targetDir?: string): void { + const searchLocation = targetDir ? ` in ${targetDir}/` : ''; + const spinner = ora(); + spinner.info( + chalk.blue( + `[COMPILE] Found ${fileCount} .compact file(s) to compile${searchLocation}`, + ), + ); + }, + + /** + * Displays a warning message when no .compact files are found. + * + * @param targetDir - Optional target directory that was searched + * @example + * ```typescript + * UIService.showNoFiles('security'); + * // Output: "No .compact files found in security/." + * ``` + */ + showNoFiles(targetDir?: string): void { + const searchLocation = targetDir ? `${targetDir}/` : ''; + const spinner = ora(); + spinner.warn( + chalk.yellow(`[COMPILE] No .compact files found in ${searchLocation}.`), + ); + }, +}; diff --git a/packages/cli/src/types/benchmark.ts b/packages/cli/src/types/benchmark.ts new file mode 100644 index 0000000..c7a610d --- /dev/null +++ b/packages/cli/src/types/benchmark.ts @@ -0,0 +1,139 @@ +// Import types for structure and version +import type { ArtifactStructure } from './manifest.ts'; +import type { CompactToolVersion } from '../config.ts'; + +/** + * Contract type distinguishing between module and top-level contracts. + * - 'module': A library/module contract with no circuits (no zkIR representation) + * - 'top-level': A deployable contract with circuits and constraints + */ +export type ContractType = 'module' | 'top-level'; + +/** + * Information about a compiled circuit. + * Captured from compiler output like: `circuit "gt" (k=12, rows=2639)` + * + * @interface CircuitInfo + */ +export interface CircuitInfo { + /** Circuit name (e.g., "gt", "gte", "transfer") */ + name: string; + /** Circuit k parameter (determines proving key size, typically 10-20) */ + k: number; + /** Number of rows/constraints in the circuit */ + rows: number; +} + +/** + * Contract benchmark entry with type and optional circuit information. + * Uses flat path keys (e.g., "math/test/Bytes32.mock") for easy diffing. + */ +export interface BenchmarkContract { + /** Contract type: 'module' or 'top-level' */ + type: ContractType; + /** Circuit information (only present for top-level contracts) */ + circuits?: CircuitInfo[]; +} + +/** + * Metadata about a compiled contract. + * Alias for BenchmarkContract for use in compiler service. + */ +export type ContractMetadata = BenchmarkContract; + +/** + * A node in the hierarchical benchmarks tree. + * Each node can contain contract metadata at its level and child directory nodes. + * + * @interface HierarchicalBenchmarkNode + */ +export interface HierarchicalBenchmarkNode { + /** Contract metadata at this directory level, keyed by contract name */ + contracts?: Record; + /** Child directories mapped by name to their benchmark nodes */ + [directory: string]: + | Record + | HierarchicalBenchmarkNode + | undefined; +} + +/** + * Hierarchical benchmarks organized as a nested tree structure. + * Mirrors the HierarchicalArtifacts structure but with contract metadata. + * + * @example + * ```typescript + * const benchmarks: HierarchicalBenchmarks = { + * math: { + * contracts: { "Bytes32": { type: "module" } }, + * test: { + * contracts: { "Bytes32.mock": { type: "top-level", circuits: [...] } } + * } + * } + * }; + * ``` + */ +export type HierarchicalBenchmarks = Record; + +/** + * Benchmark report containing circuit metadata for all compiled contracts. + * Designed to be committed to version control for tracking circuit complexity over time. + * + * @interface BenchmarkReport + * @example Flattened structure + * ```json + * { + * "structure": "flattened", + * "compactcVersion": "0.26.0", + * "compactToolVersion": "compact 0.3.0", + * "contracts": { + * "AccessControl": { "type": "module" }, + * "Bytes32.mock": { + * "type": "top-level", + * "circuits": [ + * { "name": "gt", "k": 12, "rows": 2639 } + * ] + * } + * } + * } + * ``` + * + * @example Hierarchical structure + * ```json + * { + * "structure": "hierarchical", + * "compactcVersion": "0.26.0", + * "compactToolVersion": "compact 0.3.0", + * "contracts": { + * "math": { + * "contracts": { "Bytes32": { "type": "module" } }, + * "test": { + * "contracts": { + * "Bytes32.mock": { + * "type": "top-level", + * "circuits": [{ "name": "gt", "k": 12, "rows": 2639 }] + * } + * } + * } + * } + * } + * } + * ``` + */ +export interface BenchmarkReport { + /** The artifact structure type used during compilation */ + structure: ArtifactStructure; + /** The compactc compiler version used for compilation */ + compactcVersion: string; + /** The compact-tools CLI version used for compilation */ + compactToolVersion?: CompactToolVersion; + /** + * Contract benchmarks. + * - For 'flattened' structure: flat record keyed by artifact name + * - For 'hierarchical' structure: nested tree structure matching artifacts + */ + contracts: Record | HierarchicalBenchmarks; +} + +/** Default filename for the benchmark report */ +export const BENCHMARK_FILENAME = 'benchmarks.json'; diff --git a/packages/cli/src/types/manifest.ts b/packages/cli/src/types/manifest.ts new file mode 100644 index 0000000..3368ca7 --- /dev/null +++ b/packages/cli/src/types/manifest.ts @@ -0,0 +1,156 @@ +// Import and re-export version types from config (single source of truth) +import type { CompactcVersion, CompactToolVersion } from '../config.ts'; + +/** + * A node in the hierarchical artifacts tree. + * Each node can contain artifacts at its level and child directory nodes. + * + * @interface HierarchicalArtifactNode + */ +export interface HierarchicalArtifactNode { + /** Artifacts at this directory level */ + artifacts?: string[]; + /** Child directories mapped by name to their artifact nodes */ + [directory: string]: string[] | HierarchicalArtifactNode | undefined; +} + +/** + * Hierarchical artifacts organized as a nested tree structure. + * Each top-level key is a root directory, containing nested subdirectories. + * + * @example + * ```typescript + * const artifacts: HierarchicalArtifacts = { + * math: { + * artifacts: ['Bytes32', 'Field254', 'Uint128'], + * interfaces: { + * artifacts: ['IUint128', 'IUint256', 'IUint64'] + * }, + * test: { + * artifacts: ['Bytes32.mock', 'Field254.mock', 'Uint128.mock'] + * } + * }, + * access: { + * artifacts: ['AccessControl'], + * test: { + * artifacts: ['AccessControl.mock'] + * } + * } + * }; + * ``` + */ +export type HierarchicalArtifacts = Record; + +/** + * Artifact structure type for output organization. + * - 'flattened': All artifacts in a flat directory structure + * - 'hierarchical': Artifacts organized by source directory structure + */ +export type ArtifactStructure = 'flattened' | 'hierarchical'; + +/** + * Supported Node.js major versions. + */ +export type NodeVersion = '18' | '20' | '21' | '22' | '23' | '24' | '25'; + +/** + * Supported platform identifiers. + * Format: - + */ +export type Platform = + | 'linux-x64' + | 'linux-arm64' + | 'darwin-x64' + | 'darwin-arm64' + | 'win32-x64' + | 'win32-arm64'; + +/** + * Known flags for the `compact compile` command (compactc). + * These are passed directly to the Compact compiler. + * + * Boolean flags: + * - `--skip-zk` - Skip generation of proving keys + * - `--vscode` - Format error messages for VS Code extension + * - `--no-communications-commitment` - Omit contract communications commitment + * - `--trace-passes` - Print tracing info (for compiler developers) + * + * Value flags: + * - `--sourceRoot ` - Override sourceRoot in source-map file + */ +export type CompilerFlag = + | '--skip-zk' + | '--vscode' + | '--no-communications-commitment' + | '--trace-passes' + | `--sourceRoot ${string}`; + +/** + * Represents the artifact manifest stored in the output directory. + * Used to track the structure type and metadata of compiled artifacts. + * + * @interface ArtifactManifest + */ +export interface ArtifactManifest { + /** The artifact structure type used during compilation */ + structure: ArtifactStructure; + /** The compactc compiler version used for compilation */ + compactcVersion?: CompactcVersion; + /** The compact-tools CLI version used for compilation */ + compactToolVersion?: CompactToolVersion; + /** + * ISO 8601 timestamp when artifacts were created. + * Format: YYYY-MM-DDTHH:mm:ss.sssZ + * @example "2025-12-11T10:09:46.023Z" + */ + createdAt: string; + /** Total compilation duration in milliseconds */ + buildDuration?: number; + /** Node.js major version used for compilation */ + nodeVersion?: NodeVersion; + /** Platform identifier (os-arch) */ + platform?: Platform; + /** Path to the source directory containing .compact files */ + sourcePath?: string; + /** Path to the output directory where artifacts are written */ + outputPath?: string; + /** + * Compiler flags used during compilation. + * @example "--skip-zk" + * @example ["--skip-zk", "--no-communications-commitment"] + */ + compilerFlags?: CompilerFlag | CompilerFlag[]; + /** + * Artifact names that were created. + * - For 'flattened' structure: flat array of artifact names + * - For 'hierarchical' structure: nested tree structure with directories containing artifacts and child directories + */ + artifacts: string[] | HierarchicalArtifacts; +} + +/** Filename for the artifact manifest */ +export const MANIFEST_FILENAME = 'manifest.json'; + +/** + * Custom error thrown when artifact structure mismatch is detected + * and user confirmation is required. + * + * @class StructureMismatchError + * @extends Error + */ +export class StructureMismatchError extends Error { + public readonly existingStructure: ArtifactStructure; + public readonly requestedStructure: ArtifactStructure; + + constructor( + existingStructure: ArtifactStructure, + requestedStructure: ArtifactStructure, + ) { + super( + `Artifact structure mismatch: existing="${existingStructure}", requested="${requestedStructure}"`, + ); + this.name = 'StructureMismatchError'; + this.existingStructure = existingStructure; + this.requestedStructure = requestedStructure; + } +} diff --git a/packages/cli/test/Compiler.test.ts b/packages/cli/test/Compiler.test.ts index f3cd4d9..7e05e12 100644 --- a/packages/cli/test/Compiler.test.ts +++ b/packages/cli/test/Compiler.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { readdir } from 'node:fs/promises'; +import { readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { beforeEach, describe, @@ -8,19 +8,28 @@ import { type MockedFunction, vi, } from 'vitest'; +import { CompactCompiler } from '../src/Compiler.js'; import { - CompactCompiler, CompilerService, + parseCircuitInfo, + parseContractMetadata, +} from '../src/services/CompilerService.js'; +import { EnvironmentValidator, type ExecFunction, - FileDiscovery, - UIService, -} from '../src/Compiler.js'; +} from '../src/services/EnvironmentValidator.js'; +import { FileDiscovery } from '../src/services/FileDiscovery.js'; +import { ManifestService } from '../src/services/ManifestService.js'; +import { UIService } from '../src/services/UIService.js'; import { CompactCliNotFoundError, CompilationError, DirectoryNotFoundError, } from '../src/types/errors.js'; +import { + MANIFEST_FILENAME, + StructureMismatchError, +} from '../src/types/manifest.js'; // Mock Node.js modules vi.mock('node:fs'); @@ -38,11 +47,17 @@ vi.mock('chalk', () => ({ // Mock spinner const mockSpinner = { - start: () => ({ succeed: vi.fn(), fail: vi.fn(), text: '' }), + start: () => ({ + succeed: vi.fn(), + fail: vi.fn(), + stopAndPersist: vi.fn(), + text: '', + }), info: vi.fn(), warn: vi.fn(), fail: vi.fn(), succeed: vi.fn(), + stopAndPersist: vi.fn(), }; vi.mock('ora', () => ({ @@ -51,6 +66,9 @@ vi.mock('ora', () => ({ const mockExistsSync = vi.mocked(existsSync); const mockReaddir = vi.mocked(readdir); +const mockReadFile = vi.mocked(readFile); +const mockWriteFile = vi.mocked(writeFile); +const mockRm = vi.mocked(rm); describe('EnvironmentValidator', () => { let mockExec: MockedFunction; @@ -82,47 +100,47 @@ describe('EnvironmentValidator', () => { }); }); - describe('getDevToolsVersion', () => { + describe('getCompactToolVersion', () => { it('should return trimmed version string', async () => { - mockExec.mockResolvedValue({ stdout: ' compact 0.1.0 \n', stderr: '' }); + mockExec.mockResolvedValue({ stdout: ' 0.3.0 \n', stderr: '' }); - const version = await validator.getDevToolsVersion(); + const version = await validator.getCompactToolVersion(); - expect(version).toBe('compact 0.1.0'); + expect(version).toBe('0.3.0'); expect(mockExec).toHaveBeenCalledWith('compact --version'); }); it('should throw error when command fails', async () => { mockExec.mockRejectedValue(new Error('Command failed')); - await expect(validator.getDevToolsVersion()).rejects.toThrow( + await expect(validator.getCompactToolVersion()).rejects.toThrow( 'Command failed', ); }); }); - describe('getToolchainVersion', () => { + describe('getCompactcVersion', () => { it('should get version without specific version flag', async () => { mockExec.mockResolvedValue({ - stdout: 'Compactc version: 0.26.0', + stdout: '0.26.0', stderr: '', }); - const version = await validator.getToolchainVersion(); + const version = await validator.getCompactcVersion(); - expect(version).toBe('Compactc version: 0.26.0'); + expect(version).toBe('0.26.0'); expect(mockExec).toHaveBeenCalledWith('compact compile --version'); }); it('should get version with specific version flag', async () => { mockExec.mockResolvedValue({ - stdout: 'Compactc version: 0.26.0', + stdout: '0.26.0', stderr: '', }); - const version = await validator.getToolchainVersion('0.26.0'); + const version = await validator.getCompactcVersion('0.26.0'); - expect(version).toBe('Compactc version: 0.26.0'); + expect(version).toBe('0.26.0'); expect(mockExec).toHaveBeenCalledWith( 'compact compile +0.26.0 --version', ); @@ -257,11 +275,19 @@ describe('CompilerService', () => { stderr: '', }); - const result = await service.compileFile('MyToken.compact', '--skip-zk'); + const result = await service.compileFile('MyToken.compact', [ + '--skip-zk', + ]); - expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(result.metadata).toEqual({ type: 'module' }); + // Check core command parts (quotes may be escaped differently on Linux due to script wrapper) + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile --skip-zk'), + ); expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "src/MyToken.compact" "artifacts/MyToken"', + expect.stringContaining('src/MyToken.compact'), ); }); @@ -273,13 +299,18 @@ describe('CompilerService', () => { const result = await service.compileFile( 'MyToken.compact', - '--skip-zk', + ['--skip-zk'], '0.26.0', ); - expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(result.metadata.type).toBe('module'); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile +0.26.0 --skip-zk'), + ); expect(mockExec).toHaveBeenCalledWith( - 'compact compile +0.26.0 --skip-zk "src/MyToken.compact" "artifacts/MyToken"', + expect.stringContaining('src/MyToken.compact'), ); }); @@ -289,19 +320,93 @@ describe('CompilerService', () => { stderr: '', }); - const result = await service.compileFile('MyToken.compact', ''); + const result = await service.compileFile('MyToken.compact', []); + + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(result.metadata.type).toBe('module'); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('src/MyToken.compact'), + ); + }); + + it('should use flattened artifacts output by default', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile('access/AccessControl.compact', [ + '--skip-zk', + ]); - expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); expect(mockExec).toHaveBeenCalledWith( - 'compact compile "src/MyToken.compact" "artifacts/MyToken"', + expect.stringContaining('compact compile --skip-zk'), ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('src/access/AccessControl.compact'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('artifacts/AccessControl'), + ); + }); + + it('should flatten nested directory structure by default', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile( + 'access/test/AccessControl.mock.compact', + ['--skip-zk'], + ); + + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile --skip-zk'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('src/access/test/AccessControl.mock.compact'), + ); + }); + + it('should parse circuit info and return top-level metadata', async () => { + const circuitOutput = `Compiling 4 circuits: + circuit "gt" (k=12, rows=2639) + circuit "gte" (k=12, rows=2643) + circuit "lt" (k=12, rows=2639) + circuit "lte" (k=12, rows=2643) +Overall progress [====================] 4/4`; + + mockExec.mockResolvedValue({ + stdout: 'compactc 0.26.0', + stderr: circuitOutput, + }); + + const result = await service.compileFile('Bytes32.mock.compact', []); + + expect(result.metadata.type).toBe('top-level'); + expect(result.metadata.circuits).toHaveLength(4); + expect(result.metadata.circuits).toEqual([ + { name: 'gt', k: 12, rows: 2639 }, + { name: 'gte', k: 12, rows: 2643 }, + { name: 'lt', k: 12, rows: 2639 }, + { name: 'lte', k: 12, rows: 2643 }, + ]); }); it('should throw CompilationError when compilation fails', async () => { mockExec.mockRejectedValue(new Error('Syntax error on line 10')); await expect( - service.compileFile('MyToken.compact', '--skip-zk'), + service.compileFile('MyToken.compact', ['--skip-zk']), ).rejects.toThrow(CompilationError); }); @@ -309,7 +414,7 @@ describe('CompilerService', () => { mockExec.mockRejectedValue(new Error('Syntax error')); try { - await service.compileFile('MyToken.compact', '--skip-zk'); + await service.compileFile('MyToken.compact', ['--skip-zk']); } catch (error) { expect(error).toBeInstanceOf(CompilationError); expect((error as CompilationError).file).toBe('MyToken.compact'); @@ -321,13 +426,255 @@ describe('CompilerService', () => { mockExec.mockRejectedValue(mockError); try { - await service.compileFile('MyToken.compact', '--skip-zk'); + await service.compileFile('MyToken.compact', ['--skip-zk']); } catch (error) { expect(error).toBeInstanceOf(CompilationError); expect((error as CompilationError).cause).toEqual(mockError); } }); }); + + describe('compileFile with hierarchical option', () => { + beforeEach(() => { + service = new CompilerService(mockExec, { hierarchical: true }); + }); + + it('should preserve directory structure in artifacts output when hierarchical is true', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile('access/AccessControl.compact', [ + '--skip-zk', + ]); + + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(result.metadata.type).toBe('module'); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile --skip-zk'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('src/access/AccessControl.compact'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('artifacts/access/AccessControl'), + ); + }); + + it('should preserve nested directory structure when hierarchical is true', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile( + 'access/test/AccessControl.mock.compact', + ['--skip-zk'], + ); + + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(result.metadata.type).toBe('module'); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile --skip-zk'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('src/access/test/AccessControl.mock.compact'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('artifacts/access/test/AccessControl.mock'), + ); + }); + + it('should use flattened output for root-level files even when hierarchical is true', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile('MyToken.compact', [ + '--skip-zk', + ]); + + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(result.metadata.type).toBe('module'); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile --skip-zk'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('src/MyToken.compact'), + ); + }); + }); + + describe('compileFile with custom srcDir and outDir', () => { + beforeEach(() => { + service = new CompilerService(mockExec, { + srcDir: 'contracts', + outDir: 'build', + }); + }); + + it('should use custom srcDir and outDir', async () => { + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile('MyToken.compact', [ + '--skip-zk', + ]); + + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(result.metadata.type).toBe('module'); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile --skip-zk'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('contracts/MyToken.compact'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('build/MyToken'), + ); + }); + + it('should use custom directories with hierarchical option', async () => { + service = new CompilerService(mockExec, { + srcDir: 'contracts', + outDir: 'dist/artifacts', + hierarchical: true, + }); + mockExec.mockResolvedValue({ + stdout: 'Compilation successful', + stderr: '', + }); + + const result = await service.compileFile('access/AccessControl.compact', [ + '--skip-zk', + ]); + + expect(result.stdout).toBe('Compilation successful'); + expect(result.stderr).toBe(''); + expect(result.metadata.type).toBe('module'); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('compact compile --skip-zk'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('contracts/access/AccessControl.compact'), + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('dist/artifacts/access/AccessControl'), + ); + }); + }); +}); + +describe('parseCircuitInfo', () => { + it('should parse single circuit info', () => { + const output = 'circuit "transfer" (k=14, rows=8192)'; + + const circuits = parseCircuitInfo(output); + + expect(circuits).toEqual([{ name: 'transfer', k: 14, rows: 8192 }]); + }); + + it('should parse multiple circuits', () => { + const output = `Compiling 4 circuits: + circuit "gt" (k=12, rows=2639) + circuit "gte" (k=12, rows=2643) + circuit "lt" (k=12, rows=2639) + circuit "lte" (k=12, rows=2643) +Overall progress [====================] 4/4`; + + const circuits = parseCircuitInfo(output); + + expect(circuits).toHaveLength(4); + expect(circuits[0]).toEqual({ name: 'gt', k: 12, rows: 2639 }); + expect(circuits[1]).toEqual({ name: 'gte', k: 12, rows: 2643 }); + expect(circuits[2]).toEqual({ name: 'lt', k: 12, rows: 2639 }); + expect(circuits[3]).toEqual({ name: 'lte', k: 12, rows: 2643 }); + }); + + it('should return empty array for module contracts (no circuits)', () => { + const output = 'compactc 0.26.0\nCompilation successful'; + + const circuits = parseCircuitInfo(output); + + expect(circuits).toEqual([]); + }); + + it('should handle circuits with different k and rows values', () => { + const output = `circuit "small" (k=10, rows=512) +circuit "large" (k=20, rows=1048576)`; + + const circuits = parseCircuitInfo(output); + + expect(circuits).toEqual([ + { name: 'small', k: 10, rows: 512 }, + { name: 'large', k: 20, rows: 1048576 }, + ]); + }); + + it('should handle circuit names with special characters', () => { + const output = 'circuit "verify_signature_v2" (k=15, rows=4096)'; + + const circuits = parseCircuitInfo(output); + + expect(circuits).toEqual([ + { name: 'verify_signature_v2', k: 15, rows: 4096 }, + ]); + }); + + it('should be reentrant (handle multiple calls)', () => { + const output1 = 'circuit "first" (k=10, rows=100)'; + const output2 = 'circuit "second" (k=11, rows=200)'; + + const circuits1 = parseCircuitInfo(output1); + const circuits2 = parseCircuitInfo(output2); + + expect(circuits1).toEqual([{ name: 'first', k: 10, rows: 100 }]); + expect(circuits2).toEqual([{ name: 'second', k: 11, rows: 200 }]); + }); +}); + +describe('parseContractMetadata', () => { + it('should return module type for contracts without circuits', () => { + const output = 'compactc 0.26.0\nCompilation successful'; + + const metadata = parseContractMetadata(output); + + expect(metadata).toEqual({ type: 'module' }); + expect(metadata.circuits).toBeUndefined(); + }); + + it('should return top-level type with circuits for contracts with circuits', () => { + const output = `Compiling 2 circuits: + circuit "mint" (k=14, rows=5000) + circuit "burn" (k=14, rows=4800) +Overall progress [====================] 2/2`; + + const metadata = parseContractMetadata(output); + + expect(metadata.type).toBe('top-level'); + expect(metadata.circuits).toHaveLength(2); + expect(metadata.circuits).toEqual([ + { name: 'mint', k: 14, rows: 5000 }, + { name: 'burn', k: 14, rows: 4800 }, + ]); + }); + + it('should handle single circuit', () => { + const output = 'circuit "init" (k=12, rows=1024)'; + + const metadata = parseContractMetadata(output); + + expect(metadata.type).toBe('top-level'); + expect(metadata.circuits).toEqual([{ name: 'init', k: 12, rows: 1024 }]); + }); }); describe('UIService', () => { @@ -362,41 +709,36 @@ describe('UIService', () => { describe('displayEnvInfo', () => { it('should display environment information with all parameters', () => { - UIService.displayEnvInfo( - 'compact 0.1.0', - 'Compactc 0.26.0', - 'security', - '0.26.0', - ); + UIService.displayEnvInfo('0.3.0', '0.26.0', 'security', '0.26.0'); expect(mockSpinner.info).toHaveBeenCalledWith( '[COMPILE] TARGET_DIR: security', ); expect(mockSpinner.info).toHaveBeenCalledWith( - '[COMPILE] Compact developer tools: compact 0.1.0', + '[COMPILE] compact-tools: 0.3.0', ); expect(mockSpinner.info).toHaveBeenCalledWith( - '[COMPILE] Compact toolchain: Compactc 0.26.0', + '[COMPILE] compactc: 0.26.0', ); expect(mockSpinner.info).toHaveBeenCalledWith( - '[COMPILE] Using toolchain version: 0.26.0', + '[COMPILE] Using compactc version: 0.26.0', ); }); it('should display environment information without optional parameters', () => { - UIService.displayEnvInfo('compact 0.1.0', 'Compactc 0.26.0'); + UIService.displayEnvInfo('0.3.0', '0.26.0'); expect(mockSpinner.info).toHaveBeenCalledWith( - '[COMPILE] Compact developer tools: compact 0.1.0', + '[COMPILE] compact-tools: 0.3.0', ); expect(mockSpinner.info).toHaveBeenCalledWith( - '[COMPILE] Compact toolchain: Compactc 0.26.0', + '[COMPILE] compactc: 0.26.0', ); expect(mockSpinner.info).not.toHaveBeenCalledWith( expect.stringContaining('TARGET_DIR'), ); expect(mockSpinner.info).not.toHaveBeenCalledWith( - expect.stringContaining('Using toolchain version'), + expect.stringContaining('Using compactc version'), ); }); }); @@ -454,22 +796,44 @@ describe('CompactCompiler', () => { compiler = new CompactCompiler(); expect(compiler).toBeInstanceOf(CompactCompiler); + expect(compiler.testOptions.flags).toEqual([]); + expect(compiler.testOptions.targetDir).toBeUndefined(); + expect(compiler.testOptions.version).toBeUndefined(); + expect(compiler.testOptions.hierarchical).toBe(false); + expect(compiler.testOptions.srcDir).toBe('src'); + expect(compiler.testOptions.outDir).toBe('artifacts'); }); it('should create instance with all parameters', () => { compiler = new CompactCompiler( - '--skip-zk', - 'security', - '0.26.0', + { + flags: ['--skip-zk'], + targetDir: 'security', + version: '0.26.0', + hierarchical: true, + srcDir: 'contracts', + outDir: 'build', + }, mockExec, ); expect(compiler).toBeInstanceOf(CompactCompiler); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); + expect(compiler.testOptions.targetDir).toBe('security'); + expect(compiler.testOptions.version).toBe('0.26.0'); + expect(compiler.testOptions.hierarchical).toBe(true); + expect(compiler.testOptions.srcDir).toBe('contracts'); + expect(compiler.testOptions.outDir).toBe('build'); }); - it('should trim flags', () => { - compiler = new CompactCompiler(' --skip-zk --verbose '); - expect(compiler.testFlags).toBe('--skip-zk --verbose'); + it('should handle flags array', () => { + compiler = new CompactCompiler({ + flags: ['--skip-zk', '--trace-passes'], + }); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); }); }); @@ -477,28 +841,29 @@ describe('CompactCompiler', () => { it('should parse empty arguments', () => { compiler = CompactCompiler.fromArgs([]); - expect(compiler.testFlags).toBe(''); - expect(compiler.testTargetDir).toBeUndefined(); - expect(compiler.testVersion).toBeUndefined(); + expect(compiler.testOptions.flags).toEqual([]); + expect(compiler.testOptions.targetDir).toBeUndefined(); + expect(compiler.testOptions.version).toBeUndefined(); + expect(compiler.testOptions.hierarchical).toBe(false); }); it('should handle SKIP_ZK environment variable', () => { compiler = CompactCompiler.fromArgs([], { SKIP_ZK: 'true' }); - expect(compiler.testFlags).toBe('--skip-zk'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); }); it('should ignore SKIP_ZK when not "true"', () => { compiler = CompactCompiler.fromArgs([], { SKIP_ZK: 'false' }); - expect(compiler.testFlags).toBe(''); + expect(compiler.testOptions.flags).toEqual([]); }); it('should parse --dir flag', () => { compiler = CompactCompiler.fromArgs(['--dir', 'security']); - expect(compiler.testTargetDir).toBe('security'); - expect(compiler.testFlags).toBe(''); + expect(compiler.testOptions.targetDir).toBe('security'); + expect(compiler.testOptions.flags).toEqual([]); }); it('should parse --dir flag with additional flags', () => { @@ -506,18 +871,21 @@ describe('CompactCompiler', () => { '--dir', 'security', '--skip-zk', - '--verbose', + '--trace-passes', ]); - expect(compiler.testTargetDir).toBe('security'); - expect(compiler.testFlags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.targetDir).toBe('security'); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); }); it('should parse version flag', () => { compiler = CompactCompiler.fromArgs(['+0.26.0']); - expect(compiler.testVersion).toBe('0.26.0'); - expect(compiler.testFlags).toBe(''); + expect(compiler.testOptions.version).toBe('0.26.0'); + expect(compiler.testOptions.flags).toEqual([]); }); it('should parse complex arguments', () => { @@ -525,30 +893,42 @@ describe('CompactCompiler', () => { '--dir', 'security', '--skip-zk', - '--verbose', + '--trace-passes', '+0.26.0', ]); - expect(compiler.testTargetDir).toBe('security'); - expect(compiler.testFlags).toBe('--skip-zk --verbose'); - expect(compiler.testVersion).toBe('0.26.0'); + expect(compiler.testOptions.targetDir).toBe('security'); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); + expect(compiler.testOptions.version).toBe('0.26.0'); }); it('should combine environment variables with CLI flags', () => { - compiler = CompactCompiler.fromArgs(['--dir', 'access', '--verbose'], { - SKIP_ZK: 'true', - }); + compiler = CompactCompiler.fromArgs( + ['--dir', 'access', '--trace-passes'], + { + SKIP_ZK: 'true', + }, + ); - expect(compiler.testTargetDir).toBe('access'); - expect(compiler.testFlags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.targetDir).toBe('access'); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); }); it('should deduplicate flags when both env var and CLI flag are present', () => { - compiler = CompactCompiler.fromArgs(['--skip-zk', '--verbose'], { + compiler = CompactCompiler.fromArgs(['--skip-zk', '--trace-passes'], { SKIP_ZK: 'true', }); - expect(compiler.testFlags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); }); it('should throw error for --dir without argument', () => { @@ -562,6 +942,91 @@ describe('CompactCompiler', () => { '--dir flag requires a directory name', ); }); + + it('should parse --hierarchical flag', () => { + compiler = CompactCompiler.fromArgs(['--hierarchical']); + + expect(compiler.testOptions.hierarchical).toBe(true); + expect(compiler.testOptions.flags).toEqual([]); + }); + + it('should parse --hierarchical flag with other options', () => { + compiler = CompactCompiler.fromArgs([ + '--hierarchical', + '--dir', + 'security', + '--skip-zk', + '+0.26.0', + ]); + + expect(compiler.testOptions.hierarchical).toBe(true); + expect(compiler.testOptions.targetDir).toBe('security'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); + expect(compiler.testOptions.version).toBe('0.26.0'); + }); + + it('should default to flattened output (hierarchical = false)', () => { + compiler = CompactCompiler.fromArgs(['--skip-zk']); + + expect(compiler.testOptions.hierarchical).toBe(false); + }); + + it('should parse --src flag', () => { + compiler = CompactCompiler.fromArgs(['--src', 'contracts']); + + expect(compiler.testOptions.srcDir).toBe('contracts'); + }); + + it('should parse --out flag', () => { + compiler = CompactCompiler.fromArgs(['--out', 'build']); + + expect(compiler.testOptions.outDir).toBe('build'); + }); + + it('should parse --src and --out flags together', () => { + compiler = CompactCompiler.fromArgs([ + '--src', + 'contracts', + '--out', + 'dist/artifacts', + '--skip-zk', + ]); + + expect(compiler.testOptions.srcDir).toBe('contracts'); + expect(compiler.testOptions.outDir).toBe('dist/artifacts'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); + }); + + it('should use default srcDir and outDir when not specified', () => { + compiler = CompactCompiler.fromArgs([]); + + expect(compiler.testOptions.srcDir).toBe('src'); + expect(compiler.testOptions.outDir).toBe('artifacts'); + }); + + it('should throw error for --src without argument', () => { + expect(() => CompactCompiler.fromArgs(['--src'])).toThrow( + '--src flag requires a directory path', + ); + }); + + it('should throw error for --src followed by another flag', () => { + expect(() => CompactCompiler.fromArgs(['--src', '--skip-zk'])).toThrow( + '--src flag requires a directory path', + ); + }); + + it('should throw error for --out without argument', () => { + expect(() => CompactCompiler.fromArgs(['--out'])).toThrow( + '--out flag requires a directory path', + ); + }); + + it('should throw error for --out followed by another flag', () => { + expect(() => CompactCompiler.fromArgs(['--out', '--skip-zk'])).toThrow( + '--out flag requires a directory path', + ); + }); }); describe('validateEnvironment', () => { @@ -575,9 +1040,11 @@ describe('CompactCompiler', () => { }); // getToolchainVersion compiler = new CompactCompiler( - '--skip-zk', - 'security', - '0.26.0', + { + flags: ['--skip-zk'], + targetDir: 'security', + version: '0.26.0', + }, mockExec, ); const displaySpy = vi @@ -608,7 +1075,7 @@ describe('CompactCompiler', () => { it('should handle CompactCliNotFoundError with installation instructions', async () => { mockExec.mockRejectedValue(new Error('Command not found')); - compiler = new CompactCompiler('', undefined, undefined, mockExec); + compiler = new CompactCompiler({}, mockExec); await expect(compiler.validateEnvironment()).rejects.toThrow( CompactCliNotFoundError, @@ -620,7 +1087,7 @@ describe('CompactCompiler', () => { .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) // validate() succeeds .mockRejectedValueOnce(new Error('Version command failed')); // getDevToolsVersion() fails - compiler = new CompactCompiler('', undefined, undefined, mockExec); + compiler = new CompactCompiler({}, mockExec); await expect(compiler.validateEnvironment()).rejects.toThrow( 'Version command failed', @@ -633,7 +1100,7 @@ describe('CompactCompiler', () => { childProcessError.stderr = 'some error'; mockExec.mockRejectedValue(childProcessError); - compiler = new CompactCompiler('', undefined, undefined, mockExec); + compiler = new CompactCompiler({}, mockExec); await expect(compiler.validateEnvironment()).rejects.toThrow( "'compact' CLI not found in PATH. Please install the Compact developer tools.", @@ -642,7 +1109,7 @@ describe('CompactCompiler', () => { it('should handle non-Error exceptions gracefully', async () => { mockExec.mockRejectedValue('String error message'); - compiler = new CompactCompiler('', undefined, undefined, mockExec); + compiler = new CompactCompiler({}, mockExec); await expect(compiler.validateEnvironment()).rejects.toThrow( CompactCliNotFoundError, @@ -658,7 +1125,7 @@ describe('CompactCompiler', () => { stderr: '', }); - compiler = new CompactCompiler('', undefined, '0.26.0', mockExec); + compiler = new CompactCompiler({ version: '0.26.0' }, mockExec); const displaySpy = vi .spyOn(UIService, 'displayEnvInfo') .mockImplementation(() => {}); @@ -689,7 +1156,7 @@ describe('CompactCompiler', () => { stderr: '', }); - compiler = new CompactCompiler('', undefined, undefined, mockExec); + compiler = new CompactCompiler({}, mockExec); const displaySpy = vi .spyOn(UIService, 'displayEnvInfo') .mockImplementation(() => {}); @@ -712,14 +1179,14 @@ describe('CompactCompiler', () => { describe('compile', () => { it('should handle empty source directory', async () => { mockReaddir.mockResolvedValue([]); - compiler = new CompactCompiler('', undefined, undefined, mockExec); + compiler = new CompactCompiler({}, mockExec); await expect(compiler.compile()).resolves.not.toThrow(); }); it('should throw error if target directory does not exist', async () => { mockExistsSync.mockReturnValue(false); - compiler = new CompactCompiler('', 'nonexistent', undefined, mockExec); + compiler = new CompactCompiler({ targetDir: 'nonexistent' }, mockExec); await expect(compiler.compile()).rejects.toThrow(DirectoryNotFoundError); }); @@ -738,12 +1205,7 @@ describe('CompactCompiler', () => { }, ]; mockReaddir.mockResolvedValue(mockDirents as any); - compiler = new CompactCompiler( - '--skip-zk', - undefined, - undefined, - mockExec, - ); + compiler = new CompactCompiler({ flags: ['--skip-zk'] }, mockExec); await compiler.compile(); @@ -770,7 +1232,7 @@ describe('CompactCompiler', () => { .mockResolvedValueOnce({ stdout: 'Compactc 0.26.0', stderr: '' }) // getToolchainVersion .mockRejectedValueOnce(new Error('Compilation failed')); // compileFile execution - compiler = new CompactCompiler('', undefined, undefined, testMockExec); + compiler = new CompactCompiler({}, testMockExec); // Test that compilation errors are properly propagated let thrownError: unknown; @@ -804,34 +1266,34 @@ describe('CompactCompiler', () => { it('should handle turbo compact command', () => { compiler = CompactCompiler.fromArgs([]); - expect(compiler.testFlags).toBe(''); - expect(compiler.testTargetDir).toBeUndefined(); + expect(compiler.testOptions.flags).toEqual([]); + expect(compiler.testOptions.targetDir).toBeUndefined(); }); it('should handle SKIP_ZK=true turbo compact command', () => { compiler = CompactCompiler.fromArgs([], { SKIP_ZK: 'true' }); - expect(compiler.testFlags).toBe('--skip-zk'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); }); it('should handle turbo compact:access command', () => { compiler = CompactCompiler.fromArgs(['--dir', 'access']); - expect(compiler.testFlags).toBe(''); - expect(compiler.testTargetDir).toBe('access'); + expect(compiler.testOptions.flags).toEqual([]); + expect(compiler.testOptions.targetDir).toBe('access'); }); it('should handle turbo compact:security -- --skip-zk command', () => { compiler = CompactCompiler.fromArgs(['--dir', 'security', '--skip-zk']); - expect(compiler.testFlags).toBe('--skip-zk'); - expect(compiler.testTargetDir).toBe('security'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); + expect(compiler.testOptions.targetDir).toBe('security'); }); it('should handle version specification', () => { compiler = CompactCompiler.fromArgs(['+0.26.0']); - expect(compiler.testVersion).toBe('0.26.0'); + expect(compiler.testOptions.version).toBe('0.26.0'); }); it.each([ @@ -870,11 +1332,434 @@ describe('CompactCompiler', () => { ])('should handle complex command $name', ({ args, env }) => { compiler = CompactCompiler.fromArgs(args, env); - expect(compiler.testFlags).toBe( - '--skip-zk --no-communications-commitment', + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--no-communications-commitment', + ]); + expect(compiler.testOptions.targetDir).toBe('security'); + expect(compiler.testOptions.version).toBe('0.26.0'); + }); + + it('should parse --force flag', () => { + compiler = CompactCompiler.fromArgs(['--force']); + + expect(compiler.testOptions.force).toBe(true); + }); + + it('should parse -f flag (short form)', () => { + compiler = CompactCompiler.fromArgs(['-f']); + + expect(compiler.testOptions.force).toBe(true); + }); + + it('should parse --force with other flags', () => { + compiler = CompactCompiler.fromArgs([ + '--hierarchical', + '--force', + '--skip-zk', + ]); + + expect(compiler.testOptions.force).toBe(true); + expect(compiler.testOptions.hierarchical).toBe(true); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); + }); + + it('should default force to false', () => { + compiler = CompactCompiler.fromArgs([]); + + expect(compiler.testOptions.force).toBe(false); + }); + }); +}); + +describe('ManifestService', () => { + let manifestService: ManifestService; + + beforeEach(() => { + vi.clearAllMocks(); + manifestService = new ManifestService('artifacts'); + }); + + describe('manifestPath', () => { + it('should return correct manifest path', () => { + expect(manifestService.manifestPath).toBe( + `artifacts/${MANIFEST_FILENAME}`, ); - expect(compiler.testTargetDir).toBe('security'); - expect(compiler.testVersion).toBe('0.26.0'); }); + + it('should use custom outDir', () => { + const customService = new ManifestService('build/output'); + expect(customService.manifestPath).toBe( + `build/output/${MANIFEST_FILENAME}`, + ); + }); + }); + + describe('read', () => { + it('should return null when manifest does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await manifestService.read(); + + expect(result).toBeNull(); + }); + + it('should return manifest when it exists', async () => { + const manifest = { + structure: 'flattened', + toolchainVersion: '0.26.0', + createdAt: '2025-12-11T12:00:00Z', + artifacts: ['Token', 'AccessControl'], + }; + mockExistsSync.mockReturnValue(true); + mockReadFile.mockResolvedValue(JSON.stringify(manifest)); + + const result = await manifestService.read(); + + expect(result).toEqual(manifest); + }); + + it('should return null on parse error', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFile.mockResolvedValue('invalid json'); + + const result = await manifestService.read(); + + expect(result).toBeNull(); + }); + + it('should return null on read error', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFile.mockRejectedValue(new Error('Read error')); + + const result = await manifestService.read(); + + expect(result).toBeNull(); + }); + }); + + describe('write', () => { + it('should write manifest to file', async () => { + const manifest = { + structure: 'hierarchical' as const, + toolchainVersion: '0.26.0', + createdAt: '2025-12-11T12:00:00Z', + artifacts: ['Token'], + }; + mockWriteFile.mockResolvedValue(undefined); + + await manifestService.write(manifest); + + expect(mockWriteFile).toHaveBeenCalledWith( + `artifacts/${MANIFEST_FILENAME}`, + JSON.stringify(manifest, null, 2), + ); + }); + }); + + describe('checkMismatch', () => { + it('should return null when no manifest exists', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await manifestService.checkMismatch('flattened'); + + expect(result).toBeNull(); + }); + + it('should return null when structure matches', async () => { + const manifest = { + structure: 'flattened', + createdAt: '2025-12-11T12:00:00Z', + artifacts: ['Token'], + }; + mockExistsSync.mockReturnValue(true); + mockReadFile.mockResolvedValue(JSON.stringify(manifest)); + + const result = await manifestService.checkMismatch('flattened'); + + expect(result).toBeNull(); + }); + + it('should return manifest when structure mismatches', async () => { + const manifest = { + structure: 'flattened', + createdAt: '2025-12-11T12:00:00Z', + artifacts: ['Token'], + }; + mockExistsSync.mockReturnValue(true); + mockReadFile.mockResolvedValue(JSON.stringify(manifest)); + + const result = await manifestService.checkMismatch('hierarchical'); + + expect(result).toEqual(manifest); + }); + }); + + describe('cleanOutputDirectory', () => { + it('should remove output directory when it exists', async () => { + mockExistsSync.mockReturnValue(true); + mockRm.mockResolvedValue(undefined); + + await manifestService.cleanOutputDirectory(); + + expect(mockRm).toHaveBeenCalledWith('artifacts', { + recursive: true, + force: true, + }); + }); + + it('should not throw when directory does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + await expect( + manifestService.cleanOutputDirectory(), + ).resolves.not.toThrow(); + expect(mockRm).not.toHaveBeenCalled(); + }); + }); +}); + +describe('StructureMismatchError', () => { + it('should create error with correct properties', () => { + const error = new StructureMismatchError('flattened', 'hierarchical'); + + expect(error.name).toBe('StructureMismatchError'); + expect(error.existingStructure).toBe('flattened'); + expect(error.requestedStructure).toBe('hierarchical'); + expect(error.message).toContain('flattened'); + expect(error.message).toContain('hierarchical'); + }); +}); + +describe('Hierarchical artifact tree structure', () => { + let mockExec: MockedFunction; + let compiler: CompactCompiler; + let writtenManifest: unknown; + + beforeEach(() => { + vi.clearAllMocks(); + writtenManifest = undefined; + mockExec = vi.fn().mockResolvedValue({ stdout: 'success', stderr: '' }); + // existsSync returns true for src dir but false for manifest (no existing manifest) + mockExistsSync.mockImplementation((path: string) => { + if (path.includes('manifest.json')) return false; + return true; + }); + mockWriteFile.mockImplementation(async (_path, content) => { + writtenManifest = JSON.parse(content as string); + }); + }); + + it('should create nested tree structure for hierarchical artifacts', async () => { + // Mock: src/ has 'math' dir, math/ has Uint128.compact and 'test' dir, test/ has Uint128.mock.compact + const mockSrcDir = [ + { name: 'math', isFile: () => false, isDirectory: () => true }, + ]; + const mockMathDir = [ + { name: 'Uint128.compact', isFile: () => true, isDirectory: () => false }, + { name: 'test', isFile: () => false, isDirectory: () => true }, + ]; + const mockTestDir = [ + { + name: 'Uint128.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + + mockReaddir + .mockResolvedValueOnce(mockSrcDir as any) + .mockResolvedValueOnce(mockMathDir as any) + .mockResolvedValueOnce(mockTestDir as any); + + compiler = new CompactCompiler({ hierarchical: true }, mockExec); + await compiler.compile(); + + const manifest = writtenManifest as { + structure: string; + artifacts: Record; + }; + expect(manifest.structure).toBe('hierarchical'); + expect(manifest.artifacts).toHaveProperty('math'); + const math = manifest.artifacts.math as { + artifacts: string[]; + test: { artifacts: string[] }; + }; + expect(math).toHaveProperty('artifacts'); + expect(math.artifacts).toContain('Uint128'); + expect(math).toHaveProperty('test'); + expect(math.test.artifacts).toContain('Uint128.mock'); + // Contracts metadata is now in benchmarks.json, not in manifest + expect(manifest).not.toHaveProperty('contracts'); + }); + + it('should add root level artifacts to root node', async () => { + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + mockReaddir.mockResolvedValue(mockDirents as any); + + compiler = new CompactCompiler({ hierarchical: true }, mockExec); + await compiler.compile(); + + const manifest = writtenManifest as { + artifacts: { root: { artifacts: string[] } }; + }; + expect(manifest.artifacts).toHaveProperty('root'); + expect(manifest.artifacts.root.artifacts).toContain('MyToken'); + }); + + it('should create flat array for flattened structure', async () => { + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'Ownable.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + mockReaddir.mockResolvedValue(mockDirents as any); + + compiler = new CompactCompiler({ hierarchical: false }, mockExec); + await compiler.compile(); + + const manifest = writtenManifest as { + structure: string; + artifacts: string[]; + }; + expect(manifest.structure).toBe('flattened'); + expect(Array.isArray(manifest.artifacts)).toBe(true); + expect(manifest.artifacts).toContain('MyToken'); + expect(manifest.artifacts).toContain('Ownable'); + }); + + it('should handle deeply nested directories', async () => { + // Simulate: src/ -> access/ -> roles/ -> admin/ -> Admin.compact + const mockSrcDir = [ + { name: 'access', isFile: () => false, isDirectory: () => true }, + ]; + const mockAccessDir = [ + { name: 'roles', isFile: () => false, isDirectory: () => true }, + ]; + const mockRolesDir = [ + { name: 'admin', isFile: () => false, isDirectory: () => true }, + ]; + const mockAdminDir = [ + { name: 'Admin.compact', isFile: () => true, isDirectory: () => false }, + ]; + + mockReaddir + .mockResolvedValueOnce(mockSrcDir as any) + .mockResolvedValueOnce(mockAccessDir as any) + .mockResolvedValueOnce(mockRolesDir as any) + .mockResolvedValueOnce(mockAdminDir as any); + + compiler = new CompactCompiler({ hierarchical: true }, mockExec); + await compiler.compile(); + + const manifest = writtenManifest as { + artifacts: { + access: { roles: { admin: { artifacts: string[] } } }; + }; + }; + expect(manifest.artifacts.access.roles.admin.artifacts).toContain('Admin'); + }); + + it('should write benchmarks file with contract metadata when benchmarksPath is set', async () => { + const circuitOutput = `Compiling 2 circuits: + circuit "mint" (k=14, rows=5000) + circuit "burn" (k=14, rows=4800) +Overall progress [====================] 2/2`; + + // Return different outputs for module vs top-level contracts + let compileCallCount = 0; + mockExec.mockImplementation(async (cmd: string) => { + if (cmd.includes('--version')) { + return { stdout: 'compact 0.3.0', stderr: '' }; + } + compileCallCount++; + // First compile is module (Uint128), second is top-level (Uint128.mock) + if (compileCallCount === 1) { + return { stdout: 'compactc 0.26.0', stderr: '' }; + } + return { stdout: 'compactc 0.26.0', stderr: circuitOutput }; + }); + + const mockSrcDir = [ + { name: 'math', isFile: () => false, isDirectory: () => true }, + ]; + const mockMathDir = [ + { name: 'Uint128.compact', isFile: () => true, isDirectory: () => false }, + { name: 'test', isFile: () => false, isDirectory: () => true }, + ]; + const mockTestDir = [ + { + name: 'Uint128.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + + mockReaddir + .mockResolvedValueOnce(mockSrcDir as any) + .mockResolvedValueOnce(mockMathDir as any) + .mockResolvedValueOnce(mockTestDir as any); + + // Track what's written to the benchmarks file + let writtenBenchmarks: string | null = null; + vi.mocked(writeFile).mockImplementation(async (path, content) => { + const pathStr = String(path); + if (pathStr.includes('benchmarks.json')) { + writtenBenchmarks = String(content); + } else if (pathStr.includes('manifest.json')) { + writtenManifest = JSON.parse(String(content)); + } + }); + + compiler = new CompactCompiler( + { hierarchical: true, benchmarksPath: './benchmarks.json' }, + mockExec, + ); + await compiler.compile(); + + // Verify benchmarks file was written + expect(writtenBenchmarks).not.toBeNull(); + const benchmarks = JSON.parse(writtenBenchmarks as string); + + // Verify structure and compactToolVersion are present + expect(benchmarks).toHaveProperty('structure', 'hierarchical'); + expect(benchmarks).toHaveProperty('compactToolVersion'); + expect(benchmarks).toHaveProperty('compactcVersion'); + + // Verify hierarchical structure matches artifacts structure + expect(benchmarks.contracts).toHaveProperty('math'); + expect(benchmarks.contracts.math).toHaveProperty('contracts'); + expect(benchmarks.contracts.math).toHaveProperty('test'); + + // Verify module contract (no circuits) + expect(benchmarks.contracts.math.contracts.Uint128).toEqual({ + type: 'module', + }); + + // Verify top-level contract (with circuits) + const mockContract = + benchmarks.contracts.math.test.contracts['Uint128.mock']; + expect(mockContract.type).toBe('top-level'); + expect(mockContract.circuits).toHaveLength(2); + expect(mockContract.circuits).toEqual([ + { name: 'mint', k: 14, rows: 5000 }, + { name: 'burn', k: 14, rows: 4800 }, + ]); + + // Manifest should not have contracts + expect(writtenManifest).not.toHaveProperty('contracts'); }); }); diff --git a/packages/cli/test/runCompiler.test.ts b/packages/cli/test/runCompiler.test.ts index 3110f43..57051d0 100644 --- a/packages/cli/test/runCompiler.test.ts +++ b/packages/cli/test/runCompiler.test.ts @@ -9,11 +9,15 @@ import { } from '../src/types/errors.js'; // Mock CompactCompiler -vi.mock('../src/Compiler.js', () => ({ - CompactCompiler: { - fromArgs: vi.fn(), - }, -})); +vi.mock('../src/Compiler.js', async () => { + const actual = await vi.importActual('../src/types/manifest.js'); + return { + CompactCompiler: { + fromArgs: vi.fn(), + }, + StructureMismatchError: (actual as any).StructureMismatchError, + }; +}); // Mock error utilities vi.mock('../src/types/errors.js', async () => { @@ -323,7 +327,16 @@ describe('runCompiler CLI', () => { ); expect(mockConsoleLog).toHaveBeenCalledWith('\nOptions:'); expect(mockConsoleLog).toHaveBeenCalledWith( - ' --dir Compile specific directory (access, archive, security, token, utils)', + ' --dir Compile specific subdirectory within src', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --src Source directory (default: src)', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --out Output directory (default: artifacts)', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' --hierarchical Preserve source directory structure in artifacts output', ); expect(mockConsoleLog).toHaveBeenCalledWith( ' --skip-zk Skip zero-knowledge proof generation', @@ -331,9 +344,21 @@ describe('runCompiler CLI', () => { expect(mockConsoleLog).toHaveBeenCalledWith( ' + Use specific toolchain version (e.g., +0.26.0)', ); + expect(mockConsoleLog).toHaveBeenCalledWith( + '\nArtifact Output Structure:', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' Default (flattened): //', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' With --hierarchical: ///', + ); expect(mockConsoleLog).toHaveBeenCalledWith('\nExamples:'); expect(mockConsoleLog).toHaveBeenCalledWith( - ' compact-compiler # Compile all files', + ' compact-compiler # Compile all files (flattened)', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' compact-compiler --hierarchical # Compile with nested structure', ); expect(mockConsoleLog).toHaveBeenCalledWith( ' compact-compiler --dir security # Compile security directory', @@ -341,6 +366,9 @@ describe('runCompiler CLI', () => { expect(mockConsoleLog).toHaveBeenCalledWith( ' compact-compiler --dir access --skip-zk # Compile access with flags', ); + expect(mockConsoleLog).toHaveBeenCalledWith( + ' compact-compiler --src contracts --out build # Custom directories', + ); expect(mockConsoleLog).toHaveBeenCalledWith( ' SKIP_ZK=true compact-compiler --dir token # Use environment variable', ); diff --git a/packages/simulator/test/fixtures/sample-contracts/SampleZOwnable.compact b/packages/simulator/test/fixtures/sample-contracts/SampleZOwnable.compact index 554774b..6037790 100644 --- a/packages/simulator/test/fixtures/sample-contracts/SampleZOwnable.compact +++ b/packages/simulator/test/fixtures/sample-contracts/SampleZOwnable.compact @@ -6,8 +6,11 @@ pragma language_version >= 0.18.0; import CompactStandardLibrary; export { ZswapCoinPublicKey, ContractAddress, Either }; + export ledger _ownerCommitment: Bytes<32>; + export ledger _counter: Counter; + export sealed ledger _instanceSalt: Bytes<32>; witness secretNonce(): Bytes<32>; @@ -35,36 +38,24 @@ export circuit renounceOwnership(): [] { export circuit assertOnlyOwner(): [] { const nonce = secretNonce(); - const callerAsEither = Either { - is_left: true, - left: ownPublicKey(), - right: ContractAddress { bytes: pad(32, "") } - }; + const callerAsEither = + Either { is_left: true, + left: ownPublicKey(), + right: ContractAddress { bytes: pad(32, "") } }; const id = _computeOwnerId(callerAsEither, nonce); assert(_ownerCommitment == _computeOwnerCommitment(id, _counter), "SampleZOwnable: caller is not the owner"); } -export circuit _computeOwnerCommitment( - id: Bytes<32>, - counter: Uint<64>, -): Bytes<32> { +export circuit _computeOwnerCommitment(id: Bytes<32>, counter: Uint<64>,): Bytes<32> { // the value variable is necessary to force the circuit to be impure // this avoids a compiler bug in compactc v0.26.0 const value = _instanceSalt; return persistentHash>>( - [ - id, - value, - counter as Field as Bytes<32>, - pad(32, "SampleZOwnable:shield:") - ] - ); + [id, value, counter as Field as Bytes<32>, pad(32, "SampleZOwnable:shield:")]); } export pure circuit _computeOwnerId( - pk: Either, - nonce: Bytes<32> -): Bytes<32> { + pk: Either, nonce: Bytes<32>): Bytes<32> { assert(pk.is_left, "SampleZOwnable: contract address owners are not yet supported"); return persistentHash>>([pk.left.bytes, nonce]); } diff --git a/packages/simulator/test/fixtures/sample-contracts/Simple.compact b/packages/simulator/test/fixtures/sample-contracts/Simple.compact index 3ce08c7..3d0bf81 100644 --- a/packages/simulator/test/fixtures/sample-contracts/Simple.compact +++ b/packages/simulator/test/fixtures/sample-contracts/Simple.compact @@ -3,6 +3,7 @@ pragma language_version >= 0.18.0; import CompactStandardLibrary; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; + export ledger _val: Field; export circuit setVal(n: Field): [] { diff --git a/packages/simulator/test/fixtures/sample-contracts/Witness.compact b/packages/simulator/test/fixtures/sample-contracts/Witness.compact index 211edcb..d0ddb9a 100644 --- a/packages/simulator/test/fixtures/sample-contracts/Witness.compact +++ b/packages/simulator/test/fixtures/sample-contracts/Witness.compact @@ -3,12 +3,17 @@ pragma language_version >= 0.18.0; import CompactStandardLibrary; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; + export ledger _valBytes: Bytes<32>; + export ledger _valField: Field; + export ledger _valUint: Uint<128>; witness wit_secretBytes(): Bytes<32>; + witness wit_secretFieldPlusArg(arg1: Field): Field; + witness wit_secretUintPlusArgs(arg1: Uint<128>, arg2: Uint<128>): Uint<128>; export circuit setBytes(): [] {