diff --git a/packages/cli/README.md b/packages/cli/README.md index 966bb61..20cdd56 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -50,7 +50,10 @@ compact-compiler [options] | `--dir ` | Compile specific subdirectory within src | (all) | | `--src ` | Source directory containing `.compact` files | `src` | | `--out ` | Output directory for compiled artifacts | `artifacts` | +| `--exclude ` | Glob pattern to exclude files (can be repeated) | (none) | +| `--dry-run` | Preview which files would be compiled without compiling | `false` | | `--hierarchical` | Preserve source directory structure in output | `false` | +| `--verbose` | Show circuit compilation details | `false` | | `--skip-zk` | Skip zero-knowledge proof generation | `false` | | `+` | Use specific toolchain version (e.g., `+0.28.0`) | (default) | @@ -86,6 +89,52 @@ artifacts/ # Hierarchical output Token/ ``` +### Excluding Files + +Use `--exclude` to skip files matching glob patterns. This is useful for excluding mock contracts, test files, or any files you don't want to compile. + +**Supported glob patterns:** +- `*` matches any characters except `/` +- `**` matches zero or more path segments + +**Examples:** +```bash +# Exclude all mock contracts +compact-compiler --exclude "**/*.mock.compact" + +# Exclude test directory +compact-compiler --exclude "**/test/**" + +# Multiple patterns +compact-compiler --exclude "**/*.mock.compact" --exclude "**/test/**" + +# Root-level only (no ** prefix) +compact-compiler --exclude "*.mock.compact" # Only matches root-level mocks +``` + +### Dry run + +Use `--dry-run` to see which files would be compiled without running the compiler. No environment validation or compilation is performed. Useful to verify `--exclude` patterns or to see the file list before a full run. + +**Usage:** +```bash +# Preview all files that would be compiled +compact-compiler --dry-run + +# Preview with exclusions (verify your exclude patterns) +compact-compiler --exclude "**/*.mock.compact" --dry-run + +# Dry run in a specific directory +compact-compiler --dir access --dry-run +``` + +**Example output:** +``` +ℹ [DRY-RUN] Would compile 2 file(s): + Token.compact + AccessControl.compact +``` + ### Examples ```bash @@ -112,6 +161,18 @@ compact-compiler --dir access --skip-zk --hierarchical # Use environment variable SKIP_ZK=true compact-compiler + +# Exclude mock contracts +compact-compiler --exclude "**/*.mock.compact" + +# Exclude multiple patterns +compact-compiler --exclude "**/*.mock.compact" --exclude "**/test/**" + +# Preview which files would be compiled (dry run) +compact-compiler --dry-run + +# Dry run with exclusions to verify patterns +compact-compiler --exclude "**/*.mock.compact" --dry-run ``` ## Builder CLI @@ -129,7 +190,19 @@ The builder runs the compiler as a prerequisite, then executes additional build compact-builder [options] ``` -Accepts all compiler options except `--skip-zk` (builds always include ZK proofs). +Accepts all compiler options except `--skip-zk` (builds always include ZK proofs). Use `--exclude` to skip mock contracts or test files during the build. + +### 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` | +| `--exclude ` | Glob pattern to exclude files (can be repeated) | (none) | +| `--hierarchical` | Preserve source directory structure in output | `false` | +| `--verbose` | Show circuit compilation details (requires TTY, run directly not via turbo) | `false` | +| `+` | Use specific toolchain version (e.g., `+0.28.0`) | (default) | ### Examples @@ -142,75 +215,12 @@ 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.28.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.28.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?: string; // Compiler flags (e.g., '--skip-zk --verbose') - targetDir?: string; // Subdirectory within srcDir to compile - version?: string; // Toolchain version (e.g., '0.28.0') - hierarchical?: boolean; // Preserve directory structure in output - srcDir?: string; // Source directory (default: 'src') - outDir?: string; // Output directory (default: 'artifacts') -} -``` - -### Error Types - -```typescript -import { - CompactCliNotFoundError, // Compact CLI not in PATH - CompilationError, // Compilation failed (includes file path) - DirectoryNotFoundError, // Target directory doesn't exist -} from '@openzeppelin/compact-tools-cli'; +# Build excluding mock contracts +compact-builder --exclude "**/*.mock.compact" + +# Show circuit compilation details (verbose mode) +compact-builder --verbose ``` ## Development diff --git a/packages/cli/package.json b/packages/cli/package.json index 5cf08ab..51f3c8c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,7 +12,6 @@ "author": "", "license": "MIT", "type": "module", - "exports": "./index.js", "engines": { "node": ">=20" }, diff --git a/packages/cli/src/Builder.ts b/packages/cli/src/Builder.ts index f94027f..a96c7a1 100755 --- a/packages/cli/src/Builder.ts +++ b/packages/cli/src/Builder.ts @@ -12,10 +12,10 @@ 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. + * Accepts all CompilerOptions including flags (e.g. --verbose for circuit compilation details). + * Note: builds should always include ZK proofs — avoid using --skip-zk. */ -export type BuilderOptions = Omit; +export type BuilderOptions = CompilerOptions; /** * A class to handle the build process for a project. @@ -41,7 +41,7 @@ export type BuilderOptions = Omit; * Compactc version: 0.26.0 * ✔ [BUILD] [1/3] Compiling TypeScript * ✔ [BUILD] [2/3] Copying artifacts - * ✔ [BUILD] [3/3] Copying and cleaning .compact files + * ✔ [BUILD] [3/3] Copying .compact files * ``` * * @example Failed Compilation Output @@ -76,8 +76,8 @@ export class CompactBuilder { shell: '/bin/bash', }, { - cmd: 'mkdir -p dist && find src -type f -name "*.compact" -exec cp {} dist/ \\; 2>/dev/null && rm dist/Mock*.compact 2>/dev/null || true', - msg: 'Copying and cleaning .compact files', + cmd: 'mkdir -p dist && find src -type f -name "*.compact" -exec cp {} dist/ \\; 2>/dev/null || true', + msg: 'Copying .compact files', shell: '/bin/bash', }, ]; @@ -86,7 +86,7 @@ export class CompactBuilder { * Constructs a new CompactBuilder instance. * @param options - Compiler options (flags, srcDir, outDir, hierarchical, etc.) */ - constructor(options: CompilerOptions = {}) { + constructor(options: BuilderOptions = {}) { this.options = options; } @@ -114,7 +114,6 @@ export class CompactBuilder { * @throws Error if compilation or any build step fails */ public async build(): Promise { - // Run compact compilation as a prerequisite const compiler = new CompactCompiler(this.options); await compiler.compile(); diff --git a/packages/cli/src/Compiler.ts b/packages/cli/src/Compiler.ts index e20d0fb..2856628 100755 --- a/packages/cli/src/Compiler.ts +++ b/packages/cli/src/Compiler.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { exec as execCallback } from 'node:child_process'; +import { exec as execCallback, spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { basename, dirname, join, relative } from 'node:path'; @@ -19,6 +19,66 @@ const DEFAULT_SRC_DIR = 'src'; /** Default output directory for compiled artifacts */ const DEFAULT_OUT_DIR = 'artifacts'; +/** + * Returns true if path matches the glob pattern. + * Supports ** (match zero or more path segments) and * (match within segment). + * + * @param path - The file path to test (use forward slashes) + * @param pattern - Glob pattern to match against + * @returns true if the path matches the pattern + * @example + * ```typescript + * // Pattern string: two asterisks, slash, asterisk, .mock.compact (backslash in code escapes slash for JSDoc) + * matchGlob('foo/bar.mock.compact', '**\/*.mock.compact'); // true + * matchGlob('bar.mock.compact', '*.mock.compact'); // true + * matchGlob('test/unit/foo.compact', '**\/test/**'); // true + * ``` + */ +export function matchGlob(path: string, pattern: string): boolean { + const re = globToRegExp(pattern); + return re.test(path); +} + +/** + * Converts a glob pattern to a RegExp. + * Supports: + * - `**` matches zero or more path segments + * - `*` matches any characters except `/` + * + * Does NOT support: + * - `?` (single character wildcard) + * - Brace expansion `{a,b}` + * - Negation `!pattern` + * + * @param pattern - Glob pattern to convert + * @returns RegExp that matches paths according to the glob pattern + * @example + * ```typescript + * // Pattern string: two asterisks, slash, asterisk, .mock.compact (backslash in code escapes slash for JSDoc) + * const re = globToRegExp('**\/*.mock.compact'); + * re.test('foo/bar.mock.compact'); // true + * re.test('bar.mock.compact'); // true (** matches zero segments) + * ``` + */ +// Placeholders for glob-to-regex (private-use Unicode, not in patterns) +const GLOB_STAR_STAR = '\uE001'; +const GLOB_STAR = '\uE002'; +const GLOB_STAR_STAR_END = '\uE003'; + +export function globToRegExp(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*\//g, GLOB_STAR_STAR) + .replace(/\/\*\*$/g, `/${GLOB_STAR_STAR_END}`) + .replace(/\*\*/g, GLOB_STAR_STAR) + .replace(/\*/g, GLOB_STAR); + const reStr = escaped + .replace(new RegExp(GLOB_STAR_STAR, 'g'), '(?:[^/]*/)*') + .replace(new RegExp(GLOB_STAR, 'g'), '[^/]*') + .replace(new RegExp(GLOB_STAR_STAR_END, 'g'), '.*'); + return new RegExp(`^${reStr}$`); +} + /** * Function type for executing shell commands. * Allows dependency injection for testing and customization. @@ -61,13 +121,24 @@ export interface CompilerOptions { srcDir?: string; /** Output directory for compiled artifacts (default: 'artifacts') */ outDir?: string; + /** Glob patterns to exclude from compilation (e.g. '*.mock.compact' or 'test/**') */ + exclude?: string[]; + /** Glob patterns to include - when set, only files matching at least one pattern are compiled (e.g. '**\/*.mock.compact') */ + include?: string[]; + /** If true, preview which files would be compiled without actually compiling */ + dryRun?: boolean; + /** If true, passes --verbose to compact compile to show circuit compilation details */ + verbose?: boolean; } /** Resolved compiler options with defaults applied */ type ResolvedCompilerOptions = Required< - Pick + Pick< + CompilerOptions, + 'flags' | 'hierarchical' | 'srcDir' | 'outDir' | 'dryRun' | 'verbose' + > > & - Pick; + Pick; /** * Service responsible for validating the Compact CLI environment. @@ -201,14 +272,41 @@ export class EnvironmentValidator { */ export class FileDiscovery { private srcDir: string; + private excludePatterns: string[]; + private includePatterns: string[]; /** * Creates a new FileDiscovery instance. * * @param srcDir - Base source directory for relative path calculation (default: 'src') + * @param exclude - Glob patterns to exclude from discovery (e.g. mock files) + * @param include - Glob patterns to include - when set, only matching files pass through */ - constructor(srcDir: string = DEFAULT_SRC_DIR) { + constructor( + srcDir: string = DEFAULT_SRC_DIR, + exclude: string[] = [], + include: string[] = [], + ) { this.srcDir = srcDir; + this.excludePatterns = exclude; + this.includePatterns = include; + } + + /** + * Returns true if the given relative file path should be kept. + * When include patterns are set, the file must match at least one. + * Then, the file must not match any exclude pattern. + */ + private isIncluded(relativePath: string): boolean { + if ( + this.includePatterns.length > 0 && + !this.includePatterns.some((pattern) => matchGlob(relativePath, pattern)) + ) { + return false; + } + return !this.excludePatterns.some((pattern) => + matchGlob(relativePath, pattern), + ); } /** @@ -234,7 +332,10 @@ export class FileDiscovery { } if (entry.isFile() && fullPath.endsWith('.compact')) { - return [relative(this.srcDir, fullPath)]; + const relativePath = relative(this.srcDir, fullPath); + if (this.isIncluded(relativePath)) { + return [relativePath]; + } } return []; } catch (err) { @@ -260,7 +361,7 @@ export class FileDiscovery { */ export type CompilerServiceOptions = Pick< CompilerOptions, - 'hierarchical' | 'srcDir' | 'outDir' + 'hierarchical' | 'srcDir' | 'outDir' | 'verbose' >; /** Resolved options for CompilerService with defaults applied */ @@ -301,6 +402,7 @@ export class CompilerService { hierarchical: options.hierarchical ?? false, srcDir: options.srcDir ?? DEFAULT_SRC_DIR, outDir: options.outDir ?? DEFAULT_OUT_DIR, + verbose: options.verbose ?? false, }; } @@ -352,6 +454,17 @@ export class CompilerService { const flagsStr = flags ? ` ${flags}` : ''; const command = `compact compile${versionFlag ? ` ${versionFlag}` : ''}${flagsStr} "${inputPath}" "${outputDir}"`; + if (this.options.verbose) { + // Use spawn with inherited stdio so TTY-mode output (circuit details) streams directly + try { + await this.spawnInherit(command); + return { stdout: '', stderr: '' }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new CompilationError(`Failed to compile ${file}: ${message}`, file, error); + } + } + try { return await this.execFn(command); } catch (error: unknown) { @@ -370,6 +483,27 @@ export class CompilerService { ); } } + + private spawnInherit(command: string): Promise { + return new Promise((resolve, reject) => { + // Use piped stdout/stderr forwarded to process.stdout/stderr rather than direct fd + // inheritance — direct inherit can cause EOF errors in turbo's captured pipe environment. + const child = spawn(command, { + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout?.pipe(process.stdout); + child.stderr?.pipe(process.stderr); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command failed with exit code ${code}: ${command}`)); + } + }); + child.on('error', (err) => reject(err)); + }); + } } /** @@ -484,6 +618,33 @@ export const UIService = { chalk.yellow(`[COMPILE] No .compact files found in ${searchLocation}.`), ); }, + + /** + * Displays dry-run output showing which files would be compiled. + * + * @param files - Array of file paths that would be compiled + * @param targetDir - Optional target directory being compiled + * @example + * ```typescript + * UIService.showDryRun(['Token.compact', 'AccessControl.compact']); + * // Output: + * // [DRY-RUN] Would compile 2 file(s): + * // Token.compact + * // AccessControl.compact + * ``` + */ + showDryRun(files: string[], targetDir?: string): void { + const searchLocation = targetDir ? ` in ${targetDir}/` : ''; + const spinner = ora(); + spinner.info( + chalk.cyan( + `[DRY-RUN] Would compile ${files.length} file(s)${searchLocation}:`, + ), + ); + for (const file of files) { + console.log(chalk.cyan(` ${file}`)); + } + }, }; /** @@ -566,13 +727,22 @@ export class CompactCompiler { hierarchical: options.hierarchical ?? false, srcDir: options.srcDir ?? DEFAULT_SRC_DIR, outDir: options.outDir ?? DEFAULT_OUT_DIR, + exclude: options.exclude, + include: options.include, + dryRun: options.dryRun ?? false, + verbose: options.verbose ?? false, }; this.environmentValidator = new EnvironmentValidator(execFn); - this.fileDiscovery = new FileDiscovery(this.options.srcDir); + this.fileDiscovery = new FileDiscovery( + this.options.srcDir, + this.options.exclude ?? [], + this.options.include ?? [], + ); this.compilerService = new CompilerService(execFn, { hierarchical: this.options.hierarchical, srcDir: this.options.srcDir, outDir: this.options.outDir, + verbose: this.options.verbose, }); } @@ -583,7 +753,9 @@ export class CompactCompiler { * - `--dir ` - Target specific subdirectory within srcDir * - `--src ` - Source directory containing .compact files (default: 'src') * - `--out ` - Output directory for artifacts (default: 'artifacts') + * - `--exclude ` - Glob pattern to exclude from compilation (may be repeated) * - `--hierarchical` - Preserve source directory structure in artifacts output + * - `--dry-run` - Preview which files would be compiled without actually compiling * - `+` - Use specific toolchain version * - Other arguments - Treated as compiler flags * - `SKIP_ZK=true` environment variable - Adds --skip-zk flag @@ -591,7 +763,7 @@ export class CompactCompiler { * @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 + * @throws {Error} If --dir, --src, --out, or --exclude flag is provided without a value */ static parseArgs( args: string[], @@ -634,8 +806,32 @@ export class CompactCompiler { } else { throw new Error('--out flag requires a directory path'); } + } else if (args[i] === '--exclude') { + const valueExists = + i + 1 < args.length && !args[i + 1].startsWith('--'); + if (valueExists) { + options.exclude = options.exclude ?? []; + options.exclude.push(args[i + 1]); + i++; + } else { + throw new Error('--exclude flag requires a glob pattern'); + } + } else if (args[i] === '--include') { + const valueExists = + i + 1 < args.length && !args[i + 1].startsWith('--'); + if (valueExists) { + options.include = options.include ?? []; + options.include.push(args[i + 1]); + i++; + } else { + throw new Error('--include flag requires a glob pattern'); + } } else if (args[i] === '--hierarchical') { options.hierarchical = true; + } else if (args[i] === '--dry-run') { + options.dryRun = true; + } else if (args[i] === '--verbose') { + options.verbose = true; } else if (args[i].startsWith('+')) { options.version = args[i].slice(1); } else { @@ -658,7 +854,9 @@ export class CompactCompiler { * - `--dir ` - Target specific subdirectory within srcDir * - `--src ` - Source directory containing .compact files (default: 'src') * - `--out ` - Output directory for artifacts (default: 'artifacts') + * - `--exclude ` - Glob pattern to exclude from compilation (may be repeated) * - `--hierarchical` - Preserve source directory structure in artifacts output + * - `--dry-run` - Preview which files would be compiled without actually compiling * - `+` - Use specific toolchain version * - Other arguments - Treated as compiler flags * - `SKIP_ZK=true` environment variable - Adds --skip-zk flag @@ -666,7 +864,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, --src, or --out flag is provided without a value + * @throws {Error} If --dir, --src, --out, or --exclude flag is provided without a value * @example * ```typescript * // Parse command line: compact-compiler --dir security --skip-zk +0.26.0 @@ -771,7 +969,9 @@ export class CompactCompiler { * ``` */ async compile(): Promise { - await this.validateEnvironment(); + if (!this.options.dryRun) { + await this.validateEnvironment(); + } const searchDir = this.options.targetDir ? join(this.options.srcDir, this.options.targetDir) @@ -792,6 +992,11 @@ export class CompactCompiler { return; } + if (this.options.dryRun) { + UIService.showDryRun(compactFiles, this.options.targetDir); + return; + } + UIService.showCompilationStart(compactFiles.length, this.options.targetDir); for (const [index, file] of compactFiles.entries()) { @@ -815,6 +1020,28 @@ export class CompactCompiler { total: number, ): Promise { const step = `[${index + 1}/${total}]`; + + if (this.options.verbose) { + // In verbose mode, spawn with inherited stdio streams TTY output directly. + // Skip the spinner to avoid interleaving with circuit compilation details. + // biome-ignore lint/suspicious/noConsole: Needed to display verbose compilation status + console.log(chalk.blue(` [COMPILE] ${step} Compiling ${file}...`)); + try { + await this.compilerService.compileFile( + file, + this.options.flags, + this.options.version, + ); + // biome-ignore lint/suspicious/noConsole: Needed to display compilation success + console.log(chalk.green(`✔ [COMPILE] ${step} Compiled ${file}`)); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: Needed to display compilation failure + console.log(chalk.red(`✖ [COMPILE] ${step} Failed ${file}`)); + throw error; + } + return; + } + const spinner = ora( chalk.blue(`[COMPILE] ${step} Compiling ${file}`), ).start(); diff --git a/packages/cli/src/runBuilder.ts b/packages/cli/src/runBuilder.ts index 09e589a..2643835 100644 --- a/packages/cli/src/runBuilder.ts +++ b/packages/cli/src/runBuilder.ts @@ -13,6 +13,7 @@ import { CompactBuilder } from './Builder.ts'; * - `--src ` - Source directory (default: src) * - `--out ` - Output directory (default: artifacts) * - `--hierarchical` - Preserve directory structure in output + * - `--verbose` - Show circuit compilation details from compact compile * - `+` - Use specific toolchain version * * @example diff --git a/packages/cli/src/runCompiler.ts b/packages/cli/src/runCompiler.ts index fda1a77..8592137 100644 --- a/packages/cli/src/runCompiler.ts +++ b/packages/cli/src/runCompiler.ts @@ -185,6 +185,19 @@ function showUsageHelp(): void { ' --hierarchical Preserve source directory structure in artifacts output', ), ); + console.log( + chalk.yellow(' --verbose Show circuit compilation details'), + ); + console.log( + chalk.yellow( + ' --exclude Exclude files matching pattern (e.g. "**/*.mock.compact")', + ), + ); + console.log( + chalk.yellow( + ' --include Only compile files matching pattern (e.g. "**/*.mock.compact")', + ), + ); console.log( chalk.yellow(' --skip-zk Skip zero-knowledge proof generation'), ); diff --git a/packages/cli/test/Compiler.test.ts b/packages/cli/test/Compiler.test.ts index 3913df8..fc2f355 100644 --- a/packages/cli/test/Compiler.test.ts +++ b/packages/cli/test/Compiler.test.ts @@ -237,6 +237,184 @@ describe('FileDiscovery', () => { expect(files).toEqual(['Ownable.compact']); }); + + it('should exclude files matching exclude patterns', async () => { + discovery = new FileDiscovery('src', ['**/*.mock.compact']); + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'LunarswapFactory.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'AccessControl.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + + mockReaddir.mockResolvedValue(mockDirents as any); + + const files = await discovery.getCompactFiles('src'); + + expect(files).toEqual(['MyToken.compact', 'AccessControl.compact']); + }); + + it('should exclude files matching any of multiple exclude patterns', async () => { + discovery = new FileDiscovery('src', ['**/*.mock.compact', '**/test/**']); + mockReaddir + .mockResolvedValueOnce([ + { + name: 'Token.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'test', + isFile: () => false, + isDirectory: () => true, + }, + ] as any) + .mockResolvedValueOnce([ + { + name: 'MockToken.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + ] as any); + + const files = await discovery.getCompactFiles('src'); + + expect(files).toEqual(['Token.compact']); + }); + + it('should exclude root-level files matching pattern without ** prefix', async () => { + discovery = new FileDiscovery('src', ['*.mock.compact']); + const mockDirents = [ + { + name: 'Token.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'Factory.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'subdir', + isFile: () => false, + isDirectory: () => true, + }, + ]; + + mockReaddir + .mockResolvedValueOnce(mockDirents as any) + .mockResolvedValueOnce([ + { + name: 'Nested.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + ] as any); + + const files = await discovery.getCompactFiles('src'); + + // *.mock.compact should only match root-level files, not nested ones + expect(files).toEqual(['Token.compact', 'subdir/Nested.mock.compact']); + }); + + it('should include only files matching include patterns', async () => { + discovery = new FileDiscovery('src', [], ['**/*.mock.compact']); + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'LunarswapFactory.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'AccessControl.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + + mockReaddir.mockResolvedValue(mockDirents as any); + + const files = await discovery.getCompactFiles('src'); + + expect(files).toEqual(['LunarswapFactory.mock.compact']); + }); + + it('should apply include before exclude', async () => { + discovery = new FileDiscovery( + 'src', + ['**/archive/**'], + ['**/*.mock.compact'], + ); + mockReaddir + .mockResolvedValueOnce([ + { + name: 'Token.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'Factory.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'archive', + isFile: () => false, + isDirectory: () => true, + }, + ] as any) + .mockResolvedValueOnce([ + { + name: 'Old.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + ] as any); + + const files = await discovery.getCompactFiles('src'); + + // Include keeps only *.mock.compact, then exclude removes archive/** + expect(files).toEqual(['Factory.mock.compact']); + }); + + it('should return all files when include is empty', async () => { + discovery = new FileDiscovery('src', [], []); + const mockDirents = [ + { + name: 'Token.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'Mock.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + + mockReaddir.mockResolvedValue(mockDirents as any); + + const files = await discovery.getCompactFiles('src'); + + expect(files).toEqual(['Token.compact', 'Mock.mock.compact']); + }); }); }); @@ -569,6 +747,26 @@ describe('UIService', () => { ); }); }); + + describe('showDryRun', () => { + it('should show dry-run output with file list', () => { + UIService.showDryRun(['Token.compact', 'AccessControl.compact']); + + expect(mockSpinner.info).toHaveBeenCalledWith( + '[DRY-RUN] Would compile 2 file(s):', + ); + expect(console.log).toHaveBeenCalledWith(' Token.compact'); + expect(console.log).toHaveBeenCalledWith(' AccessControl.compact'); + }); + + it('should show dry-run output with target directory', () => { + UIService.showDryRun(['Token.compact'], 'security'); + + expect(mockSpinner.info).toHaveBeenCalledWith( + '[DRY-RUN] Would compile 1 file(s) in security/:', + ); + }); + }); }); describe('CompactCompiler', () => { @@ -604,6 +802,8 @@ describe('CompactCompiler', () => { hierarchical: true, srcDir: 'contracts', outDir: 'build', + exclude: ['**/*.mock.compact'], + include: ['**/test/**'], }, mockExec, ); @@ -615,6 +815,8 @@ describe('CompactCompiler', () => { expect(compiler.testOptions.hierarchical).toBe(true); expect(compiler.testOptions.srcDir).toBe('contracts'); expect(compiler.testOptions.outDir).toBe('build'); + expect(compiler.testOptions.exclude).toEqual(['**/*.mock.compact']); + expect(compiler.testOptions.include).toEqual(['**/test/**']); }); it('should trim flags', () => { @@ -661,7 +863,8 @@ describe('CompactCompiler', () => { ]); expect(compiler.testOptions.targetDir).toBe('security'); - expect(compiler.testOptions.flags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.flags).toBe('--skip-zk'); + expect(compiler.testOptions.verbose).toBe(true); }); it('should parse version flag', () => { @@ -681,7 +884,8 @@ describe('CompactCompiler', () => { ]); expect(compiler.testOptions.targetDir).toBe('security'); - expect(compiler.testOptions.flags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.flags).toBe('--skip-zk'); + expect(compiler.testOptions.verbose).toBe(true); expect(compiler.testOptions.version).toBe('0.26.0'); }); @@ -691,7 +895,8 @@ describe('CompactCompiler', () => { }); expect(compiler.testOptions.targetDir).toBe('access'); - expect(compiler.testOptions.flags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.flags).toBe('--skip-zk'); + expect(compiler.testOptions.verbose).toBe(true); }); it('should deduplicate flags when both env var and CLI flag are present', () => { @@ -699,7 +904,8 @@ describe('CompactCompiler', () => { SKIP_ZK: 'true', }); - expect(compiler.testOptions.flags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.flags).toBe('--skip-zk'); + expect(compiler.testOptions.verbose).toBe(true); }); it('should throw error for --dir without argument', () => { @@ -714,6 +920,82 @@ describe('CompactCompiler', () => { ); }); + it('should parse --exclude flag', () => { + compiler = CompactCompiler.fromArgs(['--exclude', '**/*.mock.compact']); + + expect(compiler.testOptions.exclude).toEqual(['**/*.mock.compact']); + }); + + it('should parse multiple --exclude flags', () => { + compiler = CompactCompiler.fromArgs([ + '--exclude', + '**/*.mock.compact', + '--exclude', + '**/test/**', + ]); + + expect(compiler.testOptions.exclude).toEqual([ + '**/*.mock.compact', + '**/test/**', + ]); + }); + + it('should throw error for --exclude without argument', () => { + expect(() => CompactCompiler.fromArgs(['--exclude'])).toThrow( + '--exclude flag requires a glob pattern', + ); + }); + + it('should throw error for --exclude followed by another flag', () => { + expect(() => + CompactCompiler.fromArgs(['--exclude', '--skip-zk']), + ).toThrow('--exclude flag requires a glob pattern'); + }); + + it('should parse --include flag', () => { + compiler = CompactCompiler.fromArgs(['--include', '**/*.mock.compact']); + + expect(compiler.testOptions.include).toEqual(['**/*.mock.compact']); + }); + + it('should parse multiple --include flags', () => { + compiler = CompactCompiler.fromArgs([ + '--include', + '**/*.mock.compact', + '--include', + '**/test/**', + ]); + + expect(compiler.testOptions.include).toEqual([ + '**/*.mock.compact', + '**/test/**', + ]); + }); + + it('should throw error for --include without argument', () => { + expect(() => CompactCompiler.fromArgs(['--include'])).toThrow( + '--include flag requires a glob pattern', + ); + }); + + it('should throw error for --include followed by another flag', () => { + expect(() => + CompactCompiler.fromArgs(['--include', '--skip-zk']), + ).toThrow('--include flag requires a glob pattern'); + }); + + it('should parse --include combined with --exclude', () => { + compiler = CompactCompiler.fromArgs([ + '--include', + '**/*.mock.compact', + '--exclude', + '**/archive/**', + ]); + + expect(compiler.testOptions.include).toEqual(['**/*.mock.compact']); + expect(compiler.testOptions.exclude).toEqual(['**/archive/**']); + }); + it('should parse --hierarchical flag', () => { compiler = CompactCompiler.fromArgs(['--hierarchical']); @@ -798,6 +1080,31 @@ describe('CompactCompiler', () => { '--out flag requires a directory path', ); }); + + it('should parse --dry-run flag', () => { + compiler = CompactCompiler.fromArgs(['--dry-run']); + + expect(compiler.testOptions.dryRun).toBe(true); + }); + + it('should parse --dry-run flag with other options', () => { + compiler = CompactCompiler.fromArgs([ + '--dry-run', + '--exclude', + '**/*.mock.compact', + '--skip-zk', + ]); + + expect(compiler.testOptions.dryRun).toBe(true); + expect(compiler.testOptions.exclude).toEqual(['**/*.mock.compact']); + expect(compiler.testOptions.flags).toBe('--skip-zk'); + }); + + it('should default dryRun to false', () => { + compiler = CompactCompiler.fromArgs([]); + + expect(compiler.testOptions.dryRun).toBe(false); + }); }); describe('validateEnvironment', () => { @@ -985,6 +1292,36 @@ describe('CompactCompiler', () => { ); }); + it('should not compile files matching exclude patterns', async () => { + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'LunarswapFactory.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + mockReaddir.mockResolvedValue(mockDirents as any); + compiler = new CompactCompiler( + { flags: '--skip-zk', exclude: ['**/*.mock.compact'] }, + mockExec, + ); + + await compiler.compile(); + + const compileCalls = (mockExec as any).mock.calls.filter( + (call: string[]) => + call[0].includes('compact compile') && !call[0].includes('--version'), + ); + expect(compileCalls).toHaveLength(1); + expect(compileCalls[0][0]).toContain('MyToken.compact'); + expect(compileCalls[0][0]).not.toContain('mock.compact'); + }); + it('should handle compilation errors gracefully', async () => { const brokenDirent = { name: 'Broken.compact', @@ -1020,6 +1357,73 @@ describe('CompactCompiler', () => { ); expect(testMockExec).toHaveBeenCalledTimes(4); }); + + it('should preview files without compiling when --dry-run is set', async () => { + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'Ownable.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + mockReaddir.mockResolvedValue(mockDirents as any); + const showDryRunSpy = vi + .spyOn(UIService, 'showDryRun') + .mockImplementation(() => {}); + + compiler = new CompactCompiler({ dryRun: true }, mockExec); + + await compiler.compile(); + + // Should not call any exec commands (no environment validation, no compilation) + expect(mockExec).not.toHaveBeenCalled(); + // Should call showDryRun with the discovered files + expect(showDryRunSpy).toHaveBeenCalledWith( + ['MyToken.compact', 'Ownable.compact'], + undefined, + ); + + showDryRunSpy.mockRestore(); + }); + + it('should show excluded files in dry-run output', async () => { + const mockDirents = [ + { + name: 'MyToken.compact', + isFile: () => true, + isDirectory: () => false, + }, + { + name: 'Factory.mock.compact', + isFile: () => true, + isDirectory: () => false, + }, + ]; + mockReaddir.mockResolvedValue(mockDirents as any); + const showDryRunSpy = vi + .spyOn(UIService, 'showDryRun') + .mockImplementation(() => {}); + + compiler = new CompactCompiler( + { dryRun: true, exclude: ['**/*.mock.compact'] }, + mockExec, + ); + + await compiler.compile(); + + // Should only show non-excluded files + expect(showDryRunSpy).toHaveBeenCalledWith( + ['MyToken.compact'], + undefined, + ); + + showDryRunSpy.mockRestore(); + }); }); describe('Real-world scenarios', () => { diff --git a/packages/simulator/package.json b/packages/simulator/package.json index 8daf226..b4317a5 100644 --- a/packages/simulator/package.json +++ b/packages/simulator/package.json @@ -37,7 +37,7 @@ "vitest": "^4.0.15" }, "dependencies": { - "@midnight-ntwrk/compact-runtime": "0.14.0", + "@midnight-ntwrk/compact-runtime": "0.15.0", "@midnight-ntwrk/ledger-v7": "^7.0.0" } } diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 98c83f0..2f2295c 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -121,7 +121,10 @@ export function createSimulator< * * @returns Object containing both pure and impure circuit proxies */ - public get circuits() { + public get circuits(): { + pure: ContextlessCircuits, P>; + impure: ContextlessCircuits, P>; + } { return { pure: this.pureCircuit, impure: this.impureCircuit, diff --git a/yarn.lock b/yarn.lock index c49c106..a3e80e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -334,14 +334,14 @@ __metadata: languageName: node linkType: hard -"@midnight-ntwrk/compact-runtime@npm:0.14.0": - version: 0.14.0 - resolution: "@midnight-ntwrk/compact-runtime@npm:0.14.0" +"@midnight-ntwrk/compact-runtime@npm:0.15.0": + version: 0.15.0 + resolution: "@midnight-ntwrk/compact-runtime@npm:0.15.0" dependencies: - "@midnight-ntwrk/onchain-runtime-v2": "npm:^2.0.0" + "@midnight-ntwrk/onchain-runtime-v3": "npm:^3.0.0" "@types/object-inspect": "npm:^1.8.1" object-inspect: "npm:^1.12.3" - checksum: 10/bba44d09770b172b7a5ba193f59d2ec57ca0dff2e3fd538326942e102e8cbe0b0cc1cb736e1f469afc74258517e7d25fc4dfa7f89a299aed900efc89f1eed3a7 + checksum: 10/12ac86a114a404386037547a6eb021694537c0636d24d281b101c5be75e3f5703bad9e0bbcc7ea2a39a96e167d200860049a9957dbb4dbdeb585c3fba696909c languageName: node linkType: hard @@ -352,10 +352,10 @@ __metadata: languageName: node linkType: hard -"@midnight-ntwrk/onchain-runtime-v2@npm:^2.0.0": - version: 2.0.1 - resolution: "@midnight-ntwrk/onchain-runtime-v2@npm:2.0.1" - checksum: 10/40ffba7809ecbf9e7e4fd98e7e025922ba72ff667d15f7737b9a2b913558688f19552ef40a63a1379b348a4e5c85e4257f6f485d6b09d15c2b5e4ca0149613b0 +"@midnight-ntwrk/onchain-runtime-v3@npm:^3.0.0": + version: 3.0.0 + resolution: "@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0" + checksum: 10/873aeb9e631c3678373c62b5aef847de454de94427028fb3d3f28bfdc8b2c02a3c770bd79d9bfef183eb9db6fb8c23e6826636f2e512ffd6eacbcf7cc0651c5d languageName: node linkType: hard @@ -402,7 +402,7 @@ __metadata: version: 0.0.0-use.local resolution: "@openzeppelin/compact-tools-simulator@workspace:packages/simulator" dependencies: - "@midnight-ntwrk/compact-runtime": "npm:0.14.0" + "@midnight-ntwrk/compact-runtime": "npm:0.15.0" "@midnight-ntwrk/ledger-v7": "npm:^7.0.0" "@tsconfig/node24": "npm:^24.0.3" "@types/node": "npm:24.10.1"