diff --git a/packages/reader/src/address-book/address-book.ts b/packages/reader/src/address-book/address-book.ts new file mode 100644 index 0000000..4a4b40e --- /dev/null +++ b/packages/reader/src/address-book/address-book.ts @@ -0,0 +1,378 @@ +/** + * Address book exporter for @redeploy/reader. + * + * DESIGN + * ====== + * + * `exportAddressBook()` takes one or more `DeploymentSnapshot`s (see + * `../snapshot/snapshot.ts`) plus optional caller-supplied ABIs and returns a + * deterministic, JSON-safe `AddressBookArtifact` bundling: + * - `json` — a pretty-printed address book keyed by chain id, then contract + * id, suitable for persisting to disk (e.g. `address-book.json`). + * - `ts` / `dts` — a typed TS module (`export const addresses = {...} as + * const;`) and matching `.d.ts` declarations, so downstream consumers get + * compile-time-checked access like `addresses[1].MyContract.address`. + * - `packageFiles` — when `packageName` is supplied, a minimal publishable + * package scaffold (`package.json` + compiled `index.js` + `index.d.ts`) + * wrapping the same data. + * + * This module is READ-ONLY / PURE by design, matching the rest of + * @redeploy/reader: it performs NO filesystem writes. Persisting any of the + * returned strings to disk is the caller's responsibility. + * + * SNAPSHOTS DO NOT CARRY ABIs + * =========================== + * + * `DeploymentSnapshot` deliberately excludes ABIs (see `../snapshot/snapshot.ts`). + * ABIs are therefore an OPTIONAL caller-supplied input here (`options.abis`, + * keyed by Solidity contract name) — this module never reads Foundry + * artifacts or imports ignition-core at runtime. + * + * DETERMINISM + * ============ + * + * Given identical inputs, `exportAddressBook()` returns byte-identical + * `json`/`ts`/`dts`/`packageFiles` strings, regardless of input ordering: + * - Chain id keys are sorted ascending numerically. + * - Contract id keys (within a chain) are sorted ascending via + * `localeCompare`. + * - Entry fields are always emitted in a fixed order: `address`, + * `contractName`, `chainId`, `network` (omitted if the snapshot has none), + * `deployedAt`, `abi` (omitted unless supplied via `options.abis`). + * + * SKIPPED / CONFLICTING CONTRACTS + * ================================= + * + * - Contracts with `address: null` (not yet completed) are excluded from the + * address book entirely — an address book only records deployed addresses. + * A single combined warning lists everything skipped this way. + * - If the SAME `(chainId, contractId)` pair appears in more than one input + * snapshot with a DIFFERENT address, the FIRST occurrence wins (subsequent + * snapshots are processed in `options.snapshots` array order) and a warning + * is recorded describing the conflict. Duplicate entries with an IDENTICAL + * address never produce a warning. + */ + +import type { ContractView } from "../read/reader.js"; +import type { DeploymentSnapshot } from "../snapshot/snapshot.js"; + +// --------------------------------------------------------------------------- +// Public schema +// --------------------------------------------------------------------------- + +/** + * A single address book entry: the deployed address of one contract on one + * chain, plus enough metadata to be useful standalone (contract name, chain + * id, optional network label, when it was recorded, and an optional ABI). + * + * Field order is significant for deterministic serialization — see the + * module-level doc comment. + */ +export interface AddressBookEntry { + /** Deployed address. Always present — null-address contracts are excluded. */ + readonly address: string; + /** Solidity contract name. */ + readonly contractName: string; + /** Chain id this entry was deployed on. */ + readonly chainId: number; + /** Optional human-readable network label (e.g. "sepolia", "mainnet"). */ + readonly network?: string; + /** `takenAt` of the snapshot this entry was recorded from (ISO-8601). */ + readonly deployedAt: string; + /** + * Viem-ready ABI, embedded verbatim from `options.abis[contractName]` when + * supplied. Omitted entirely (not `null`) when no ABI was supplied for this + * contract name. + */ + readonly abi?: unknown; +} + +/** + * The address book data model: chain id (as a string key) → contract id → + * `AddressBookEntry`. This is exactly the shape serialized into `json`/`ts`. + */ +export type AddressBookData = Readonly>>>; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +/** Options for `exportAddressBook()`. */ +export interface ExportAddressBookOptions { + /** One or more deployment snapshots (e.g. one per network/chain id). */ + readonly snapshots: ReadonlyArray; + /** Optional map of Solidity contract name → viem-ready ABI. */ + readonly abis?: Readonly>; + /** + * If set, also emit a publishable package scaffold in `packageFiles` + * (`package.json` name = this value). + */ + readonly packageName?: string; + /** JSON/TS pretty-print indent width. Defaults to `2`. */ + readonly indent?: number; +} + +/** The artifact returned by `exportAddressBook()`. */ +export interface AddressBookArtifact { + /** Deterministic pretty-printed JSON address book, trailing-newline terminated. */ + readonly json: string; + /** + * Typed TS module source: `export const addresses = {...} as const;` plus + * `export type AddressBook = typeof addresses;`. + */ + readonly ts: string; + /** Matching `.d.ts` declarations for `ts` (and for `packageFiles["index.js"]`). */ + readonly dts: string; + /** Warnings: skipped null-address contracts, address conflicts. */ + readonly warnings: ReadonlyArray; + /** + * Present iff `options.packageName` was given: a minimal publishable + * package scaffold — `package.json`, compiled `index.js`, and `index.d.ts`. + */ + readonly packageFiles?: Readonly>; +} + +// --------------------------------------------------------------------------- +// Data assembly +// --------------------------------------------------------------------------- + +function buildEntry( + contract: ContractView, + address: string, + snapshot: DeploymentSnapshot, + abis: Readonly> | undefined, +): AddressBookEntry { + const abi = abis?.[contract.contractName]; + return { + address, + contractName: contract.contractName, + chainId: snapshot.chainId, + ...(snapshot.network !== undefined ? { network: snapshot.network } : {}), + deployedAt: snapshot.takenAt, + ...(abi !== undefined ? { abi } : {}), + }; +} + +/** + * Merge `snapshots` into a sorted, deterministic `AddressBookData` structure + * plus any warnings (skipped null-address contracts, address conflicts). + * + * Conflict policy: FIRST-WINS. When the same `(chainId, contractId)` appears + * more than once with differing addresses, the first occurrence (in + * `snapshots` array order) is kept and a warning is recorded. + */ +function buildAddressBookData( + snapshots: ReadonlyArray, + abis: Readonly> | undefined, + warnings: string[], +): AddressBookData { + const byChain = new Map>(); + const skipped: string[] = []; + + for (const snapshot of snapshots) { + for (const contract of snapshot.contracts) { + if (contract.address === null) { + skipped.push(`"${contract.id}" (chain ${snapshot.chainId})`); + continue; + } + + let chainMap = byChain.get(snapshot.chainId); + if (chainMap === undefined) { + chainMap = new Map(); + byChain.set(snapshot.chainId, chainMap); + } + + const entry = buildEntry(contract, contract.address, snapshot, abis); + const existing = chainMap.get(contract.id); + + if (existing !== undefined) { + if (existing.address !== entry.address) { + warnings.push( + `Conflicting address for "${contract.id}" on chain ${snapshot.chainId}: ` + + `keeping "${existing.address}", ignoring "${entry.address}"`, + ); + } + // First-wins: never overwrite an already-recorded entry. + continue; + } + + chainMap.set(contract.id, entry); + } + } + + if (skipped.length > 0) { + warnings.push( + `Skipped ${skipped.length} contract(s) with no deployed address (excluded from address book): ${skipped.join(", ")}`, + ); + } + + const sortedChainIds = Array.from(byChain.keys()).sort((a, b) => a - b); + const data: Record> = {}; + for (const chainId of sortedChainIds) { + const chainMap = byChain.get(chainId); + if (chainMap === undefined) continue; + const sortedContractIds = Array.from(chainMap.keys()).sort((a, b) => a.localeCompare(b)); + const chainEntries: Record = {}; + for (const contractId of sortedContractIds) { + const entry = chainMap.get(contractId); + if (entry !== undefined) { + chainEntries[contractId] = entry; + } + } + data[String(chainId)] = chainEntries; + } + + return data; +} + +// --------------------------------------------------------------------------- +// TS / dts pretty-printers +// --------------------------------------------------------------------------- + +/** True iff `key` can be written as a bare (unquoted) object key in TS/JS. */ +function isBareKey(key: string): boolean { + return /^(0|[1-9]\d*)$/.test(key) || /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key); +} + +function tsKey(key: string): string { + return isBareKey(key) ? key : JSON.stringify(key); +} + +/** + * Pretty-print a JSON-safe value as TS/JS source (a runtime-valid object/array + * literal). Used to render both `ts` (with `as const` appended by the caller) + * and `packageFiles["index.js"]` (plain JS, no `as const`). + */ +function printTsValue(value: unknown, indent: number, depth: number): string { + const pad = " ".repeat(indent * (depth + 1)); + const closePad = " ".repeat(indent * depth); + + if (value === null) return "null"; + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") return String(value); + + if (Array.isArray(value)) { + if (value.length === 0) return "[]"; + const items = value.map((v) => pad + printTsValue(v, indent, depth + 1)); + return "[\n" + items.join(",\n") + "\n" + closePad + "]"; + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + if (entries.length === 0) return "{}"; + const lines = entries.map( + ([k, v]) => pad + tsKey(k) + ": " + printTsValue(v, indent, depth + 1), + ); + return "{\n" + lines.join(",\n") + "\n" + closePad + "}"; + } + + // Fallback — should not occur for JSON-safe values. + return JSON.stringify(value); +} + +/** + * Pretty-print a JSON-safe value as a TS literal TYPE mirroring the shape + * `printTsValue()` would emit for the same value (as if inferred from + * `as const`). Used to render `dts`. + */ +function printTsLiteralType(value: unknown, indent: number, depth: number): string { + const pad = " ".repeat(indent * (depth + 1)); + const closePad = " ".repeat(indent * depth); + + if (value === null) return "null"; + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") return String(value); + + if (Array.isArray(value)) { + if (value.length === 0) return "readonly []"; + const items = value.map((v) => pad + printTsLiteralType(v, indent, depth + 1)); + return "readonly [\n" + items.join(",\n") + "\n" + closePad + "]"; + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + if (entries.length === 0) return "Record"; + const lines = entries.map( + ([k, v]) => pad + "readonly " + tsKey(k) + ": " + printTsLiteralType(v, indent, depth + 1), + ); + return "{\n" + lines.join(",\n") + "\n" + closePad + "}"; + } + + return "unknown"; +} + +// --------------------------------------------------------------------------- +// Package scaffold +// --------------------------------------------------------------------------- + +function buildPackageFiles( + packageName: string, + indexJs: string, + dts: string, + indent: number, +): Readonly> { + const pkgJson = { + name: packageName, + version: "0.0.0", + private: false, + type: "module", + main: "./index.js", + types: "./index.d.ts", + exports: { + ".": { + types: "./index.d.ts", + default: "./index.js", + }, + }, + }; + + return { + "package.json": JSON.stringify(pkgJson, null, indent) + "\n", + "index.js": indexJs, + "index.d.ts": dts, + }; +} + +// --------------------------------------------------------------------------- +// exportAddressBook +// --------------------------------------------------------------------------- + +/** + * Build a deterministic, JSON-safe address book artifact from one or more + * `DeploymentSnapshot`s. + * + * PURE / READ-ONLY: performs NO filesystem access. Persisting `json`, `ts`, + * `dts`, or `packageFiles` entries to disk is the caller's responsibility. + * + * See the module-level doc comment for the merge/conflict policy, skip + * behavior, and determinism guarantees. + */ +export function exportAddressBook(options: ExportAddressBookOptions): AddressBookArtifact { + const { snapshots, abis, packageName, indent = 2 } = options; + + const warnings: string[] = []; + const data = buildAddressBookData(snapshots, abis, warnings); + + const json = JSON.stringify(data, null, indent) + "\n"; + + const rendered = printTsValue(data, indent, 0); + const renderedType = printTsLiteralType(data, indent, 0); + + const ts = `export const addresses = ${rendered} as const;\n\nexport type AddressBook = typeof addresses;\n`; + const dts = `export declare const addresses: ${renderedType};\n\nexport type AddressBook = typeof addresses;\n`; + + const artifact: { + json: string; + ts: string; + dts: string; + warnings: ReadonlyArray; + packageFiles?: Readonly>; + } = { json, ts, dts, warnings }; + + if (packageName !== undefined) { + const indexJs = `export const addresses = ${rendered};\n`; + artifact.packageFiles = buildPackageFiles(packageName, indexJs, dts, indent); + } + + return artifact; +} diff --git a/packages/reader/src/index.ts b/packages/reader/src/index.ts index 6a44bb3..9e72291 100644 --- a/packages/reader/src/index.ts +++ b/packages/reader/src/index.ts @@ -37,3 +37,13 @@ export type { BuildSnapshotOptions, SnapshotVersion, } from "./snapshot/snapshot.js"; + +// Public address book API +export { exportAddressBook } from "./address-book/address-book.js"; + +export type { + ExportAddressBookOptions, + AddressBookArtifact, + AddressBookEntry, + AddressBookData, +} from "./address-book/address-book.js"; diff --git a/packages/reader/test/address-book.test.ts b/packages/reader/test/address-book.test.ts new file mode 100644 index 0000000..78f9d95 --- /dev/null +++ b/packages/reader/test/address-book.test.ts @@ -0,0 +1,555 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { execFileSync } from "node:child_process"; +import { + exportAddressBook, + type ExportAddressBookOptions, + type DeploymentSnapshot, + type ContractView, +} from "../src/index.js"; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "redeploy-reader-address-book-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function makeContract(overrides: Partial = {}): ContractView { + return { + id: "MyContract", + contractName: "MyContract", + address: "0x1111111111111111111111111111111111111111", + args: [], + links: { dependencies: [], libraries: {} }, + ...overrides, + }; +} + +function makeSnapshot(overrides: Partial = {}): DeploymentSnapshot { + return { + snapshotVersion: 1, + takenAt: "2026-07-21T00:00:00.000Z", + chainId: 1, + toolVersion: "1.0.0", + specHash: "deadbeef", + contracts: [makeContract()], + configSteps: [], + warnings: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Happy path — single snapshot +// --------------------------------------------------------------------------- + +describe("exportAddressBook — happy path (single snapshot)", () => { + it("produces the expected JSON structure, sorted keys, and a trailing newline", () => { + const snapshot = makeSnapshot({ + chainId: 1, + network: "mainnet", + takenAt: "2026-07-21T00:00:00.000Z", + contracts: [ + makeContract({ + id: "MyContract", + contractName: "MyContract", + address: "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }), + ], + }); + + const artifact = exportAddressBook({ snapshots: [snapshot] }); + + expect(artifact.json.endsWith("\n")).toBe(true); + expect(artifact.json.endsWith("\n\n")).toBe(false); + + const parsed = JSON.parse(artifact.json) as unknown; + expect(parsed).toEqual({ + "1": { + MyContract: { + address: "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + contractName: "MyContract", + chainId: 1, + network: "mainnet", + deployedAt: "2026-07-21T00:00:00.000Z", + }, + }, + }); + + // Fixed field order within an entry: address, contractName, chainId, network, deployedAt. + const entryKeyOrderIdx = [ + artifact.json.indexOf('"address"'), + artifact.json.indexOf('"contractName"'), + artifact.json.indexOf('"chainId"'), + artifact.json.indexOf('"network"'), + artifact.json.indexOf('"deployedAt"'), + ]; + for (let i = 1; i < entryKeyOrderIdx.length; i++) { + expect(entryKeyOrderIdx[i]).toBeGreaterThan(entryKeyOrderIdx[i - 1]); + } + + expect(artifact.warnings).toHaveLength(0); + }); + + it("omits the `network` field entirely when the snapshot has none", () => { + const snapshot = makeSnapshot({ network: undefined }); + const artifact = exportAddressBook({ snapshots: [snapshot] }); + const parsed = JSON.parse(artifact.json) as Record>; + expect(parsed["1"]!["MyContract"]).not.toHaveProperty("network"); + }); + + it("respects a custom `indent` option", () => { + const snapshot = makeSnapshot(); + const artifact = exportAddressBook({ snapshots: [snapshot], indent: 4 }); + expect(artifact.json).toContain('\n "1"'); + }); +}); + +// --------------------------------------------------------------------------- +// Acceptance: multiple networks +// --------------------------------------------------------------------------- + +describe("exportAddressBook — acceptance: multiple networks", () => { + it("merges two snapshots of the same spec on two chainIds into one artifact with both chainIds", () => { + const mainnet = makeSnapshot({ + chainId: 1, + network: "mainnet", + contracts: [ + makeContract({ + id: "MyContract", + contractName: "MyContract", + address: "0x1111111111111111111111111111111111111111", + }), + ], + }); + const sepolia = makeSnapshot({ + chainId: 11155111, + network: "sepolia", + contracts: [ + makeContract({ + id: "MyContract", + contractName: "MyContract", + address: "0x2222222222222222222222222222222222222222", + }), + ], + }); + + const artifact = exportAddressBook({ snapshots: [mainnet, sepolia] }); + const parsed = JSON.parse(artifact.json) as Record>; + + expect(Object.keys(parsed)).toEqual(["1", "11155111"]); + expect(parsed["1"]!["MyContract"]).toMatchObject({ + address: "0x1111111111111111111111111111111111111111", + chainId: 1, + network: "mainnet", + }); + expect(parsed["11155111"]!["MyContract"]).toMatchObject({ + address: "0x2222222222222222222222222222222222222222", + chainId: 11155111, + network: "sepolia", + }); + expect(artifact.warnings).toHaveLength(0); + }); + + it("sorts chainId keys ascending numerically even when input order is descending", () => { + const artifact = exportAddressBook({ + snapshots: [ + makeSnapshot({ chainId: 137 }), + makeSnapshot({ chainId: 1 }), + makeSnapshot({ chainId: 42161 }), + ], + }); + const parsed = JSON.parse(artifact.json) as Record; + expect(Object.keys(parsed)).toEqual(["1", "137", "42161"]); + }); + + it("sorts contractId keys ascending via localeCompare within a chain", () => { + const artifact = exportAddressBook({ + snapshots: [ + makeSnapshot({ + contracts: [ + makeContract({ id: "Zeta", contractName: "Zeta" }), + makeContract({ id: "Alpha", contractName: "Alpha" }), + makeContract({ id: "Mid", contractName: "Mid" }), + ], + }), + ], + }); + const parsed = JSON.parse(artifact.json) as Record>; + expect(Object.keys(parsed["1"]!)).toEqual(["Alpha", "Mid", "Zeta"]); + }); +}); + +// --------------------------------------------------------------------------- +// Determinism +// --------------------------------------------------------------------------- + +describe("exportAddressBook — determinism", () => { + it("produces byte-identical json and ts for identical inputs across two builds", () => { + const options: ExportAddressBookOptions = { + snapshots: [ + makeSnapshot({ chainId: 5, contracts: [makeContract({ id: "B" }), makeContract({ id: "A" })] }), + ], + }; + + const a = exportAddressBook(options); + const b = exportAddressBook(options); + + expect(a.json).toBe(b.json); + expect(a.ts).toBe(b.ts); + expect(a.dts).toBe(b.dts); + }); + + it("yields sorted output regardless of unsorted input ordering", () => { + const artifactUnsorted = exportAddressBook({ + snapshots: [ + makeSnapshot({ + chainId: 10, + contracts: [makeContract({ id: "Z" }), makeContract({ id: "A" })], + }), + makeSnapshot({ + chainId: 1, + contracts: [makeContract({ id: "Y" }), makeContract({ id: "B" })], + }), + ], + }); + const artifactSorted = exportAddressBook({ + snapshots: [ + makeSnapshot({ + chainId: 1, + contracts: [makeContract({ id: "B" }), makeContract({ id: "Y" })], + }), + makeSnapshot({ + chainId: 10, + contracts: [makeContract({ id: "A" }), makeContract({ id: "Z" })], + }), + ], + }); + + expect(artifactUnsorted.json).toBe(artifactSorted.json); + expect(artifactUnsorted.ts).toBe(artifactSorted.ts); + }); +}); + +// --------------------------------------------------------------------------- +// ABI embedding +// --------------------------------------------------------------------------- + +describe("exportAddressBook — ABI embedding", () => { + const fakeAbi = [{ type: "function", name: "foo", inputs: [], outputs: [] }]; + + it("embeds the ABI verbatim when supplied via options.abis, keyed by contractName", () => { + const artifact = exportAddressBook({ + snapshots: [makeSnapshot({ contracts: [makeContract({ contractName: "MyContract" })] })], + abis: { MyContract: fakeAbi }, + }); + const parsed = JSON.parse(artifact.json) as Record>; + expect(parsed["1"]!["MyContract"]!.abi).toEqual(fakeAbi); + expect(artifact.ts).toContain('"foo"'); + }); + + it("omits the abi field entirely (not null/empty) when no ABI is supplied", () => { + const artifact = exportAddressBook({ + snapshots: [makeSnapshot()], + }); + const parsed = JSON.parse(artifact.json) as Record>; + expect(parsed["1"]!["MyContract"]).not.toHaveProperty("abi"); + expect(artifact.json).not.toContain('"abi"'); + }); + + it("omits the abi field for contracts whose contractName is not present in options.abis", () => { + const artifact = exportAddressBook({ + snapshots: [makeSnapshot({ contracts: [makeContract({ contractName: "Other" })] })], + abis: { MyContract: fakeAbi }, + }); + const parsed = JSON.parse(artifact.json) as Record>; + expect(parsed["1"]!["MyContract"]).not.toHaveProperty("abi"); + }); +}); + +// --------------------------------------------------------------------------- +// Null-address contracts +// --------------------------------------------------------------------------- + +describe("exportAddressBook — null-address contracts", () => { + it("skips contracts with address: null and records a warning listing what was skipped", () => { + const artifact = exportAddressBook({ + snapshots: [ + makeSnapshot({ + contracts: [ + makeContract({ id: "Deployed", address: "0x1111111111111111111111111111111111111111" }), + makeContract({ id: "Pending", address: null }), + ], + }), + ], + }); + + const parsed = JSON.parse(artifact.json) as Record>; + expect(Object.keys(parsed["1"]!)).toEqual(["Deployed"]); + expect(artifact.warnings).toHaveLength(1); + expect(artifact.warnings[0]).toContain("Pending"); + expect(artifact.warnings[0]).toContain("chain 1"); + }); + + it("returns an empty address book (not an error) when every contract has a null address", () => { + const artifact = exportAddressBook({ + snapshots: [makeSnapshot({ contracts: [makeContract({ address: null })] })], + }); + expect(JSON.parse(artifact.json)).toEqual({}); + expect(artifact.warnings).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Conflicts +// --------------------------------------------------------------------------- + +describe("exportAddressBook — conflicting vs. identical duplicate entries", () => { + it("keeps the first occurrence and warns when the same (chainId, contractId) has conflicting addresses", () => { + const artifact = exportAddressBook({ + snapshots: [ + makeSnapshot({ + chainId: 1, + contracts: [makeContract({ id: "X", address: "0x1111111111111111111111111111111111111111" })], + }), + makeSnapshot({ + chainId: 1, + contracts: [makeContract({ id: "X", address: "0x2222222222222222222222222222222222222222" })], + }), + ], + }); + + const parsed = JSON.parse(artifact.json) as Record>; + expect(parsed["1"]!["X"]!.address).toBe("0x1111111111111111111111111111111111111111"); + expect(artifact.warnings).toHaveLength(1); + expect(artifact.warnings[0]).toContain("Conflicting address"); + expect(artifact.warnings[0]).toContain("X"); + }); + + it("does not warn when the same (chainId, contractId) appears twice with an identical address", () => { + const artifact = exportAddressBook({ + snapshots: [ + makeSnapshot({ + chainId: 1, + contracts: [makeContract({ id: "X", address: "0x1111111111111111111111111111111111111111" })], + }), + makeSnapshot({ + chainId: 1, + contracts: [makeContract({ id: "X", address: "0x1111111111111111111111111111111111111111" })], + }), + ], + }); + + expect(artifact.warnings).toHaveLength(0); + const parsed = JSON.parse(artifact.json) as Record>; + expect(parsed["1"]!["X"]!.address).toBe("0x1111111111111111111111111111111111111111"); + }); +}); + +// --------------------------------------------------------------------------- +// Package scaffold +// --------------------------------------------------------------------------- + +describe("exportAddressBook — package scaffold", () => { + it("does not include packageFiles when packageName is not set", () => { + const artifact = exportAddressBook({ snapshots: [makeSnapshot()] }); + expect(artifact.packageFiles).toBeUndefined(); + }); + + it("emits a package.json + index.js + index.d.ts scaffold when packageName is set", () => { + const artifact = exportAddressBook({ + snapshots: [makeSnapshot()], + packageName: "@acme/address-book", + }); + + expect(artifact.packageFiles).toBeDefined(); + const files = artifact.packageFiles!; + expect(Object.keys(files).sort()).toEqual(["index.d.ts", "index.js", "package.json"]); + + const pkg = JSON.parse(files["package.json"]!) as Record; + expect(pkg["name"]).toBe("@acme/address-book"); + expect(pkg["type"]).toBe("module"); + expect(pkg["main"]).toBe("./index.js"); + expect(pkg["types"]).toBe("./index.d.ts"); + + expect(files["index.js"]).toContain("export const addresses = {"); + expect(files["index.js"]).not.toContain("as const"); + expect(files["index.d.ts"]).toContain("export declare const addresses:"); + }); + + it("produces deterministic packageFiles across two identical builds", () => { + const options: ExportAddressBookOptions = { + snapshots: [makeSnapshot()], + packageName: "@acme/address-book", + }; + const a = exportAddressBook(options); + const b = exportAddressBook(options); + expect(a.packageFiles).toEqual(b.packageFiles); + }); +}); + +// --------------------------------------------------------------------------- +// Empty snapshots +// --------------------------------------------------------------------------- + +describe("exportAddressBook — empty snapshots", () => { + it("returns a valid, empty-but-well-formed artifact for an empty snapshots array", () => { + const artifact = exportAddressBook({ snapshots: [] }); + + expect(JSON.parse(artifact.json)).toEqual({}); + expect(artifact.json).toBe("{}\n"); + expect(artifact.ts).toBe( + "export const addresses = {} as const;\n\nexport type AddressBook = typeof addresses;\n", + ); + expect(artifact.dts).toContain("export declare const addresses:"); + expect(artifact.warnings).toHaveLength(0); + }); + + it("treats a snapshot with an empty contracts array the same as no snapshot for that chain", () => { + const artifact = exportAddressBook({ snapshots: [makeSnapshot({ contracts: [] })] }); + expect(JSON.parse(artifact.json)).toEqual({}); + expect(artifact.warnings).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Type-safety compile check +// --------------------------------------------------------------------------- + +describe("exportAddressBook — generated `ts` type-safety (real tsc compile)", () => { + it("contains `as const` and the expected nested keys", () => { + const snapshot = makeSnapshot({ + chainId: 1, + contracts: [makeContract({ id: "MyContract", contractName: "MyContract" })], + }); + const artifact = exportAddressBook({ snapshots: [snapshot] }); + + expect(artifact.ts).toContain("as const"); + expect(artifact.ts).toContain("export type AddressBook = typeof addresses;"); + expect(artifact.ts).toMatch(/\n\s*1:\s*\{/); + expect(artifact.ts).toContain("MyContract:"); + }); + + it("compiles cleanly with tsc --noEmit: addresses[1]?.MyContract?.address is a valid, typed access", () => { + const snapshot = makeSnapshot({ + chainId: 1, + network: "mainnet", + contracts: [makeContract({ id: "MyContract", contractName: "MyContract" })], + }); + const artifact = exportAddressBook({ snapshots: [snapshot] }); + + fs.writeFileSync(path.join(tmpDir, "addresses.ts"), artifact.ts, "utf8"); + fs.writeFileSync( + path.join(tmpDir, "consumer.ts"), + [ + 'import { addresses, type AddressBook } from "./addresses.js";', + "", + "const a: string | undefined = addresses[1]?.MyContract?.address;", + "void a;", + "", + "const book: AddressBook = addresses;", + "void book;", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(tmpDir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], + }, + include: ["*.ts"], + }, + null, + 2, + ), + "utf8", + ); + + const tscBin = resolveTscBin(); + expect(() => + execFileSync(tscBin, ["-p", tmpDir], { stdio: "pipe", encoding: "utf8" }), + ).not.toThrow(); + }); + + it("compiles cleanly with tsc --noEmit against the packageFiles index.js + index.d.ts pair", () => { + const snapshot = makeSnapshot({ + chainId: 1, + contracts: [makeContract({ id: "MyContract", contractName: "MyContract" })], + }); + const artifact = exportAddressBook({ + snapshots: [snapshot], + packageName: "@acme/address-book", + }); + const files = artifact.packageFiles!; + + fs.writeFileSync(path.join(tmpDir, "index.js"), files["index.js"]!, "utf8"); + fs.writeFileSync(path.join(tmpDir, "index.d.ts"), files["index.d.ts"]!, "utf8"); + fs.writeFileSync( + path.join(tmpDir, "consumer.ts"), + [ + 'import { addresses } from "./index.js";', + "", + "const a: string | undefined = addresses[1]?.MyContract?.address;", + "void a;", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(tmpDir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + skipLibCheck: true, + allowJs: true, + types: [], + }, + include: ["*.ts", "*.js"], + }, + null, + 2, + ), + "utf8", + ); + + const tscBin = resolveTscBin(); + expect(() => + execFileSync(tscBin, ["-p", tmpDir], { stdio: "pipe", encoding: "utf8" }), + ).not.toThrow(); + }); +}); + +function resolveTscBin(): string { + const candidates = [ + path.resolve(process.cwd(), "node_modules/.bin/tsc"), + path.resolve(process.cwd(), "../../node_modules/.bin/tsc"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + throw new Error(`Could not locate a tsc binary (checked: ${candidates.join(", ")})`); +}