From 8aff8fb7c3a783a0273f60c7425947ed8e68a3c1 Mon Sep 17 00:00:00 2001 From: 0xisk Date: Thu, 11 Dec 2025 12:28:45 +0100 Subject: [PATCH 1/7] refactor: introduce artifacts manifest, strict types checking --- packages/cli/README.md | 73 ++- packages/cli/src/Compiler.ts | 603 +++++------------- packages/cli/src/config.ts | 60 ++ packages/cli/src/runCompiler.ts | 86 +++ packages/cli/src/services/CompilerService.ts | 128 ++++ .../cli/src/services/EnvironmentValidator.ts | 165 +++++ packages/cli/src/services/FileDiscovery.ts | 70 ++ packages/cli/src/services/ManifestService.ts | 90 +++ packages/cli/src/services/UIService.ts | 107 ++++ packages/cli/src/types/manifest.ts | 130 ++++ packages/cli/test/Compiler.test.ts | 341 +++++++--- packages/cli/test/runCompiler.test.ts | 14 +- 12 files changed, 1328 insertions(+), 539 deletions(-) create mode 100644 packages/cli/src/config.ts create mode 100644 packages/cli/src/services/CompilerService.ts create mode 100644 packages/cli/src/services/EnvironmentValidator.ts create mode 100644 packages/cli/src/services/FileDiscovery.ts create mode 100644 packages/cli/src/services/ManifestService.ts create mode 100644 packages/cli/src/services/UIService.ts create mode 100644 packages/cli/src/types/manifest.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 9c76f81..60ab235 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -51,6 +51,7 @@ compact-compiler [options] | `--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) | @@ -86,6 +87,50 @@ artifacts/ # Hierarchical output 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 @@ -110,6 +155,9 @@ 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 ``` @@ -153,7 +201,7 @@ import { CompactCompiler } from '@openzeppelin/compact-tools-cli'; // Using options object const compiler = new CompactCompiler({ - flags: '--skip-zk', + flags: ['--skip-zk'], targetDir: 'security', version: '0.26.0', hierarchical: true, @@ -194,13 +242,25 @@ class CompactBuilder { // Options interface interface CompilerOptions { - flags?: string; // Compiler flags (e.g., '--skip-zk --verbose') + flags?: CompilerFlag[]; // Compiler flags (e.g., ['--skip-zk']) targetDir?: string; // Subdirectory within srcDir to compile - version?: string; // Toolchain version (e.g., '0.26.0') + 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 @@ -210,6 +270,7 @@ 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'; ``` @@ -235,13 +296,11 @@ yarn clean ``` ℹ [COMPILE] Compact compiler started -ℹ [COMPILE] Compact developer tools: compact 0.1.0 -ℹ [COMPILE] Compact toolchain: Compactc version: 0.26.0 +ℹ [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 - Compactc version: 0.26.0 ✔ [COMPILE] [2/2] Compiled Token.compact - Compactc version: 0.26.0 ``` ## License diff --git a/packages/cli/src/Compiler.ts b/packages/cli/src/Compiler.ts index e20d0fb..c75ba10 100755 --- a/packages/cli/src/Compiler.ts +++ b/packages/cli/src/Compiler.ts @@ -1,34 +1,34 @@ #!/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, dirname, join, relative } from 'node:path'; -import { promisify } from 'node:util'; +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'; - -/** Default source directory containing .compact files */ -const DEFAULT_SRC_DIR = 'src'; -/** Default output directory for compiled artifacts */ -const DEFAULT_OUT_DIR = '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 CompilerFlag, + type NodeVersion, + type Platform, + StructureMismatchError, +} from './types/manifest.ts'; /** * Configuration options for the Compact compiler CLI. @@ -37,7 +37,7 @@ export type ExecFunction = ( * @example * ```typescript * const options: CompilerOptions = { - * flags: '--skip-zk --verbose', + * flags: ['--skip-zk'], * targetDir: 'security', * version: '0.26.0', * hierarchical: false, @@ -45,12 +45,12 @@ export type ExecFunction = ( * ``` */ export interface CompilerOptions { - /** Compiler flags to pass to the Compact CLI (e.g., '--skip-zk --verbose') */ - flags?: string; + /** Compiler flags to pass to the Compact CLI */ + flags?: CompilerFlag[]; /** Optional subdirectory within srcDir to compile (e.g., 'security', 'token') */ targetDir?: string; - /** Optional toolchain version to use (e.g., '0.26.0') */ - version?: string; + /** Optional compactc toolchain version to use */ + version?: CompactcVersion; /** * Whether to preserve directory structure in artifacts output. * - `false` (default): Flattened output - `//` @@ -61,431 +61,22 @@ export interface CompilerOptions { srcDir?: string; /** Output directory for compiled artifacts (default: 'artifacts') */ outDir?: string; + /** + * Force deletion of existing artifacts on structure mismatch. + * When true, skips the confirmation prompt and auto-deletes. + */ + force?: boolean; } /** Resolved compiler options with defaults applied */ type ResolvedCompilerOptions = Required< - Pick + Pick< + CompilerOptions, + 'flags' | 'hierarchical' | 'srcDir' | 'outDir' | 'force' + > > & Pick; -/** - * 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.getDevToolsVersion(); - * ``` - */ -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.getDevToolsVersion(); - * console.log(`Using Compact ${version}`); - * ``` - */ - async getDevToolsVersion(): 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 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}`); - * ``` - */ - async getToolchainVersion(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 - * @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<{ 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('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 []; - } - } -} - -/** - * Options for configuring the CompilerService. - * A subset of CompilerOptions relevant to the compilation process. - */ -export type CompilerServiceOptions = Pick< - CompilerOptions, - 'hierarchical' | 'srcDir' | 'outDir' ->; - -/** Resolved options for CompilerService with defaults applied */ -type ResolvedCompilerServiceOptions = Required; - -/** - * 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; - - /** - * 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 = promisify(execCallback), - options: CompilerServiceOptions = {}, - ) { - this.execFn = execFn; - this.options = { - hierarchical: options.hierarchical ?? false, - srcDir: options.srcDir ?? DEFAULT_SRC_DIR, - outDir: options.outDir ?? DEFAULT_OUT_DIR, - }; - } - - /** - * 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 - 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(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 ? ` ${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}.`), - ); - }, -}; - /** * Main compiler class that orchestrates the compilation process. * Coordinates environment validation, file discovery, and compilation services @@ -531,6 +122,8 @@ export class CompactCompiler { private readonly fileDiscovery: FileDiscovery; /** Compilation execution service */ private readonly compilerService: CompilerService; + /** Manifest management service */ + private readonly manifestService: ManifestService; /** Compiler options */ private readonly options: ResolvedCompilerOptions; @@ -560,12 +153,13 @@ export class CompactCompiler { */ constructor(options: CompilerOptions = {}, execFn?: ExecFunction) { this.options = { - flags: (options.flags ?? '').trim(), + 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, }; this.environmentValidator = new EnvironmentValidator(execFn); this.fileDiscovery = new FileDiscovery(this.options.srcDir); @@ -574,6 +168,7 @@ export class CompactCompiler { srcDir: this.options.srcDir, outDir: this.options.outDir, }); + this.manifestService = new ManifestService(this.options.outDir); } /** @@ -599,6 +194,7 @@ export class CompactCompiler { ): CompilerOptions { const options: CompilerOptions = { hierarchical: false, + force: false, }; const flags: string[] = []; @@ -636,8 +232,10 @@ export class CompactCompiler { } } else if (args[i] === '--hierarchical') { options.hierarchical = true; + } else if (args[i] === '--force' || args[i] === '-f') { + options.force = true; } else if (args[i].startsWith('+')) { - options.version = args[i].slice(1); + options.version = args[i].slice(1) as CompactcVersion; } else { // Only add flag if it's not already present if (!flags.includes(args[i])) { @@ -646,7 +244,7 @@ export class CompactCompiler { } } - options.flags = flags.join(' '); + options.flags = flags as CompilerFlag[]; return options; } @@ -731,15 +329,19 @@ export class CompactCompiler { * } * ``` */ - async validateEnvironment(): Promise { - const { devToolsVersion, toolchainVersion } = + async validateEnvironment(): Promise<{ + compactToolVersion: CompactToolVersion; + compactcVersion: CompactcVersion; + }> { + const { compactToolVersion, compactcVersion } = await this.environmentValidator.validate(this.options.version); UIService.displayEnvInfo( - devToolsVersion, - toolchainVersion, + compactToolVersion, + compactcVersion, this.options.targetDir, this.options.version, ); + return { compactToolVersion, compactcVersion }; } /** @@ -771,7 +373,35 @@ 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.options.targetDir ? join(this.options.srcDir, this.options.targetDir) @@ -794,9 +424,59 @@ export class CompactCompiler { UIService.showCompilationStart(compactFiles.length, this.options.targetDir); + // Track artifacts: hierarchical uses Record, flattened uses string[] + const hierarchicalArtifacts: Record = {}; + const flatArtifacts: string[] = []; + for (const [index, file] of compactFiles.entries()) { await this.compileFile(file, index, compactFiles.length); + // Extract artifact name from file path + const artifactName = basename(file, '.compact'); + + if (requestedStructure === 'hierarchical') { + // Get the subdirectory from the file path (e.g., 'ledger' from 'ledger/Counter.compact') + const subDir = dirname(file); + const category = subDir === '.' ? 'root' : subDir.split('/')[0]; + if (!hierarchicalArtifacts[category]) { + hierarchicalArtifacts[category] = []; + } + hierarchicalArtifacts[category].push(artifactName); + } else { + flatArtifacts.push(artifactName); + } } + + // 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, + }); } /** @@ -855,6 +535,25 @@ export class CompactCompiler { } } + /** + * Cleans the output directory by removing all artifacts. + * Used when user confirms deletion after structure mismatch. + */ + async cleanOutputDirectory(): Promise { + await this.manifestService.cleanOutputDirectory(); + } + + /** + * 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 */ diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..f30f45a --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,60 @@ +/** + * 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]; + +/** + * 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 + */ +export 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; +} diff --git a/packages/cli/src/runCompiler.ts b/packages/cli/src/runCompiler.ts index fda1a77..c8dfb0d 100644 --- a/packages/cli/src/runCompiler.ts +++ b/packages/cli/src/runCompiler.ts @@ -1,8 +1,10 @@ #!/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'; +import { StructureMismatchError } from './types/manifest.ts'; import { type CompilationError, isPromisifiedChildProcessError, @@ -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. * @@ -185,6 +266,11 @@ function showUsageHelp(): void { ' --hierarchical Preserve source directory structure in artifacts output', ), ); + console.log( + chalk.yellow( + ' --force, -f Force delete existing artifacts on structure mismatch', + ), + ); console.log( chalk.yellow(' --skip-zk Skip zero-knowledge proof generation'), ); diff --git a/packages/cli/src/services/CompilerService.ts b/packages/cli/src/services/CompilerService.ts new file mode 100644 index 0000000..aaf9134 --- /dev/null +++ b/packages/cli/src/services/CompilerService.ts @@ -0,0 +1,128 @@ +import { exec as execCallback } 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 { 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; +} + +/** Resolved options for CompilerService with defaults applied */ +type ResolvedCompilerServiceOptions = Required; + +/** + * 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; + + /** + * 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 = promisify(execCallback), + options: CompilerServiceOptions = {}, + ) { + this.execFn = execFn; + this.options = { + hierarchical: options.hierarchical ?? false, + srcDir: options.srcDir ?? DEFAULT_SRC_DIR, + outDir: options.outDir ?? DEFAULT_OUT_DIR, + }; + } + + /** + * 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 (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: CompilerFlag[], + version?: string, + ): Promise<{ stdout: string; stderr: string }> { + const inputPath = join(this.options.srcDir, file); + const fileDir = dirname(file); + const fileName = basename(file, '.compact'); + + // Flattened (default): // + // 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 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); + } + + throw new CompilationError( + `Failed to compile ${file}: ${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..c69d413 --- /dev/null +++ b/packages/cli/src/services/EnvironmentValidator.ts @@ -0,0 +1,165 @@ +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, + compareVersions, + MAX_COMPACTC_VERSION, + MIN_COMPACT_TOOL_VERSION, +} from '../config.ts'; +import { CompactCliNotFoundError } from '../types/errors.ts'; + +/** + * 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/manifest.ts b/packages/cli/src/types/manifest.ts new file mode 100644 index 0000000..0464c90 --- /dev/null +++ b/packages/cli/src/types/manifest.ts @@ -0,0 +1,130 @@ +// Import and re-export version types from config (single source of truth) +import type { CompactcVersion, CompactToolVersion } from '../config.ts'; + +/** + * Hierarchical artifacts organized by subdirectory. + * Keys are subdirectory names, values are arrays of artifact names. + * + * @example + * ```typescript + * const artifacts: HierarchicalArtifacts = { + * ledger: ['Counter'], + * reference: ['Boolean', 'Field', 'Uint'] + * }; + * ``` + */ +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: object with subdirectory keys and artifact name arrays + */ + 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 3913df8..ec73598 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,22 @@ import { type MockedFunction, vi, } from 'vitest'; +import { CompactCompiler } from '../src/Compiler.js'; +import { CompilerService } from '../src/services/CompilerService.js'; import { - CompactCompiler, - CompilerService, 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 { StructureMismatchError } from '../src/types/manifest.js'; import { CompactCliNotFoundError, CompilationError, DirectoryNotFoundError, } from '../src/types/errors.js'; +import { MANIFEST_FILENAME } from '../src/types/manifest.js'; // Mock Node.js modules vi.mock('node:fs'); @@ -51,6 +54,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 +88,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,7 +263,7 @@ 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(mockExec).toHaveBeenCalledWith( @@ -273,7 +279,7 @@ describe('CompilerService', () => { const result = await service.compileFile( 'MyToken.compact', - '--skip-zk', + ['--skip-zk'], '0.26.0', ); @@ -289,7 +295,7 @@ describe('CompilerService', () => { stderr: '', }); - const result = await service.compileFile('MyToken.compact', ''); + const result = await service.compileFile('MyToken.compact', []); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); expect(mockExec).toHaveBeenCalledWith( @@ -305,7 +311,7 @@ describe('CompilerService', () => { const result = await service.compileFile( 'access/AccessControl.compact', - '--skip-zk', + ['--skip-zk'], ); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); @@ -322,7 +328,7 @@ describe('CompilerService', () => { const result = await service.compileFile( 'access/test/AccessControl.mock.compact', - '--skip-zk', + ['--skip-zk'], ); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); @@ -335,7 +341,7 @@ describe('CompilerService', () => { 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); }); @@ -343,7 +349,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'); @@ -355,7 +361,7 @@ 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); @@ -376,7 +382,7 @@ describe('CompilerService', () => { const result = await service.compileFile( 'access/AccessControl.compact', - '--skip-zk', + ['--skip-zk'], ); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); @@ -393,7 +399,7 @@ describe('CompilerService', () => { const result = await service.compileFile( 'access/test/AccessControl.mock.compact', - '--skip-zk', + ['--skip-zk'], ); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); @@ -408,7 +414,7 @@ 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(mockExec).toHaveBeenCalledWith( @@ -431,7 +437,7 @@ 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(mockExec).toHaveBeenCalledWith( @@ -452,7 +458,7 @@ describe('CompilerService', () => { const result = await service.compileFile( 'access/AccessControl.compact', - '--skip-zk', + ['--skip-zk'], ); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); @@ -495,41 +501,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'), ); }); }); @@ -587,7 +588,7 @@ describe('CompactCompiler', () => { compiler = new CompactCompiler(); expect(compiler).toBeInstanceOf(CompactCompiler); - expect(compiler.testOptions.flags).toBe(''); + expect(compiler.testOptions.flags).toEqual([]); expect(compiler.testOptions.targetDir).toBeUndefined(); expect(compiler.testOptions.version).toBeUndefined(); expect(compiler.testOptions.hierarchical).toBe(false); @@ -598,7 +599,7 @@ describe('CompactCompiler', () => { it('should create instance with all parameters', () => { compiler = new CompactCompiler( { - flags: '--skip-zk', + flags: ['--skip-zk'], targetDir: 'security', version: '0.26.0', hierarchical: true, @@ -609,7 +610,7 @@ describe('CompactCompiler', () => { ); expect(compiler).toBeInstanceOf(CompactCompiler); - expect(compiler.testOptions.flags).toBe('--skip-zk'); + 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); @@ -617,9 +618,9 @@ describe('CompactCompiler', () => { expect(compiler.testOptions.outDir).toBe('build'); }); - it('should trim flags', () => { - compiler = new CompactCompiler({ flags: ' --skip-zk --verbose ' }); - expect(compiler.testOptions.flags).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']); }); }); @@ -627,7 +628,7 @@ describe('CompactCompiler', () => { it('should parse empty arguments', () => { compiler = CompactCompiler.fromArgs([]); - expect(compiler.testOptions.flags).toBe(''); + expect(compiler.testOptions.flags).toEqual([]); expect(compiler.testOptions.targetDir).toBeUndefined(); expect(compiler.testOptions.version).toBeUndefined(); expect(compiler.testOptions.hierarchical).toBe(false); @@ -636,20 +637,20 @@ describe('CompactCompiler', () => { it('should handle SKIP_ZK environment variable', () => { compiler = CompactCompiler.fromArgs([], { SKIP_ZK: 'true' }); - expect(compiler.testOptions.flags).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.testOptions.flags).toBe(''); + expect(compiler.testOptions.flags).toEqual([]); }); it('should parse --dir flag', () => { compiler = CompactCompiler.fromArgs(['--dir', 'security']); expect(compiler.testOptions.targetDir).toBe('security'); - expect(compiler.testOptions.flags).toBe(''); + expect(compiler.testOptions.flags).toEqual([]); }); it('should parse --dir flag with additional flags', () => { @@ -657,18 +658,18 @@ describe('CompactCompiler', () => { '--dir', 'security', '--skip-zk', - '--verbose', + '--trace-passes', ]); expect(compiler.testOptions.targetDir).toBe('security'); - expect(compiler.testOptions.flags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk', '--trace-passes']); }); it('should parse version flag', () => { compiler = CompactCompiler.fromArgs(['+0.26.0']); expect(compiler.testOptions.version).toBe('0.26.0'); - expect(compiler.testOptions.flags).toBe(''); + expect(compiler.testOptions.flags).toEqual([]); }); it('should parse complex arguments', () => { @@ -676,30 +677,30 @@ describe('CompactCompiler', () => { '--dir', 'security', '--skip-zk', - '--verbose', + '--trace-passes', '+0.26.0', ]); expect(compiler.testOptions.targetDir).toBe('security'); - expect(compiler.testOptions.flags).toBe('--skip-zk --verbose'); + 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'], { + compiler = CompactCompiler.fromArgs(['--dir', 'access', '--trace-passes'], { SKIP_ZK: 'true', }); expect(compiler.testOptions.targetDir).toBe('access'); - expect(compiler.testOptions.flags).toBe('--skip-zk --verbose'); + 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.testOptions.flags).toBe('--skip-zk --verbose'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk', '--trace-passes']); }); it('should throw error for --dir without argument', () => { @@ -718,7 +719,7 @@ describe('CompactCompiler', () => { compiler = CompactCompiler.fromArgs(['--hierarchical']); expect(compiler.testOptions.hierarchical).toBe(true); - expect(compiler.testOptions.flags).toBe(''); + expect(compiler.testOptions.flags).toEqual([]); }); it('should parse --hierarchical flag with other options', () => { @@ -732,7 +733,7 @@ describe('CompactCompiler', () => { expect(compiler.testOptions.hierarchical).toBe(true); expect(compiler.testOptions.targetDir).toBe('security'); - expect(compiler.testOptions.flags).toBe('--skip-zk'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); expect(compiler.testOptions.version).toBe('0.26.0'); }); @@ -765,7 +766,7 @@ describe('CompactCompiler', () => { expect(compiler.testOptions.srcDir).toBe('contracts'); expect(compiler.testOptions.outDir).toBe('dist/artifacts'); - expect(compiler.testOptions.flags).toBe('--skip-zk'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); }); it('should use default srcDir and outDir when not specified', () => { @@ -812,7 +813,7 @@ describe('CompactCompiler', () => { compiler = new CompactCompiler( { - flags: '--skip-zk', + flags: ['--skip-zk'], targetDir: 'security', version: '0.26.0', }, @@ -976,7 +977,7 @@ describe('CompactCompiler', () => { }, ]; mockReaddir.mockResolvedValue(mockDirents as any); - compiler = new CompactCompiler({ flags: '--skip-zk' }, mockExec); + compiler = new CompactCompiler({ flags: ['--skip-zk'] }, mockExec); await compiler.compile(); @@ -1037,27 +1038,27 @@ describe('CompactCompiler', () => { it('should handle turbo compact command', () => { compiler = CompactCompiler.fromArgs([]); - expect(compiler.testOptions.flags).toBe(''); + 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.testOptions.flags).toBe('--skip-zk'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); }); it('should handle turbo compact:access command', () => { compiler = CompactCompiler.fromArgs(['--dir', 'access']); - expect(compiler.testOptions.flags).toBe(''); + 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.testOptions.flags).toBe('--skip-zk'); + expect(compiler.testOptions.flags).toEqual(['--skip-zk']); expect(compiler.testOptions.targetDir).toBe('security'); }); @@ -1103,11 +1104,201 @@ describe('CompactCompiler', () => { ])('should handle complex command $name', ({ args, env }) => { compiler = CompactCompiler.fromArgs(args, env); - expect(compiler.testOptions.flags).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}`, + ); + }); + + 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'); }); }); diff --git a/packages/cli/test/runCompiler.test.ts b/packages/cli/test/runCompiler.test.ts index 58f064a..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 () => { From 24aae2fa3badd14f02626b507ba3cf63452677f3 Mon Sep 17 00:00:00 2001 From: 0xisk Date: Thu, 11 Dec 2025 13:24:15 +0100 Subject: [PATCH 2/7] fix: clean the config --- packages/cli/src/config.ts | 19 ----- packages/cli/src/runCompiler.ts | 2 +- .../cli/src/services/EnvironmentValidator.ts | 20 ++++- packages/cli/test/Compiler.test.ts | 77 ++++++++++++------- 4 files changed, 71 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index f30f45a..2b6b23c 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -39,22 +39,3 @@ export type CompactcVersion = (typeof COMPACTC_VERSIONS)[number]; /** Type derived from supported compact-tools versions */ export type CompactToolVersion = (typeof COMPACT_TOOL_VERSIONS)[number]; - -/** - * 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 - */ -export 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; -} diff --git a/packages/cli/src/runCompiler.ts b/packages/cli/src/runCompiler.ts index c8dfb0d..83cf782 100644 --- a/packages/cli/src/runCompiler.ts +++ b/packages/cli/src/runCompiler.ts @@ -4,11 +4,11 @@ import * as readline from 'node:readline'; import chalk from 'chalk'; import ora, { type Ora } from 'ora'; import { CompactCompiler } from './Compiler.ts'; -import { StructureMismatchError } from './types/manifest.ts'; 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. diff --git a/packages/cli/src/services/EnvironmentValidator.ts b/packages/cli/src/services/EnvironmentValidator.ts index c69d413..1b81108 100644 --- a/packages/cli/src/services/EnvironmentValidator.ts +++ b/packages/cli/src/services/EnvironmentValidator.ts @@ -5,12 +5,30 @@ import ora from 'ora'; import { type CompactcVersion, type CompactToolVersion, - compareVersions, 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. diff --git a/packages/cli/test/Compiler.test.ts b/packages/cli/test/Compiler.test.ts index ec73598..b1656d3 100644 --- a/packages/cli/test/Compiler.test.ts +++ b/packages/cli/test/Compiler.test.ts @@ -17,13 +17,15 @@ import { import { FileDiscovery } from '../src/services/FileDiscovery.js'; import { ManifestService } from '../src/services/ManifestService.js'; import { UIService } from '../src/services/UIService.js'; -import { StructureMismatchError } from '../src/types/manifest.js'; import { CompactCliNotFoundError, CompilationError, DirectoryNotFoundError, } from '../src/types/errors.js'; -import { MANIFEST_FILENAME } from '../src/types/manifest.js'; +import { + MANIFEST_FILENAME, + StructureMismatchError, +} from '../src/types/manifest.js'; // Mock Node.js modules vi.mock('node:fs'); @@ -263,7 +265,9 @@ 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(mockExec).toHaveBeenCalledWith( @@ -309,10 +313,9 @@ describe('CompilerService', () => { stderr: '', }); - const result = await service.compileFile( - 'access/AccessControl.compact', - ['--skip-zk'], - ); + const result = await service.compileFile('access/AccessControl.compact', [ + '--skip-zk', + ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); expect(mockExec).toHaveBeenCalledWith( @@ -380,10 +383,9 @@ describe('CompilerService', () => { stderr: '', }); - const result = await service.compileFile( - 'access/AccessControl.compact', - ['--skip-zk'], - ); + const result = await service.compileFile('access/AccessControl.compact', [ + '--skip-zk', + ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); expect(mockExec).toHaveBeenCalledWith( @@ -414,7 +416,9 @@ 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(mockExec).toHaveBeenCalledWith( @@ -437,7 +441,9 @@ 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(mockExec).toHaveBeenCalledWith( @@ -456,10 +462,9 @@ describe('CompilerService', () => { stderr: '', }); - const result = await service.compileFile( - 'access/AccessControl.compact', - ['--skip-zk'], - ); + const result = await service.compileFile('access/AccessControl.compact', [ + '--skip-zk', + ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); expect(mockExec).toHaveBeenCalledWith( @@ -619,8 +624,13 @@ describe('CompactCompiler', () => { }); it('should handle flags array', () => { - compiler = new CompactCompiler({ flags: ['--skip-zk', '--trace-passes'] }); - expect(compiler.testOptions.flags).toEqual(['--skip-zk', '--trace-passes']); + compiler = new CompactCompiler({ + flags: ['--skip-zk', '--trace-passes'], + }); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); }); }); @@ -662,7 +672,10 @@ describe('CompactCompiler', () => { ]); expect(compiler.testOptions.targetDir).toBe('security'); - expect(compiler.testOptions.flags).toEqual(['--skip-zk', '--trace-passes']); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); }); it('should parse version flag', () => { @@ -682,17 +695,26 @@ describe('CompactCompiler', () => { ]); expect(compiler.testOptions.targetDir).toBe('security'); - expect(compiler.testOptions.flags).toEqual(['--skip-zk', '--trace-passes']); + 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', '--trace-passes'], { - SKIP_ZK: 'true', - }); + compiler = CompactCompiler.fromArgs( + ['--dir', 'access', '--trace-passes'], + { + SKIP_ZK: 'true', + }, + ); expect(compiler.testOptions.targetDir).toBe('access'); - expect(compiler.testOptions.flags).toEqual(['--skip-zk', '--trace-passes']); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); }); it('should deduplicate flags when both env var and CLI flag are present', () => { @@ -700,7 +722,10 @@ describe('CompactCompiler', () => { SKIP_ZK: 'true', }); - expect(compiler.testOptions.flags).toEqual(['--skip-zk', '--trace-passes']); + expect(compiler.testOptions.flags).toEqual([ + '--skip-zk', + '--trace-passes', + ]); }); it('should throw error for --dir without argument', () => { From bd6b5aaeb2a46137b3f111b24c7b56ffc383ee76 Mon Sep 17 00:00:00 2001 From: 0xisk Date: Mon, 15 Dec 2025 11:20:41 +0100 Subject: [PATCH 3/7] chore: compact format --- .../sample-contracts/SampleZOwnable.compact | 29 +++++++------------ .../fixtures/sample-contracts/Simple.compact | 1 + .../fixtures/sample-contracts/Witness.compact | 5 ++++ 3 files changed, 16 insertions(+), 19 deletions(-) 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(): [] { From 95c03e0427722d739a800e1cd6d8b5e3b1bdb7af Mon Sep 17 00:00:00 2001 From: 0xisk Date: Mon, 15 Dec 2025 13:51:45 +0100 Subject: [PATCH 4/7] refactor: recommended changes coderabbitai --- packages/cli/src/Compiler.ts | 20 ++- packages/cli/src/config.ts | 4 +- packages/cli/src/services/CompilerService.ts | 68 +++++-- .../cli/src/services/EnvironmentValidator.ts | 8 +- packages/cli/src/services/FileDiscovery.ts | 87 ++++++++- packages/cli/src/services/UIService.ts | 6 +- packages/cli/test/Compiler.test.ts | 168 ++++++++++++------ 7 files changed, 280 insertions(+), 81 deletions(-) diff --git a/packages/cli/src/Compiler.ts b/packages/cli/src/Compiler.ts index c75ba10..4c13679 100755 --- a/packages/cli/src/Compiler.ts +++ b/packages/cli/src/Compiler.ts @@ -10,7 +10,10 @@ import { DEFAULT_OUT_DIR, DEFAULT_SRC_DIR, } from './config.ts'; -import { CompilerService } from './services/CompilerService.ts'; +import { + CompilerService, + type ExecFileFunction, +} from './services/CompilerService.ts'; import { EnvironmentValidator, type ExecFunction, @@ -135,7 +138,7 @@ export class CompactCompiler { * @example * ```typescript * // Compile all files with flags (flattened artifacts) - * const compiler = new CompactCompiler({ flags: '--skip-zk --verbose' }); + * const compiler = new CompactCompiler({ flags: '--skip-zk --trace-passes' }); * * // Compile specific directory * const compiler = new CompactCompiler({ targetDir: 'security' }); @@ -163,7 +166,18 @@ export class CompactCompiler { }; this.environmentValidator = new EnvironmentValidator(execFn); this.fileDiscovery = new FileDiscovery(this.options.srcDir); - this.compilerService = new CompilerService(execFn, { + + // Convert ExecFunction to ExecFileFunction if provided + // If execFn is provided, create an adapter; otherwise use default execFile + const execFileFn: ExecFileFunction | undefined = execFn + ? async (command: string, args: string[]) => { + // Convert execFile-style call to exec-style command string + const commandStr = `${command} ${args.join(' ')}`; + return execFn(commandStr); + } + : undefined; + + this.compilerService = new CompilerService(execFileFn, { hierarchical: this.options.hierarchical, srcDir: this.options.srcDir, outDir: this.options.outDir, diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 2b6b23c..2c26380 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -26,8 +26,8 @@ export const COMPACTC_VERSIONS = [ */ 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 = +/** Latest supported compact-tools version */ +export const LATEST_COMPACT_TOOL_VERSION = COMPACT_TOOL_VERSIONS[COMPACT_TOOL_VERSIONS.length - 1]; /** Maximum supported compactc version */ diff --git a/packages/cli/src/services/CompilerService.ts b/packages/cli/src/services/CompilerService.ts index aaf9134..7d239d9 100644 --- a/packages/cli/src/services/CompilerService.ts +++ b/packages/cli/src/services/CompilerService.ts @@ -1,10 +1,10 @@ -import { exec as execCallback } from 'node:child_process'; -import { basename, dirname, join } from 'node:path'; +import { execFile as execFileCallback } from 'node:child_process'; +import { basename, dirname, join, normalize, resolve } from 'node:path'; import { promisify } from 'node:util'; import { DEFAULT_OUT_DIR, DEFAULT_SRC_DIR } from '../config.ts'; import { CompilationError } from '../types/errors.ts'; import type { CompilerFlag } from '../types/manifest.ts'; -import type { ExecFunction } from './EnvironmentValidator.ts'; +import { FileDiscovery } from './FileDiscovery.ts'; /** * Options for configuring the CompilerService. @@ -21,6 +21,19 @@ export interface CompilerServiceOptions { /** Resolved options for CompilerService with defaults applied */ type ResolvedCompilerServiceOptions = Required; +/** + * Function type for executing commands with arguments array (non-shell execution). + * Allows dependency injection for testing and customization. + * + * @param command - The command to execute (e.g., 'compact') + * @param args - Array of command arguments + * @returns Promise resolving to command output + */ +export type ExecFileFunction = ( + command: string, + args: string[], +) => Promise<{ stdout: string; stderr: string }>; + /** * Service responsible for compiling individual .compact files. * Handles command construction, execution, and error processing. @@ -31,32 +44,42 @@ type ResolvedCompilerServiceOptions = Required; * const compiler = new CompilerService(); * const result = await compiler.compileFile( * 'contracts/Token.compact', - * ['--skip-zk', '--verbose'], + * ['--skip-zk', '--trace-passes'], * '0.26.0' * ); * console.log('Compilation output:', result.stdout); * ``` */ export class CompilerService { - private execFn: ExecFunction; + private execFileFn: ExecFileFunction; private options: ResolvedCompilerServiceOptions; + private pathValidator: FileDiscovery; /** * Creates a new CompilerService instance. * - * @param execFn - Function to execute shell commands (defaults to promisified child_process.exec) + * @param execFileFn - Function to execute commands with args array (defaults to promisified child_process.execFile) * @param options - Compiler service options + * @param pathValidator - Optional FileDiscovery instance for path validation (creates new one if not provided) */ constructor( - execFn: ExecFunction = promisify(execCallback), + execFileFn?: ExecFileFunction, options: CompilerServiceOptions = {}, + pathValidator?: FileDiscovery, ) { - this.execFn = execFn; + // Default to promisified execFile for safe non-shell execution + this.execFileFn = + execFileFn ?? + ((command: string, args: string[]) => + promisify(execFileCallback)(command, args)); this.options = { hierarchical: options.hierarchical ?? false, srcDir: options.srcDir ?? DEFAULT_SRC_DIR, outDir: options.outDir ?? DEFAULT_OUT_DIR, }; + // Use FileDiscovery for path validation (defense in depth - paths should already be validated during discovery) + this.pathValidator = + pathValidator ?? new FileDiscovery(this.options.srcDir); } /** @@ -67,7 +90,7 @@ export class CompilerService { * 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 flags - Array of compiler flags (e.g., ['--skip-zk', '--trace-passes']) * @param version - Optional specific toolchain version to use * @returns Promise resolving to compilation output (stdout/stderr) * @throws {CompilationError} If compilation fails for any reason @@ -103,12 +126,31 @@ export class CompilerService { ? join(this.options.outDir, fileDir, fileName) : join(this.options.outDir, fileName); - const versionFlag = version ? `+${version}` : ''; - const flagsStr = flags.length > 0 ? ` ${flags.join(' ')}` : ''; - const command = `compact compile${versionFlag ? ` ${versionFlag}` : ''}${flagsStr} "${inputPath}" "${outputDir}"`; + // Validate and normalize input path to prevent command injection + const validatedInputPath = this.pathValidator.validateAndNormalizePath( + inputPath, + this.options.srcDir, + ); + + // Normalize output directory path (no need to validate existence, it will be created) + const normalizedOutputDir = normalize(resolve(outputDir)); + + // Construct args array for execFile (non-shell execution) + const args: string[] = ['compile']; + + // Add version flag if specified + if (version) { + args.push(`+${version}`); + } + + // Add compiler flags + args.push(...flags); + + // Add input and output paths + args.push(validatedInputPath, normalizedOutputDir); try { - return await this.execFn(command); + return await this.execFileFn('compact', args); } catch (error: unknown) { let message: string; diff --git a/packages/cli/src/services/EnvironmentValidator.ts b/packages/cli/src/services/EnvironmentValidator.ts index 1b81108..7ffbc17 100644 --- a/packages/cli/src/services/EnvironmentValidator.ts +++ b/packages/cli/src/services/EnvironmentValidator.ts @@ -5,8 +5,8 @@ import ora from 'ora'; import { type CompactcVersion, type CompactToolVersion, + LATEST_COMPACT_TOOL_VERSION, MAX_COMPACTC_VERSION, - MIN_COMPACT_TOOL_VERSION, } from '../config.ts'; import { CompactCliNotFoundError } from '../types/errors.ts'; @@ -155,13 +155,13 @@ export class EnvironmentValidator { 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) { + // Warn if compact-tools version is older than latest + if (compareVersions(compactToolVersion, LATEST_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.`, + `Run 'compact self update' to update to ${LATEST_COMPACT_TOOL_VERSION} or later.`, ), ); } diff --git a/packages/cli/src/services/FileDiscovery.ts b/packages/cli/src/services/FileDiscovery.ts index 139be39..4461c01 100644 --- a/packages/cli/src/services/FileDiscovery.ts +++ b/packages/cli/src/services/FileDiscovery.ts @@ -1,6 +1,8 @@ +import { existsSync, realpathSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { join, relative } from 'node:path'; +import { join, normalize, relative, resolve } from 'node:path'; import { DEFAULT_SRC_DIR } from '../config.ts'; +import { CompilationError } from '../types/errors.ts'; /** * Service responsible for discovering .compact files in the source directory. @@ -26,12 +28,85 @@ export class FileDiscovery { this.srcDir = srcDir; } + /** + * Validates and normalizes a file path to prevent command injection. + * Ensures the path exists, resolves symlinks, and is within allowed directories. + * + * @param filePath - The file path to validate + * @param allowedBaseDir - Base directory that the path must be within + * @returns Normalized absolute path + * @throws {CompilationError} If path is invalid or contains unsafe characters + * @example + * ```typescript + * const discovery = new FileDiscovery('src'); + * const safePath = discovery.validateAndNormalizePath( + * 'src/MyToken.compact', + * 'src' + * ); + * ``` + */ + validateAndNormalizePath(filePath: string, allowedBaseDir: string): string { + // Normalize the path to resolve '..' and '.' segments + const normalized = normalize(filePath); + + // Check for shell metacharacters and embedded quotes + if (/[;&|`$(){}[\]<>'"\\]/.test(normalized)) { + throw new CompilationError( + `Invalid file path: contains unsafe characters: ${filePath}`, + filePath, + ); + } + + // Resolve to absolute path + const absolutePath = resolve(normalized); + + // Ensure path exists + if (!existsSync(absolutePath)) { + throw new CompilationError( + `File path does not exist: ${filePath}`, + filePath, + ); + } + + // Resolve symlinks to get real path + let realPath: string; + try { + realPath = realpathSync(absolutePath); + } catch { + throw new CompilationError( + `Failed to resolve file path: ${filePath}`, + filePath, + ); + } + + // Ensure path is within allowed base directory + const allowedBase = resolve(allowedBaseDir); + const relativePath = relative(allowedBase, realPath); + + // Check if path is outside allowed directory + // relative() returns paths starting with '..' if outside, or absolute paths on some systems + if ( + relativePath.startsWith('..') || + relativePath.startsWith('/') || + (relativePath.length > 1 && relativePath[1] === ':') // Windows absolute path (C:) + ) { + throw new CompilationError( + `File path is outside allowed directory: ${filePath}`, + filePath, + ); + } + + return realPath; + } + /** * Recursively discovers all .compact files in a directory. * Returns relative paths from the srcDir for consistent processing. + * Validates paths during discovery to prevent command injection. * * @param dir - Directory path to search (relative or absolute) * @returns Promise resolving to array of relative file paths + * @throws {CompilationError} If a discovered file path contains unsafe characters * @example * ```typescript * const files = await discovery.getCompactFiles('src'); @@ -49,10 +124,16 @@ export class FileDiscovery { } if (entry.isFile() && fullPath.endsWith('.compact')) { + // Validate path during discovery to prevent command injection + this.validateAndNormalizePath(fullPath, this.srcDir); return [relative(this.srcDir, fullPath)]; } return []; } catch (err) { + // If validation fails, throw the error (don't silently skip) + if (err instanceof CompilationError) { + throw err; + } // biome-ignore lint/suspicious/noConsole: Needed to display error and file path console.warn(`Error accessing ${fullPath}:`, err); return []; @@ -62,6 +143,10 @@ export class FileDiscovery { const results = await Promise.all(filePromises); return results.flat(); } catch (err) { + // If it's a validation error, re-throw it + if (err instanceof CompilationError) { + throw 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/UIService.ts b/packages/cli/src/services/UIService.ts index 6a510a9..87c2aed 100644 --- a/packages/cli/src/services/UIService.ts +++ b/packages/cli/src/services/UIService.ts @@ -95,13 +95,15 @@ export const UIService = { * ```typescript * UIService.showNoFiles('security'); * // Output: "No .compact files found in security/." + * UIService.showNoFiles(); + * // Output: "No .compact files found." * ``` */ showNoFiles(targetDir?: string): void { - const searchLocation = targetDir ? `${targetDir}/` : ''; + const searchLocation = targetDir ? ` in ${targetDir}/` : ''; const spinner = ora(); spinner.warn( - chalk.yellow(`[COMPILE] No .compact files found in ${searchLocation}.`), + chalk.yellow(`[COMPILE] No .compact files found${searchLocation}.`), ); }, }; diff --git a/packages/cli/test/Compiler.test.ts b/packages/cli/test/Compiler.test.ts index b1656d3..fbd09cc 100644 --- a/packages/cli/test/Compiler.test.ts +++ b/packages/cli/test/Compiler.test.ts @@ -1,5 +1,6 @@ -import { existsSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; import { readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { beforeEach, describe, @@ -9,7 +10,10 @@ import { vi, } from 'vitest'; import { CompactCompiler } from '../src/Compiler.js'; -import { CompilerService } from '../src/services/CompilerService.js'; +import { + CompilerService, + type ExecFileFunction, +} from '../src/services/CompilerService.js'; import { EnvironmentValidator, type ExecFunction, @@ -55,6 +59,7 @@ vi.mock('ora', () => ({ })); const mockExistsSync = vi.mocked(existsSync); +const mockRealpathSync = vi.mocked(realpathSync); const mockReaddir = vi.mocked(readdir); const mockReadFile = vi.mocked(readFile); const mockWriteFile = vi.mocked(writeFile); @@ -160,6 +165,9 @@ describe('FileDiscovery', () => { beforeEach(() => { vi.clearAllMocks(); discovery = new FileDiscovery(); + // Mock file system functions for path validation + mockExistsSync.mockReturnValue(true); + mockRealpathSync.mockImplementation((path) => resolve(String(path))); }); describe('getCompactFiles', () => { @@ -240,6 +248,10 @@ describe('FileDiscovery', () => { ]; mockReaddir.mockResolvedValue(mockDirents as any); + // Mock existsSync to return false for MyToken (access denied) and true for Ownable + mockExistsSync.mockImplementation((path) => { + return String(path).includes('Ownable.compact'); + }); const files = await discovery.getCompactFiles('src'); @@ -249,18 +261,22 @@ describe('FileDiscovery', () => { }); describe('CompilerService', () => { - let mockExec: MockedFunction; + let mockExecFile: MockedFunction; let service: CompilerService; beforeEach(() => { vi.clearAllMocks(); - mockExec = vi.fn(); - service = new CompilerService(mockExec); + mockExecFile = vi.fn(); + // Mock file system functions for path validation + // These are used by FileDiscovery.validateAndNormalizePath + mockExistsSync.mockReturnValue(true); + mockRealpathSync.mockImplementation((path) => resolve(String(path))); + service = new CompilerService(mockExecFile); }); describe('compileFile', () => { it('should compile file successfully with basic flags', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -270,13 +286,16 @@ describe('CompilerService', () => { ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "src/MyToken.compact" "artifacts/MyToken"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '--skip-zk', + expect.stringContaining('MyToken.compact'), + expect.stringContaining('MyToken'), + ]); }); it('should compile file with version flag', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -288,13 +307,17 @@ describe('CompilerService', () => { ); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile +0.26.0 --skip-zk "src/MyToken.compact" "artifacts/MyToken"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '+0.26.0', + '--skip-zk', + expect.stringContaining('MyToken.compact'), + expect.stringContaining('MyToken'), + ]); }); it('should handle empty flags', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -302,13 +325,15 @@ describe('CompilerService', () => { const result = await service.compileFile('MyToken.compact', []); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile "src/MyToken.compact" "artifacts/MyToken"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + expect.stringContaining('MyToken.compact'), + expect.stringContaining('MyToken'), + ]); }); it('should use flattened artifacts output by default', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -318,13 +343,16 @@ describe('CompilerService', () => { ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "src/access/AccessControl.compact" "artifacts/AccessControl"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '--skip-zk', + expect.stringContaining('AccessControl.compact'), + expect.stringContaining('AccessControl'), + ]); }); it('should flatten nested directory structure by default', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -335,13 +363,16 @@ describe('CompilerService', () => { ); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "src/access/test/AccessControl.mock.compact" "artifacts/AccessControl.mock"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '--skip-zk', + expect.stringContaining('AccessControl.mock.compact'), + expect.stringContaining('AccessControl.mock'), + ]); }); it('should throw CompilationError when compilation fails', async () => { - mockExec.mockRejectedValue(new Error('Syntax error on line 10')); + mockExecFile.mockRejectedValue(new Error('Syntax error on line 10')); await expect( service.compileFile('MyToken.compact', ['--skip-zk']), @@ -349,7 +380,7 @@ describe('CompilerService', () => { }); it('should include file path in CompilationError', async () => { - mockExec.mockRejectedValue(new Error('Syntax error')); + mockExecFile.mockRejectedValue(new Error('Syntax error')); try { await service.compileFile('MyToken.compact', ['--skip-zk']); @@ -361,7 +392,7 @@ describe('CompilerService', () => { it('should include cause in CompilationError', async () => { const mockError = new Error('Syntax error'); - mockExec.mockRejectedValue(mockError); + mockExecFile.mockRejectedValue(mockError); try { await service.compileFile('MyToken.compact', ['--skip-zk']); @@ -374,11 +405,11 @@ describe('CompilerService', () => { describe('compileFile with hierarchical option', () => { beforeEach(() => { - service = new CompilerService(mockExec, { hierarchical: true }); + service = new CompilerService(mockExecFile, { hierarchical: true }); }); it('should preserve directory structure in artifacts output when hierarchical is true', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -388,13 +419,16 @@ describe('CompilerService', () => { ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "src/access/AccessControl.compact" "artifacts/access/AccessControl"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '--skip-zk', + expect.stringContaining('AccessControl.compact'), + expect.stringContaining('access/AccessControl'), + ]); }); it('should preserve nested directory structure when hierarchical is true', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -405,13 +439,16 @@ describe('CompilerService', () => { ); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "src/access/test/AccessControl.mock.compact" "artifacts/access/test/AccessControl.mock"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '--skip-zk', + expect.stringContaining('AccessControl.mock.compact'), + expect.stringContaining('access/test/AccessControl.mock'), + ]); }); it('should use flattened output for root-level files even when hierarchical is true', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -421,22 +458,25 @@ describe('CompilerService', () => { ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "src/MyToken.compact" "artifacts/MyToken"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '--skip-zk', + expect.stringContaining('MyToken.compact'), + expect.stringContaining('MyToken'), + ]); }); }); describe('compileFile with custom srcDir and outDir', () => { beforeEach(() => { - service = new CompilerService(mockExec, { + service = new CompilerService(mockExecFile, { srcDir: 'contracts', outDir: 'build', }); }); it('should use custom srcDir and outDir', async () => { - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -446,18 +486,21 @@ describe('CompilerService', () => { ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "contracts/MyToken.compact" "build/MyToken"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '--skip-zk', + expect.stringContaining('MyToken.compact'), + expect.stringContaining('MyToken'), + ]); }); it('should use custom directories with hierarchical option', async () => { - service = new CompilerService(mockExec, { + service = new CompilerService(mockExecFile, { srcDir: 'contracts', outDir: 'dist/artifacts', hierarchical: true, }); - mockExec.mockResolvedValue({ + mockExecFile.mockResolvedValue({ stdout: 'Compilation successful', stderr: '', }); @@ -467,9 +510,12 @@ describe('CompilerService', () => { ]); expect(result).toEqual({ stdout: 'Compilation successful', stderr: '' }); - expect(mockExec).toHaveBeenCalledWith( - 'compact compile --skip-zk "contracts/access/AccessControl.compact" "dist/artifacts/access/AccessControl"', - ); + expect(mockExecFile).toHaveBeenCalledWith('compact', [ + 'compile', + '--skip-zk', + expect.stringContaining('AccessControl.compact'), + expect.stringContaining('access/AccessControl'), + ]); }); }); }); @@ -571,7 +617,7 @@ describe('UIService', () => { UIService.showNoFiles(); expect(mockSpinner.warn).toHaveBeenCalledWith( - '[COMPILE] No .compact files found in .', + '[COMPILE] No .compact files found.', ); }); }); @@ -1002,13 +1048,23 @@ describe('CompactCompiler', () => { }, ]; mockReaddir.mockResolvedValue(mockDirents as any); + mockExistsSync.mockReturnValue(true); compiler = new CompactCompiler({ flags: ['--skip-zk'] }, mockExec); await compiler.compile(); - expect(mockExec).toHaveBeenCalledWith( - expect.stringContaining('compact compile --skip-zk'), + // Check that mockExec was called with compile commands + // The adapter converts execFile calls back to string commands + // Filter out version check calls (which contain '--version') and only check actual compilation calls + const compileCalls = mockExec.mock.calls.filter( + (call) => + call[0]?.includes('compact compile') && + !call[0]?.includes('--version') && + call[0]?.includes('.compact'), // Actual compilation calls include file paths ); + expect(compileCalls.length).toBeGreaterThan(0); + expect(compileCalls[0][0]).toContain('compact compile'); + expect(compileCalls[0][0]).toContain('--skip-zk'); }); it('should handle compilation errors gracefully', async () => { @@ -1204,7 +1260,7 @@ describe('ManifestService', () => { it('should return manifest when it exists', async () => { const manifest = { structure: 'flattened', - toolchainVersion: '0.26.0', + compactcVersion: '0.26.0' as const, createdAt: '2025-12-11T12:00:00Z', artifacts: ['Token', 'AccessControl'], }; @@ -1239,7 +1295,7 @@ describe('ManifestService', () => { it('should write manifest to file', async () => { const manifest = { structure: 'hierarchical' as const, - toolchainVersion: '0.26.0', + compactcVersion: '0.26.0' as const, createdAt: '2025-12-11T12:00:00Z', artifacts: ['Token'], }; From 4610b37b8d6ec2f7d4c93e08667f15b6aeea9543 Mon Sep 17 00:00:00 2001 From: 0xisk Date: Mon, 22 Dec 2025 14:59:58 +0100 Subject: [PATCH 5/7] docs: update cli readme --- packages/cli/README.md | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 60ab235..afaf595 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -110,7 +110,46 @@ compact-compiler --hierarchical --force ### Manifest File -The compiler generates a `manifest.json` in the output directory with build metadata: +The compiler generates a `manifest.json` in the output directory containing build metadata and artifact information. This file is used for structure mismatch detection and build reproducibility. + +#### Manifest Fields + +| Field | Type | Description | +|-------|------|-------------| +| `structure` | `"flattened"` \| `"hierarchical"` | Artifact output structure used during compilation | +| `compactcVersion` | `CompactcVersion` | Supported compiler version (e.g., `"0.25.0"` \| `"0.26.0"`) | +| `compactToolVersion` | `CompactToolVersion` | Supported CLI version (e.g., `"0.2.0"` \| `"0.3.0"`) | +| `createdAt` | `string` | ISO 8601 timestamp (e.g., `"2025-12-11T10:09:46.023Z"`) | +| `buildDuration` | `number` | Total compilation duration in milliseconds | +| `nodeVersion` | `NodeVersion` | Node.js major version (`"18"` \| `"20"` \| `"21"` \| `"22"` \| `"23"` \| `"24"` \| `"25"`) | +| `platform` | `Platform` | Platform identifier (`"linux-x64"` \| `"linux-arm64"` \| `"darwin-x64"` \| `"darwin-arm64"` \| `"win32-x64"` \| `"win32-arm64"`) | +| `sourcePath` | `string` | Path to source directory containing `.compact` files | +| `outputPath` | `string` | Path to output directory where artifacts were written | +| `compilerFlags` | `CompilerFlag[]` | Compiler flags (`"--skip-zk"` \| `"--vscode"` \| `"--no-communications-commitment"` \| `"--trace-passes"` \| `"--sourceRoot "`) | +| `artifacts` | `string[]` \| `HierarchicalArtifacts` | Compiled contracts (format depends on structure, see below) | + +#### Artifacts Format + +The `artifacts` format depends on the output structure: + +- **Flattened structure**: A flat array of contract names + +```json +"artifacts": ["Counter", "Boolean", "Bytes", "Field"] +``` + +- **Hierarchical structure**: An object where keys are source subdirectory names and values are arrays of contract names compiled from that subdirectory + +```json +"artifacts": { + "ledger": ["Counter"], + "reference": ["Boolean", "Bytes", "Field"] +} +``` + +This corresponds to source files like `src/ledger/Counter.compact` and `src/reference/Boolean.compact`. + +#### Example Manifest ```json { From 39ea43444db5e169b1425a486956f8d6c51e9883 Mon Sep 17 00:00:00 2001 From: 0xisk Date: Mon, 22 Dec 2025 15:19:28 +0100 Subject: [PATCH 6/7] refactor: auto resolve compiler command line path --- packages/cli/src/config.ts | 57 +++++++++++++++++++ packages/cli/src/services/CompilerService.ts | 13 ++++- .../cli/src/services/EnvironmentValidator.ts | 10 +++- packages/cli/src/services/FileDiscovery.ts | 16 ++---- packages/cli/test/Compiler.test.ts | 9 +++ 5 files changed, 87 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 2c26380..b180c90 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,3 +1,7 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + /** * Configuration constants for the Compact compiler CLI. * This is the single source of truth for all version definitions. @@ -39,3 +43,56 @@ export type CompactcVersion = (typeof COMPACTC_VERSIONS)[number]; /** Type derived from supported compact-tools versions */ export type CompactToolVersion = (typeof COMPACT_TOOL_VERSIONS)[number]; + +/** Name of the compact executable */ +export const COMPACT_EXECUTABLE = 'compact'; + +/** + * Standard install paths for the Compact CLI. + * Based on the dist-workspace.toml install-path configuration: + * - $XDG_BIN_HOME/ + * - $XDG_DATA_HOME/../bin (typically ~/.local/bin) + * - ~/.local/bin + * + * @see https://github.com/midnightntwrk/compact-export/blob/main/dist-workspace.toml + */ +export function getCompactInstallPaths(): string[] { + const paths: string[] = []; + const home = homedir(); + + // $XDG_BIN_HOME takes priority if set + const xdgBinHome = process.env.XDG_BIN_HOME; + if (xdgBinHome) { + paths.push(xdgBinHome); + } + + // $XDG_DATA_HOME/../bin (defaults to ~/.local/share/../bin = ~/.local/bin) + const xdgDataHome = process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'); + paths.push(join(xdgDataHome, '..', 'bin')); + + // ~/.local/bin as fallback + paths.push(join(home, '.local', 'bin')); + + return paths; +} + +/** + * Resolves the absolute path to the compact executable. + * Checks standard install locations first, then falls back to PATH resolution. + * + * @returns Absolute path to compact executable if found in standard locations, + * otherwise returns 'compact' for PATH resolution + */ +export function resolveCompactExecutable(): string { + const installPaths = getCompactInstallPaths(); + + for (const dir of installPaths) { + const executablePath = join(dir, COMPACT_EXECUTABLE); + if (existsSync(executablePath)) { + return executablePath; + } + } + + // Fall back to PATH resolution + return COMPACT_EXECUTABLE; +} diff --git a/packages/cli/src/services/CompilerService.ts b/packages/cli/src/services/CompilerService.ts index 7d239d9..7d33ad6 100644 --- a/packages/cli/src/services/CompilerService.ts +++ b/packages/cli/src/services/CompilerService.ts @@ -1,7 +1,11 @@ import { execFile as execFileCallback } from 'node:child_process'; import { basename, dirname, join, normalize, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { DEFAULT_OUT_DIR, DEFAULT_SRC_DIR } from '../config.ts'; +import { + DEFAULT_OUT_DIR, + DEFAULT_SRC_DIR, + resolveCompactExecutable, +} from '../config.ts'; import { CompilationError } from '../types/errors.ts'; import type { CompilerFlag } from '../types/manifest.ts'; import { FileDiscovery } from './FileDiscovery.ts'; @@ -54,6 +58,7 @@ export class CompilerService { private execFileFn: ExecFileFunction; private options: ResolvedCompilerServiceOptions; private pathValidator: FileDiscovery; + private compactExecutable: string; /** * Creates a new CompilerService instance. @@ -80,6 +85,8 @@ export class CompilerService { // Use FileDiscovery for path validation (defense in depth - paths should already be validated during discovery) this.pathValidator = pathValidator ?? new FileDiscovery(this.options.srcDir); + // Resolve compact executable path from standard install locations + this.compactExecutable = resolveCompactExecutable(); } /** @@ -126,7 +133,7 @@ export class CompilerService { ? join(this.options.outDir, fileDir, fileName) : join(this.options.outDir, fileName); - // Validate and normalize input path to prevent command injection + // Validate and normalize input path const validatedInputPath = this.pathValidator.validateAndNormalizePath( inputPath, this.options.srcDir, @@ -150,7 +157,7 @@ export class CompilerService { args.push(validatedInputPath, normalizedOutputDir); try { - return await this.execFileFn('compact', args); + return await this.execFileFn(this.compactExecutable, args); } catch (error: unknown) { let message: string; diff --git a/packages/cli/src/services/EnvironmentValidator.ts b/packages/cli/src/services/EnvironmentValidator.ts index 7ffbc17..866f9b3 100644 --- a/packages/cli/src/services/EnvironmentValidator.ts +++ b/packages/cli/src/services/EnvironmentValidator.ts @@ -7,6 +7,7 @@ import { type CompactToolVersion, LATEST_COMPACT_TOOL_VERSION, MAX_COMPACTC_VERSION, + resolveCompactExecutable, } from '../config.ts'; import { CompactCliNotFoundError } from '../types/errors.ts'; @@ -55,6 +56,7 @@ export type ExecFunction = ( */ export class EnvironmentValidator { private execFn: ExecFunction; + private compactExecutable: string; /** * Creates a new EnvironmentValidator instance. @@ -63,6 +65,8 @@ export class EnvironmentValidator { */ constructor(execFn: ExecFunction = promisify(execCallback)) { this.execFn = execFn; + // Resolve compact executable path from standard install locations + this.compactExecutable = resolveCompactExecutable(); } /** @@ -79,7 +83,7 @@ export class EnvironmentValidator { */ async checkCompactAvailable(): Promise { try { - await this.execFn('compact --version'); + await this.execFn(`${this.compactExecutable} --version`); return true; } catch { return false; @@ -98,7 +102,7 @@ export class EnvironmentValidator { * ``` */ async getCompactToolVersion(): Promise { - const { stdout } = await this.execFn('compact --version'); + const { stdout } = await this.execFn(`${this.compactExecutable} --version`); return stdout.trim(); } @@ -117,7 +121,7 @@ export class EnvironmentValidator { async getCompactcVersion(version?: string): Promise { const versionFlag = version ? `+${version}` : ''; const { stdout } = await this.execFn( - `compact compile ${versionFlag} --version`, + `${this.compactExecutable} compile ${versionFlag} --version`, ); return stdout.trim(); } diff --git a/packages/cli/src/services/FileDiscovery.ts b/packages/cli/src/services/FileDiscovery.ts index 4461c01..460cbd8 100644 --- a/packages/cli/src/services/FileDiscovery.ts +++ b/packages/cli/src/services/FileDiscovery.ts @@ -29,13 +29,13 @@ export class FileDiscovery { } /** - * Validates and normalizes a file path to prevent command injection. + * Validates and normalizes a file path * Ensures the path exists, resolves symlinks, and is within allowed directories. * * @param filePath - The file path to validate * @param allowedBaseDir - Base directory that the path must be within * @returns Normalized absolute path - * @throws {CompilationError} If path is invalid or contains unsafe characters + * @throws {CompilationError} If path is invalid or outside allowed directory * @example * ```typescript * const discovery = new FileDiscovery('src'); @@ -49,14 +49,6 @@ export class FileDiscovery { // Normalize the path to resolve '..' and '.' segments const normalized = normalize(filePath); - // Check for shell metacharacters and embedded quotes - if (/[;&|`$(){}[\]<>'"\\]/.test(normalized)) { - throw new CompilationError( - `Invalid file path: contains unsafe characters: ${filePath}`, - filePath, - ); - } - // Resolve to absolute path const absolutePath = resolve(normalized); @@ -102,7 +94,7 @@ export class FileDiscovery { /** * Recursively discovers all .compact files in a directory. * Returns relative paths from the srcDir for consistent processing. - * Validates paths during discovery to prevent command injection. + * Validates paths during discovery * * @param dir - Directory path to search (relative or absolute) * @returns Promise resolving to array of relative file paths @@ -124,7 +116,7 @@ export class FileDiscovery { } if (entry.isFile() && fullPath.endsWith('.compact')) { - // Validate path during discovery to prevent command injection + // Validate path during discovery this.validateAndNormalizePath(fullPath, this.srcDir); return [relative(this.srcDir, fullPath)]; } diff --git a/packages/cli/test/Compiler.test.ts b/packages/cli/test/Compiler.test.ts index fbd09cc..60dc536 100644 --- a/packages/cli/test/Compiler.test.ts +++ b/packages/cli/test/Compiler.test.ts @@ -34,6 +34,15 @@ import { // Mock Node.js modules vi.mock('node:fs'); vi.mock('node:fs/promises'); + +// Mock resolveCompactExecutable to return 'compact' for consistent test expectations +vi.mock('../src/config.ts', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + resolveCompactExecutable: () => 'compact', + }; +}); vi.mock('chalk', () => ({ default: { blue: (text: string) => text, From 8a9149e21c29825338e1aaa940561c6b800c37b6 Mon Sep 17 00:00:00 2001 From: 0xisk Date: Mon, 22 Dec 2025 15:20:54 +0100 Subject: [PATCH 7/7] chore: lint fix --- packages/cli/src/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index b180c90..2e612d8 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -67,7 +67,8 @@ export function getCompactInstallPaths(): string[] { } // $XDG_DATA_HOME/../bin (defaults to ~/.local/share/../bin = ~/.local/bin) - const xdgDataHome = process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'); + const xdgDataHome = + process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'); paths.push(join(xdgDataHome, '..', 'bin')); // ~/.local/bin as fallback