diff --git a/packages/builder/src/Compiler.ts b/packages/builder/src/Compiler.ts index cec51f2..c0b23a3 100755 --- a/packages/builder/src/Compiler.ts +++ b/packages/builder/src/Compiler.ts @@ -20,6 +20,11 @@ import { DEFAULT_SRC_DIR, type ExecFunction, } from './types/options.ts'; +import { + cleanForDisplay, + parseCircuitConstraints, + writeCircuitInfoJson, +} from './utils.ts'; // Re-export public types and services so consumers keep importing them // from './Compiler.js' regardless of the internal file layout. @@ -271,6 +276,10 @@ export class CompactCompiler { /** * Compiles a single file with progress reporting and error handling. * + * When circuit constraint data is available in the compile output (i.e., + * compiling without `--skip-zk`), prints a clean summary and writes a + * `.circuit-info.json` file in the source directory. + * * @param file - Relative path to the .compact file * @param index - Current file index (0-based) for progress tracking * @param total - Total number of files being compiled @@ -294,29 +303,42 @@ export class CompactCompiler { ); spinner.succeed(chalk.green(`[COMPILE] ${step} Compiled ${file}`)); - // Filter out compactc version output from compact compile - const filteredOutput = result.stdout.split('\n').slice(1).join('\n'); - if (filteredOutput) { - UIService.printOutput(filteredOutput, chalk.cyan); + // Parse circuit constraints from the captured PTY output and print + // a clean summary. When --skip-zk is used, no constraints are + // available and this is a no-op. + const circuits = parseCircuitConstraints(result.stdout); + if (circuits.length > 0) { + const summary = circuits + .map((c) => ` "${c.name}" (k=${c.k}, rows=${c.rows})`) + .join('\n'); + UIService.printOutput(summary, chalk.cyan); + + // Write .circuit-info.json in the source directory + writeCircuitInfoJson(file, this.options.srcDir, circuits); + } + + const cleanStderr = cleanForDisplay(result.stderr); + if (cleanStderr) { + UIService.printOutput(cleanStderr, chalk.yellow); } - UIService.printOutput(result.stderr, chalk.yellow); } catch (error) { spinner.fail(chalk.red(`[COMPILE] ${step} Failed ${file}`)); - // CompilationError wraps the underlying child-process error in `.cause`. - // The previous guard `isPromisifiedChildProcessError(error)` on a - // CompilationError instance was unreachable — unwrap via `.cause` to - // surface compactc's stdout/stderr to the user. const execError = error instanceof CompilationError ? error.cause : error; if (isPromisifiedChildProcessError(execError)) { - // Filter out compactc version output from compact compile - const filteredOutput = execError.stdout.split('\n').slice(1).join('\n'); + const circuits = parseCircuitConstraints(execError.stdout); + if (circuits.length > 0) { + const summary = circuits + .map((c) => ` "${c.name}" (k=${c.k}, rows=${c.rows})`) + .join('\n'); + UIService.printOutput(summary, chalk.cyan); + } - if (filteredOutput) { - UIService.printOutput(filteredOutput, chalk.cyan); + const cleanStderr = cleanForDisplay(execError.stderr); + if (cleanStderr) { + UIService.printOutput(cleanStderr, chalk.red); } - UIService.printOutput(execError.stderr, chalk.red); } throw error; diff --git a/packages/builder/src/services/CompilerService.ts b/packages/builder/src/services/CompilerService.ts index 9c9f046..6477c3f 100644 --- a/packages/builder/src/services/CompilerService.ts +++ b/packages/builder/src/services/CompilerService.ts @@ -1,6 +1,7 @@ -import { execFile as execFileCallback } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs'; +import { platform, tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; -import { promisify } from 'node:util'; import { parse as parseShellArgs } from 'shell-quote'; import { CompilationError } from '../types/errors.ts'; import { @@ -13,9 +14,6 @@ import { /** Resolved options for CompilerService with defaults applied */ type ResolvedCompilerServiceOptions = Required; -const defaultExecFn: ExecFunction = (file, args) => - promisify(execFileCallback)(file, [...args]); - /** * Tokenizes a user-supplied `flags` string into discrete argv entries using * `shell-quote` (the same rules a shell would apply for splitting). Any @@ -32,37 +30,115 @@ function tokenizeFlags(flags: string): string[] { ); } +/** + * Spawn `compact` under a pseudo-terminal using the `script` command so that + * `compactc` emits its full progress UI (circuit constraint lines with k/rows + * values). + * + * Output is silenced by redirecting stdout/stderr to `/dev/null` file + * descriptors. The `script` command still captures the full PTY output to + * a temp file, which is read after the process exits. + * + * Handles both macOS and Linux `script` syntax: + * - macOS: `script -q [args...]` + * - Linux: `script -qc " [args...]" ` + * + * Note: Circuit constraint output is only available when compiling WITHOUT + * `--skip-zk`, as the k/rows values come from the ZK proving pass. When + * `--skip-zk` is used, the returned stdout will contain only the + * `Compiling N circuits:` header. + */ +async function spawnWithPty( + args: string[], +): Promise<{ stdout: string; stderr: string }> { + const tmpFile = join(tmpdir(), `compact-pty-${Date.now()}.txt`); + const devNull = openSync('/dev/null', 'w'); + + return new Promise((resolve, reject) => { + let scriptArgs: string[]; + + if (platform() === 'darwin') { + // macOS: script -q [args...] + scriptArgs = ['-q', tmpFile, 'compact', ...args]; + } else { + // Linux: script -qc "" + const cmd = ['compact', ...args] + .map((a) => (a.includes(' ') ? `'${a}'` : a)) + .join(' '); + scriptArgs = ['-qc', cmd, tmpFile]; + } + + const proc = spawn('script', scriptArgs, { + stdio: ['ignore', devNull, devNull], + }); + closeSync(devNull); + + proc.on('error', (err) => { + try { + unlinkSync(tmpFile); + } catch {} + reject(err); + }); + + proc.on('close', (code) => { + let output = ''; + try { + output = readFileSync(tmpFile, 'utf-8'); + } catch {} + try { + unlinkSync(tmpFile); + } catch {} + + if (code !== 0) { + const error = new Error(`compact exited with code ${code}`) as Error & { + stdout: string; + stderr: string; + }; + error.stdout = output; + error.stderr = ''; + reject(error); + } else { + resolve({ stdout: output, stderr: '' }); + } + }); + }); +} + /** * Service responsible for compiling individual .compact files. - * Builds argv arrays and invokes the Compact CLI via `child_process.execFile` - * (no shell), so user-supplied values cannot inject extra commands. + * + * When no custom `execFn` is provided (production), spawns `compact` under a + * pseudo-terminal via the `script` command so the full progress output + * (including per-circuit constraint lines) is silently captured. The output + * is written to a temp file, read after compilation, and cleaned up. + * + * When a custom `execFn` is injected (testing), uses that function instead, + * preserving full testability without `script` or PTY. * * @example * ```typescript + * // Production — uses script/PTY (silent capture) * const compiler = new CompilerService(); - * const result = await compiler.compileFile( - * 'contracts/Token.compact', - * '--skip-zk --verbose', - * '0.26.0' - * ); + * + * // Testing — uses injected mock + * const mockExec = vi.fn(); + * const compiler = new CompilerService(mockExec); * ``` */ export class CompilerService { - private execFn: ExecFunction; + private execFn: ExecFunction | null; private options: ResolvedCompilerServiceOptions; /** * Creates a new CompilerService instance. * - * @param execFn - Function to invoke the Compact CLI binary (defaults to - * a promisified `child_process.execFile` — argv array, no shell). + * @param execFn - Optional exec function for dependency injection (testing). + * When provided, used instead of PTY. When omitted or + * undefined, uses `script` for full TTY output capture. * @param options - Compiler service options */ - constructor( - execFn: ExecFunction = defaultExecFn, - options: CompilerServiceOptions = {}, - ) { - this.execFn = execFn; + constructor(execFn?: ExecFunction, options: CompilerServiceOptions = {}) { + this.execFn = execFn ?? null; this.options = { hierarchical: options.hierarchical ?? false, srcDir: options.srcDir ?? DEFAULT_SRC_DIR, @@ -72,10 +148,12 @@ export class CompilerService { /** * Compiles a single .compact file using the Compact CLI. - * Builds the argv array (no shell interpolation) and invokes the binary. * - * By default, uses flattened output structure where all artifacts go to `//`. - * When `hierarchical` is true, preserves source directory structure: `///`. + * In production (no injected execFn): spawns under a PTY via `script` + * to silently capture the full progress output including circuit constraint + * lines. No terminal output is produced. + * + * In testing (injected execFn): calls the provided function directly. * * @param file - Relative path to the .compact file from srcDir * @param flags - Space-separated compiler flags (e.g., '--skip-zk --verbose'). @@ -110,7 +188,10 @@ export class CompilerService { ]; try { - return await this.execFn('compact', args); + if (this.execFn) { + return await this.execFn('compact', args); + } + return await spawnWithPty(args); } catch (error: unknown) { let message: string; diff --git a/packages/builder/src/utils.ts b/packages/builder/src/utils.ts index c60cd67..34fa933 100644 --- a/packages/builder/src/utils.ts +++ b/packages/builder/src/utils.ts @@ -6,8 +6,16 @@ * - **Shell quoting** ({@link shellQuote}, {@link buildFindExcludes}) — used by * `CompactBuilder` to interpolate user-supplied values into bash commands * safely. + * - **Output cleaning** ({@link cleanCompileOutput}, {@link cleanForDisplay}, + * {@link parseCircuitConstraints}) — strips ANSI codes, spinner artifacts, + * and cursor-movement sequences from `compact compile` PTY output and + * extracts circuit constraint data. + * - **Circuit info persistence** ({@link writeCircuitInfoJson}) — writes + * `.circuit-info.json` files with parsed circuit constraints. */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; /** * Converts a simple glob pattern to a regular expression. * Supports `*` (any sequence) and `?` (single char). All other glob features @@ -68,3 +76,192 @@ export function buildFindExcludes(patterns: readonly string[]): string { ) .join(' '); } + +// ─── Compile output cleaning ──────────────────────────────────────────── + +// Precompiled patterns — built via `new RegExp` so biome's +// noControlCharactersInRegex rule doesn't fire on the literal escapes. +// biome-ignore lint/complexity/useRegexLiterals: control characters require RegExp constructor to avoid noControlCharactersInRegex +const CSI_RE = new RegExp(String.raw`\x1B\[[0-9;]*[A-Za-z]`, 'g'); +// biome-ignore lint/complexity/useRegexLiterals: control characters require RegExp constructor to avoid noControlCharactersInRegex +const OSC_RE = new RegExp(String.raw`\x1B\][^\x07]*\x07`, 'g'); +// biome-ignore lint/complexity/useRegexLiterals: control characters require RegExp constructor to avoid noControlCharactersInRegex +const CHARSET_RE = new RegExp(String.raw`\x1B[()][A-Z0-9]`, 'g'); + +/** + * Strip ANSI escape sequences, spinner artifacts, cursor-movement sequences, + * and carriage returns from `compact compile` output. + * + * `compactc` writes its progress UI (per-circuit spinner lines with constraint + * info) directly to the TTY using cursor-up/erase-line sequences to redraw + * the display on every spinner frame. When captured via a PTY (e.g. `node-pty` + * or `script`), the raw output contains hundreds of redraw frames. This + * function strips all the control sequences, then the `\r`-based line + * overwrites, leaving clean text that can be parsed or displayed. + * + * @param raw - Raw output from `compact compile` (captured via PTY) + * @returns Cleaned output with only visible text + */ +export function cleanCompileOutput(raw: string): string { + return ( + raw + // CSI sequences (colors, cursor movement, erase line, etc.) + .replace(CSI_RE, '') + // OSC sequences (terminal title, etc.) + .replace(OSC_RE, '') + // Character set designation + .replace(CHARSET_RE, '') + // Carriage returns (spinner overwrites) — keep only the last frame + .replace(/^.*\r(?!\n)/gm, '') + // Unicode spinner/check characters (braille patterns + common symbols) + .replace(/[\u2800-\u28FF⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✓✔✗✘⣾⣽⣻⢿⡿⣟⣯⣷]/g, '') + // Collapse whitespace runs (preserve newlines) + .replace(/[^\S\n]+/g, ' ') + // Trim each line + .replace(/^ | $/gm, '') + ); +} + +/** + * Clean `compact compile` output for display: strips the `compactc` version + * line, ANSI codes, spinner artifacts, cursor redraws, and duplicate/empty + * lines. + * + * Since `compactc` redraws all circuit lines on every spinner frame, the + * cleaned output will contain many duplicates. This function deduplicates + * by keeping the last occurrence of each `circuit "name" (...)` line and + * the final progress bar state. + * + * @param raw - Raw output from `compact compile` (captured via PTY or pipe) + * @returns Clean multi-line string suitable for terminal display + */ +export function cleanForDisplay(raw: string): string { + const cleaned = cleanCompileOutput(raw); + const lines = cleaned.split('\n'); + + // Deduplicate: for circuit lines, keep last occurrence; for others, keep unique + const circuitLines = new Map(); + const otherLines: string[] = []; + let compilingLine = ''; + let progressLine = ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('compactc ')) continue; + + const circuitMatch = trimmed.match( + /circuit\s+"([^"]+)"\s*\(k\s*=\s*\d+\s*,\s*rows\s*=\s*\d+\s*\)/, + ); + if (circuitMatch) { + circuitLines.set(circuitMatch[1], trimmed); + } else if (trimmed.startsWith('Compiling ')) { + compilingLine = trimmed; + } else if (trimmed.startsWith('Overall progress')) { + progressLine = trimmed; + } else if ( + // Skip partial circuit lines (no k/rows yet, just spinner) + !trimmed.match(/^circuit\s+"[^"]+"/) + ) { + otherLines.push(trimmed); + } + } + + const result: string[] = []; + if (compilingLine) result.push(compilingLine); + for (const line of circuitLines.values()) { + result.push(` ${line}`); + } + if (progressLine) result.push(progressLine); + result.push(...otherLines); + + return result.join('\n'); +} + +/** + * Parsed circuit constraint from compile output. + */ +export interface CircuitConstraint { + name: string; + k: number; + rows: number; +} + +/** + * Parse circuit constraint info from `compact compile` output. + * + * Extracts all `circuit "name" (k=N, rows=N)` occurrences, deduplicates + * by circuit name (last occurrence wins — which is the final spinner state + * with complete k + rows values). + * + * @param rawOutput - Raw output from `compact compile` (PTY or pipe) + * @returns Array of unique circuit constraints + */ +export function parseCircuitConstraints( + rawOutput: string, +): CircuitConstraint[] { + const cleaned = cleanCompileOutput(rawOutput); + const circuitPattern = + /circuit\s+"([^"]+)"\s*\(k\s*=\s*(\d+)\s*,\s*rows\s*=\s*(\d+)\s*\)/g; + + // Deduplicate by name — last match wins (final spinner state) + const circuits = new Map(); + for (const match of cleaned.matchAll(circuitPattern)) { + circuits.set(match[1], { + name: match[1], + k: Number.parseInt(match[2], 10), + rows: Number.parseInt(match[3], 10), + }); + } + + return [...circuits.values()]; +} + +// ─── Circuit info file ────────────────────────────────────────────────── + +/** + * Shape of the `.circuit-info.json` file written per source directory. + */ +export interface CircuitInfoFile { + /** ISO timestamp of when this file was last generated */ + generatedAt: string; + /** Map of compiled filename → circuit constraints */ + files: Record; +} + +/** + * Write or merge circuit constraint data into a `.circuit-info.json` file + * in the source directory of the compiled file. + * + * If the file already exists, the entry for the given file is updated and + * other entries are preserved. The `generatedAt` timestamp is always updated. + * + * @param file - Relative path to the compiled .compact file (from srcDir) + * @param srcDir - Base source directory + * @param circuits - Parsed circuit constraints to write + * @returns The absolute path to the written `.circuit-info.json` file + */ +export function writeCircuitInfoJson( + file: string, + srcDir: string, + circuits: CircuitConstraint[], +): string { + const srcFilePath = resolve(srcDir, file); + const dir = dirname(srcFilePath); + const jsonPath = resolve(dir, '.circuit-info.json'); + + let existing: CircuitInfoFile = { generatedAt: '', files: {} }; + try { + existing = JSON.parse(readFileSync(jsonPath, 'utf-8')); + } catch { + // File doesn't exist or is invalid, start fresh + } + + const fileName = file.split('/').pop() ?? file; + existing.generatedAt = new Date().toISOString(); + existing.files[fileName] = circuits; + + mkdirSync(dir, { recursive: true }); + writeFileSync(jsonPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf-8'); + return jsonPath; +} diff --git a/packages/builder/test/utils.test.ts b/packages/builder/test/utils.test.ts new file mode 100644 index 0000000..71771d0 --- /dev/null +++ b/packages/builder/test/utils.test.ts @@ -0,0 +1,353 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + type CircuitInfoFile, + cleanCompileOutput, + cleanForDisplay, + parseCircuitConstraints, + writeCircuitInfoJson, +} from '../src/utils.js'; + +describe('cleanCompileOutput', () => { + it('strips ANSI CSI color sequences', () => { + const input = '\x1B[32m\x1B[1mhello\x1B[0m world'; + const result = cleanCompileOutput(input); + expect(result).toContain('hello'); + expect(result).toContain('world'); + expect(result).not.toContain('\x1B'); + }); + + it('strips cursor movement sequences', () => { + const input = '\x1B[3A\x1B[2Ksome text'; + const result = cleanCompileOutput(input); + expect(result).toContain('some text'); + expect(result).not.toContain('\x1B'); + }); + + it('keeps only the last frame on carriage-return overwrites', () => { + const input = 'loading |\rloading /\rloading done'; + const result = cleanCompileOutput(input); + expect(result).toContain('loading done'); + expect(result).not.toContain('loading |'); + expect(result).not.toContain('loading /'); + }); + + it('preserves newlines', () => { + const input = 'line 1\nline 2\nline 3'; + const result = cleanCompileOutput(input); + expect(result).toBe('line 1\nline 2\nline 3'); + }); + + it('strips unicode spinner characters', () => { + const input = 'circuit "foo" (k=5, rows=100) ⠋'; + const result = cleanCompileOutput(input); + expect(result).not.toContain('⠋'); + expect(result).toContain('circuit "foo"'); + }); + + it('handles empty input', () => { + expect(cleanCompileOutput('')).toBe(''); + }); + + it('cleans real compactc TTY output with ANSI and spinners', () => { + const input = + ' circuit "owner" \x1B[32m\x1B[1m|\x1B[0m\r\x1B[2K' + + ' circuit "owner" (k=7, rows=76) \x1B[32m\x1B[1m|\x1B[0m\r\n'; + const result = cleanCompileOutput(input); + expect(result).toContain('circuit "owner" (k=7, rows=76)'); + }); +}); + +describe('cleanForDisplay', () => { + it('strips the compactc version line', () => { + const input = + 'compactc 0.26.0\nCompiling 3 circuits:\n circuit "foo" (k=5, rows=100)\n'; + const result = cleanForDisplay(input); + expect(result).not.toContain('compactc'); + expect(result).toContain('Compiling 3 circuits'); + }); + + it('deduplicates circuit lines from spinner redraws', () => { + const input = [ + 'Compiling 2 circuits:', + ' circuit "foo" (k=5, rows=100)', + ' circuit "bar" (k=7, rows=200)', + ' circuit "foo" (k=5, rows=100)', + ' circuit "bar" (k=7, rows=200)', + ' circuit "foo" (k=5, rows=100)', + ' circuit "bar" (k=7, rows=200)', + 'Overall progress [====================] 2/2', + ].join('\n'); + + const result = cleanForDisplay(input); + const fooCount = (result.match(/circuit "foo"/g) ?? []).length; + const barCount = (result.match(/circuit "bar"/g) ?? []).length; + + expect(fooCount).toBe(1); + expect(barCount).toBe(1); + expect(result).toContain('Compiling 2 circuits'); + expect(result).toContain('Overall progress'); + }); + + it('drops partial circuit lines (no k/rows)', () => { + const input = [ + 'Compiling 1 circuits:', + ' circuit "foo"', + ' circuit "foo" (k=5)', + ' circuit "foo" (k=5, rows=100)', + ].join('\n'); + + const result = cleanForDisplay(input); + expect(result).toContain('circuit "foo" (k=5, rows=100)'); + const fooCount = (result.match(/circuit "foo"/g) ?? []).length; + expect(fooCount).toBe(1); + }); + + it('returns empty string for empty input', () => { + expect(cleanForDisplay('')).toBe(''); + }); + + it('returns only the compiling line for --skip-zk output', () => { + const input = 'Compiling 3 circuits:\n'; + const result = cleanForDisplay(input); + expect(result).toBe('Compiling 3 circuits:'); + }); +}); + +describe('parseCircuitConstraints', () => { + it('parses clean compile output', () => { + const input = `Compiling 3 circuits: + circuit "assertInitialized" (k=6, rows=26) + circuit "assertNotInitialized" (k=6, rows=29) + circuit "initialize" (k=6, rows=29) +Overall progress [====================] 3/3`; + + const circuits = parseCircuitConstraints(input); + + expect(circuits).toHaveLength(3); + expect(circuits).toEqual([ + { name: 'assertInitialized', k: 6, rows: 26 }, + { name: 'assertNotInitialized', k: 6, rows: 29 }, + { name: 'initialize', k: 6, rows: 29 }, + ]); + }); + + it('parses output with ANSI codes', () => { + const input = + ' \x1B[36mcircuit\x1B[0m "owner" \x1B[33m(k=7, rows=76)\x1B[0m\n' + + ' \x1B[36mcircuit\x1B[0m "transfer" \x1B[33m(k=10, rows=970)\x1B[0m\n'; + + const circuits = parseCircuitConstraints(input); + + expect(circuits).toHaveLength(2); + expect(circuits[0]).toEqual({ name: 'owner', k: 7, rows: 76 }); + expect(circuits[1]).toEqual({ name: 'transfer', k: 10, rows: 970 }); + }); + + it('deduplicates by circuit name (last occurrence wins)', () => { + const input = [ + ' circuit "foo" (k=5, rows=100)', + ' circuit "bar" (k=7, rows=200)', + ' circuit "foo" (k=5, rows=100)', + ' circuit "bar" (k=7, rows=200)', + ' circuit "foo" (k=5, rows=100)', + ' circuit "bar" (k=7, rows=200)', + ].join('\n'); + + const circuits = parseCircuitConstraints(input); + + expect(circuits).toHaveLength(2); + expect(circuits[0]).toEqual({ name: 'foo', k: 5, rows: 100 }); + expect(circuits[1]).toEqual({ name: 'bar', k: 7, rows: 200 }); + }); + + it('ignores partial circuit lines without k/rows', () => { + const input = [ + ' circuit "foo"', + ' circuit "foo" (k=5)', + ' circuit "foo" (k=5, rows=100)', + ].join('\n'); + + const circuits = parseCircuitConstraints(input); + + expect(circuits).toHaveLength(1); + expect(circuits[0]).toEqual({ name: 'foo', k: 5, rows: 100 }); + }); + + it('returns empty array for --skip-zk output', () => { + expect(parseCircuitConstraints('Compiling 3 circuits:\n')).toEqual([]); + }); + + it('returns empty array for empty input', () => { + expect(parseCircuitConstraints('')).toEqual([]); + }); + + it('returns empty array for unrelated output', () => { + expect( + parseCircuitConstraints('Some random text\nwith no circuits\n'), + ).toEqual([]); + }); + + it('handles real-world compactc output with many circuits', () => { + const input = `Compiling 7 circuits: + circuit "_transferOwnership" (k=10, rows=600) + circuit "_unsafeTransferOwnership" (k=13, rows=2956) + circuit "_unsafeUncheckedTransferOwnership" (k=10, rows=597) + circuit "assertOnlyOwner" (k=13, rows=2360) + circuit "owner" (k=7, rows=76) + circuit "renounceOwnership" (k=13, rows=2364) + circuit "transferOwnership" (k=13, rows=2959) +Overall progress [====================] 7/7`; + + const circuits = parseCircuitConstraints(input); + + expect(circuits).toHaveLength(7); + expect(circuits.find((c) => c.name === 'owner')).toEqual({ + name: 'owner', + k: 7, + rows: 76, + }); + expect(circuits.find((c) => c.name === '_transferOwnership')).toEqual({ + name: '_transferOwnership', + k: 10, + rows: 600, + }); + }); +}); + +describe('writeCircuitInfoJson', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'circuit-info-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates a new .circuit-info.json when none exists', () => { + const circuits = [ + { name: 'owner', k: 7, rows: 76 }, + { name: 'transfer', k: 10, rows: 970 }, + ]; + + const jsonPath = writeCircuitInfoJson('Token.compact', tmpDir, circuits); + + expect(existsSync(jsonPath)).toBe(true); + expect(jsonPath).toMatch(/\.circuit-info\.json$/); + + const content: CircuitInfoFile = JSON.parse( + readFileSync(jsonPath, 'utf-8'), + ); + expect(content.files['Token.compact']).toEqual(circuits); + expect(new Date(content.generatedAt).getTime()).not.toBeNaN(); + }); + + it('merges into an existing .circuit-info.json', () => { + const firstCircuits = [{ name: 'owner', k: 7, rows: 76 }]; + const secondCircuits = [{ name: 'pause', k: 6, rows: 29 }]; + + writeCircuitInfoJson('Token.compact', tmpDir, firstCircuits); + writeCircuitInfoJson('Pausable.compact', tmpDir, secondCircuits); + + const jsonPath = join(tmpDir, '.circuit-info.json'); + const content: CircuitInfoFile = JSON.parse( + readFileSync(jsonPath, 'utf-8'), + ); + + expect(Object.keys(content.files)).toHaveLength(2); + expect(content.files['Token.compact']).toEqual(firstCircuits); + expect(content.files['Pausable.compact']).toEqual(secondCircuits); + }); + + it('updates existing entry for the same file', () => { + const oldCircuits = [{ name: 'owner', k: 7, rows: 76 }]; + const newCircuits = [{ name: 'owner', k: 8, rows: 100 }]; + + writeCircuitInfoJson('Token.compact', tmpDir, oldCircuits); + writeCircuitInfoJson('Token.compact', tmpDir, newCircuits); + + const jsonPath = join(tmpDir, '.circuit-info.json'); + const content: CircuitInfoFile = JSON.parse( + readFileSync(jsonPath, 'utf-8'), + ); + + expect(Object.keys(content.files)).toHaveLength(1); + expect(content.files['Token.compact']).toEqual(newCircuits); + }); + + it('creates intermediate directories for nested paths', () => { + const circuits = [{ name: 'init', k: 6, rows: 26 }]; + + const jsonPath = writeCircuitInfoJson( + 'test/mocks/MockInit.compact', + tmpDir, + circuits, + ); + + expect(jsonPath).toBe(join(tmpDir, 'test', 'mocks', '.circuit-info.json')); + expect(existsSync(jsonPath)).toBe(true); + + const content: CircuitInfoFile = JSON.parse( + readFileSync(jsonPath, 'utf-8'), + ); + expect(content.files['MockInit.compact']).toEqual(circuits); + }); + + it('updates generatedAt timestamp on subsequent writes', async () => { + const circuits = [{ name: 'owner', k: 7, rows: 76 }]; + const jsonPath = join(tmpDir, '.circuit-info.json'); + + writeCircuitInfoJson('Token.compact', tmpDir, circuits); + const firstTimestamp = JSON.parse( + readFileSync(jsonPath, 'utf-8'), + ).generatedAt; + + // Ensure at least 1ms passes so the timestamp differs + await new Promise((resolve) => setTimeout(resolve, 5)); + + writeCircuitInfoJson('Token.compact', tmpDir, circuits); + const secondTimestamp = JSON.parse( + readFileSync(jsonPath, 'utf-8'), + ).generatedAt; + + expect(new Date(secondTimestamp).getTime()).toBeGreaterThan( + new Date(firstTimestamp).getTime(), + ); + }); + + it('handles empty circuits array', () => { + const jsonPath = writeCircuitInfoJson('Token.compact', tmpDir, []); + + const content: CircuitInfoFile = JSON.parse( + readFileSync(jsonPath, 'utf-8'), + ); + expect(content.files['Token.compact']).toEqual([]); + }); + + it('writes valid JSON with trailing newline', () => { + writeCircuitInfoJson('Token.compact', tmpDir, [ + { name: 'foo', k: 5, rows: 100 }, + ]); + + const raw = readFileSync(join(tmpDir, '.circuit-info.json'), 'utf-8'); + expect(raw.endsWith('\n')).toBe(true); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + it('preserves unrelated entries when updating one file', () => { + writeCircuitInfoJson('A.compact', tmpDir, [{ name: 'a', k: 1, rows: 10 }]); + writeCircuitInfoJson('B.compact', tmpDir, [{ name: 'b', k: 2, rows: 20 }]); + writeCircuitInfoJson('A.compact', tmpDir, [{ name: 'a', k: 3, rows: 30 }]); + + const content: CircuitInfoFile = JSON.parse( + readFileSync(join(tmpDir, '.circuit-info.json'), 'utf-8'), + ); + + expect(content.files['A.compact']).toEqual([{ name: 'a', k: 3, rows: 30 }]); + expect(content.files['B.compact']).toEqual([{ name: 'b', k: 2, rows: 20 }]); + }); +});