diff --git a/.gitignore b/.gitignore index e2ebbac..62eb293 100644 --- a/.gitignore +++ b/.gitignore @@ -33,9 +33,6 @@ managed/ # never gets committed — including under examples/, where the walkthrough # expects you to compile the contract yourself before deploying. artifacts/ -# compact-compiler's per-source circuit-info cache, written next to the -# .compact sources it measures. -.circuit-info.json midnight-level-db compactc diff --git a/packages/builder/README.md b/packages/builder/README.md index 5b939df..c3fd7ee 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -40,6 +40,13 @@ const builder = new CompactBuilder({ await builder.build(); ``` +## Artifacts + +Each contract compiles into `//` (flattened) or +`///` (`hierarchical: true`). Beside the `compiler/` +output, every ZK compile writes `circuit-info.json` with the parsed `k`/`rows` +per circuit. `--skip-zk` writes none. + ## Public API ```ts diff --git a/packages/builder/src/Compiler.ts b/packages/builder/src/Compiler.ts index 2bf7272..fefb770 100755 --- a/packages/builder/src/Compiler.ts +++ b/packages/builder/src/Compiler.ts @@ -21,9 +21,10 @@ import { type ExecFunction, } from './types/options.ts'; import { + artifactDir, cleanForDisplay, parseCircuitConstraints, - writeCircuitInfoJson, + writeCircuitInfo, } from './utils.ts'; // Re-export public types and services so consumers keep importing them @@ -291,7 +292,7 @@ export class CompactCompiler { * * 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. + * `circuit-info.json` file into the contract's artifact directory. * * @param file - Relative path to the .compact file * @param index - Current file index (0-based) for progress tracking @@ -327,8 +328,11 @@ export class CompactCompiler { .join('\n'); UIService.printOutput(summary, chalk.cyan); - // Write .circuit-info.json in the source directory - writeCircuitInfoJson(file, this.options.srcDir, circuits); + writeCircuitInfo( + artifactDir(this.options.outDir, file, this.options.hierarchical), + file, + circuits, + ); } const cleanStderr = cleanForDisplay(result.stderr); diff --git a/packages/builder/src/services/CompilerService.ts b/packages/builder/src/services/CompilerService.ts index 1ea9b3c..81efae9 100644 --- a/packages/builder/src/services/CompilerService.ts +++ b/packages/builder/src/services/CompilerService.ts @@ -1,7 +1,7 @@ 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 { join } from 'node:path'; import { parse as parseShellArgs } from 'shell-quote'; import { CompilationError } from '../types/errors.ts'; import { @@ -10,6 +10,7 @@ import { DEFAULT_SRC_DIR, type ExecFunction, } from '../types/options.ts'; +import { artifactDir } from '../utils.ts'; /** Resolved options for CompilerService with defaults applied */ type ResolvedCompilerServiceOptions = Required; @@ -172,15 +173,11 @@ export class CompilerService { 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 outputDir = artifactDir( + this.options.outDir, + file, + this.options.hierarchical, + ); const args: string[] = [ 'compile', diff --git a/packages/builder/src/utils.ts b/packages/builder/src/utils.ts index fa8f9eb..a42e6e5 100644 --- a/packages/builder/src/utils.ts +++ b/packages/builder/src/utils.ts @@ -11,12 +11,14 @@ * {@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. + * - **Artifact layout** ({@link artifactDir}) — resolves the per-contract + * output directory `compact compile` writes into. + * - **Circuit info persistence** ({@link writeCircuitInfo}) — writes + * `circuit-info.json` into a contract's artifact directory. */ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; /** * Converts a simple glob pattern to a regular expression. * Supports `*` (any sequence) and `?` (single char). All other glob features @@ -242,51 +244,70 @@ export function parseCircuitConstraints( return [...circuits.values()]; } +// ─── Artifact layout ──────────────────────────────────────────────────── + +/** + * Resolve the artifact directory `compact compile` writes a contract into. + * + * Flattened (default): `//`. Hierarchical: + * `///`, except for sources at the root of `srcDir`, + * which have no subdir and stay flattened. + * + * @param outDir - Base artifacts directory + * @param file - Relative path to the .compact file (from srcDir) + * @param hierarchical - Whether to mirror the source directory structure + * @returns The contract's artifact directory, under `outDir` + */ +export function artifactDir( + outDir: string, + file: string, + hierarchical: boolean, +): string { + const fileDir = dirname(file); + const contract = basename(file, '.compact'); + return hierarchical && fileDir !== '.' + ? join(outDir, fileDir, contract) + : join(outDir, contract); +} + // ─── Circuit info file ────────────────────────────────────────────────── /** - * Shape of the `.circuit-info.json` file written per source directory. + * Shape of the `circuit-info.json` file written per compiled contract. */ -export interface CircuitInfoFile { - /** ISO timestamp of when this file was last generated */ +export interface CircuitInfo { + /** ISO timestamp of when this file was generated */ generatedAt: string; - /** Map of compiled filename → circuit constraints */ - files: Record; + /** Path of the compiled .compact file relative to srcDir, forward slashes */ + source: string; + /** Circuit constraints in the compiler's output order */ + circuits: CircuitConstraint[]; } /** - * Write or merge circuit constraint data into a `.circuit-info.json` file - * in the source directory of the compiled file. + * Write circuit constraint data to `circuit-info.json` in a contract's + * artifact directory, beside the compiler's own output. * - * If the file already exists, the entry for the given file is updated and - * other entries are preserved. The `generatedAt` timestamp is always updated. + * Each compile replaces the file; nothing is merged from a previous run. * - * @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 + * @param outputDir - The contract's artifact directory, created if missing + * @param file - Relative path to the compiled .compact file (from srcDir) + * @param circuits - Parsed circuit constraints to write + * @returns The absolute path to the written `circuit-info.json` file */ -export function writeCircuitInfoJson( +export function writeCircuitInfo( + outputDir: string, 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; + const jsonPath = resolve(outputDir, 'circuit-info.json'); + const info: CircuitInfo = { + generatedAt: new Date().toISOString(), + source: file.replaceAll('\\', '/'), + circuits, + }; - mkdirSync(dir, { recursive: true }); - writeFileSync(jsonPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf-8'); + mkdirSync(outputDir, { recursive: true }); + writeFileSync(jsonPath, `${JSON.stringify(info, null, 2)}\n`, 'utf-8'); return jsonPath; } diff --git a/packages/builder/test/Compiler.test.ts b/packages/builder/test/Compiler.test.ts index 4941f3d..d10c761 100644 --- a/packages/builder/test/Compiler.test.ts +++ b/packages/builder/test/Compiler.test.ts @@ -1,5 +1,6 @@ -import { existsSync } from 'node:fs'; +import { existsSync, writeFileSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { beforeEach, describe, @@ -51,6 +52,7 @@ vi.mock('ora', () => ({ const mockExistsSync = vi.mocked(existsSync); const mockReaddir = vi.mocked(readdir); +const mockWriteFileSync = vi.mocked(writeFileSync); describe('EnvironmentValidator', () => { let mockExec: MockedFunction; @@ -1278,6 +1280,47 @@ describe('CompactCompiler', () => { ); }); + it('writes circuit-info.json into the artifact dir and nowhere else', async () => { + mockReaddir.mockResolvedValue([ + { + name: 'MockOwnable.compact', + isFile: () => true, + isDirectory: () => false, + }, + ] as any); + mockExistsSync.mockReturnValue(true); + + const testMockExec = vi + .fn() + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'compact 0.1.0', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Compactc 0.26.0', stderr: '' }) + .mockResolvedValueOnce({ + stdout: + 'Compiling 1 circuits:\n circuit "transferOwnership" (k=10, rows=626)\n', + stderr: '', + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + compiler = new CompactCompiler({}, testMockExec); + + await compiler.compile(); + + const written = mockWriteFileSync.mock.calls.map(([path]) => + String(path), + ); + expect(written).toEqual([ + resolve('artifacts', 'MockOwnable', 'circuit-info.json'), + ]); + expect(JSON.parse(String(mockWriteFileSync.mock.calls[0][1]))).toEqual({ + generatedAt: expect.any(String), + source: 'MockOwnable.compact', + circuits: [{ name: 'transferOwnership', k: 10, rows: 626 }], + }); + + logSpy.mockRestore(); + }); + it('should handle compilation errors gracefully', async () => { const brokenDirent = { name: 'Broken.compact', diff --git a/packages/builder/test/utils.test.ts b/packages/builder/test/utils.test.ts index 71771d0..a88936d 100644 --- a/packages/builder/test/utils.test.ts +++ b/packages/builder/test/utils.test.ts @@ -3,11 +3,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { - type CircuitInfoFile, + artifactDir, + type CircuitInfo, cleanCompileOutput, cleanForDisplay, parseCircuitConstraints, - writeCircuitInfoJson, + writeCircuitInfo, } from '../src/utils.js'; describe('cleanCompileOutput', () => { @@ -217,7 +218,27 @@ Overall progress [====================] 7/7`; }); }); -describe('writeCircuitInfoJson', () => { +describe('artifactDir', () => { + it('flattens a contract into /', () => { + expect(artifactDir('artifacts', 'access/MockOwnable.compact', false)).toBe( + join('artifacts', 'MockOwnable'), + ); + }); + + it('mirrors the source subdirectory when hierarchical', () => { + expect( + artifactDir('artifacts', 'access/test/mocks/MockOwnable.compact', true), + ).toBe(join('artifacts', 'access', 'test', 'mocks', 'MockOwnable')); + }); + + it('flattens a source at the srcDir root when hierarchical', () => { + expect(artifactDir('artifacts', 'MockOwnable.compact', true)).toBe( + join('artifacts', 'MockOwnable'), + ); + }); +}); + +describe('writeCircuitInfo', () => { let tmpDir: string; beforeEach(() => { @@ -228,126 +249,89 @@ describe('writeCircuitInfoJson', () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it('creates a new .circuit-info.json when none exists', () => { + it('writes circuit-info.json into the artifact directory', () => { const circuits = [ { name: 'owner', k: 7, rows: 76 }, { name: 'transfer', k: 10, rows: 970 }, ]; + const outputDir = join(tmpDir, 'MockOwnable'); - const jsonPath = writeCircuitInfoJson('Token.compact', tmpDir, circuits); + const jsonPath = writeCircuitInfo( + outputDir, + 'access/test/mocks/MockOwnable.compact', + circuits, + ); + expect(jsonPath).toBe(join(outputDir, 'circuit-info.json')); 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); + const content: CircuitInfo = JSON.parse(readFileSync(jsonPath, 'utf-8')); + expect(content.source).toBe('access/test/mocks/MockOwnable.compact'); + expect(content.circuits).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); + it('keeps the circuits in compiler output order', () => { + const circuits = [ + { name: 'z', k: 5, rows: 10 }, + { name: 'a', k: 6, rows: 20 }, + ]; - const jsonPath = join(tmpDir, '.circuit-info.json'); - const content: CircuitInfoFile = JSON.parse( - readFileSync(jsonPath, 'utf-8'), - ); + const jsonPath = writeCircuitInfo(tmpDir, 'Token.compact', circuits); - expect(Object.keys(content.files)).toHaveLength(2); - expect(content.files['Token.compact']).toEqual(firstCircuits); - expect(content.files['Pausable.compact']).toEqual(secondCircuits); + const content: CircuitInfo = JSON.parse(readFileSync(jsonPath, 'utf-8')); + expect(content.circuits.map((circuit) => circuit.name)).toEqual(['z', 'a']); }); - 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 the artifact directory when it does not exist', () => { + const outputDir = join(tmpDir, 'nested', 'MockInit'); - it('creates intermediate directories for nested paths', () => { - const circuits = [{ name: 'init', k: 6, rows: 26 }]; - - const jsonPath = writeCircuitInfoJson( - 'test/mocks/MockInit.compact', - tmpDir, - circuits, - ); + const jsonPath = writeCircuitInfo(outputDir, 'MockInit.compact', [ + { name: 'init', k: 6, rows: 26 }, + ]); - 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)); + it('overwrites a previous run instead of merging', () => { + writeCircuitInfo(tmpDir, 'Token.compact', [ + { name: 'owner', k: 7, rows: 76 }, + ]); + const jsonPath = writeCircuitInfo(tmpDir, 'Pausable.compact', [ + { name: 'pause', k: 6, rows: 29 }, + ]); - writeCircuitInfoJson('Token.compact', tmpDir, circuits); - const secondTimestamp = JSON.parse( - readFileSync(jsonPath, 'utf-8'), - ).generatedAt; + const content: CircuitInfo = JSON.parse(readFileSync(jsonPath, 'utf-8')); + expect(content.source).toBe('Pausable.compact'); + expect(content.circuits).toEqual([{ name: 'pause', k: 6, rows: 29 }]); + expect(Object.keys(content)).toEqual(['generatedAt', 'source', 'circuits']); + }); - expect(new Date(secondTimestamp).getTime()).toBeGreaterThan( - new Date(firstTimestamp).getTime(), + it('records the source path with forward slashes', () => { + const jsonPath = writeCircuitInfo( + tmpDir, + 'token/test/mocks/MockFungibleToken.compact', + [], ); + + const content: CircuitInfo = JSON.parse(readFileSync(jsonPath, 'utf-8')); + expect(content.source).toBe('token/test/mocks/MockFungibleToken.compact'); }); it('handles empty circuits array', () => { - const jsonPath = writeCircuitInfoJson('Token.compact', tmpDir, []); + const jsonPath = writeCircuitInfo(tmpDir, 'Token.compact', []); - const content: CircuitInfoFile = JSON.parse( - readFileSync(jsonPath, 'utf-8'), - ); - expect(content.files['Token.compact']).toEqual([]); + const content: CircuitInfo = JSON.parse(readFileSync(jsonPath, 'utf-8')); + expect(content.circuits).toEqual([]); }); it('writes valid JSON with trailing newline', () => { - writeCircuitInfoJson('Token.compact', tmpDir, [ + writeCircuitInfo(tmpDir, 'Token.compact', [ { name: 'foo', k: 5, rows: 100 }, ]); - const raw = readFileSync(join(tmpDir, '.circuit-info.json'), 'utf-8'); + 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 }]); - }); });