diff --git a/.changeset/cd-28-keep-relative-import-paths.md b/.changeset/cd-28-keep-relative-import-paths.md new file mode 100644 index 00000000..b6f9fcb9 --- /dev/null +++ b/.changeset/cd-28-keep-relative-import-paths.md @@ -0,0 +1,5 @@ +--- +"@perfect-abstractions/compose-cli": patch +--- + +Preserve the relative folder structure of locally imported dependencies when copying catalog facets, so nested imports (for example `./libraries/LibFee.sol`) keep resolving after `compose install` and `compose init`. diff --git a/.changeset/facet-install-remove-commands.md b/.changeset/facet-install-remove-commands.md new file mode 100644 index 00000000..d56c7f18 --- /dev/null +++ b/.changeset/facet-install-remove-commands.md @@ -0,0 +1,5 @@ +--- +"@perfect-abstractions/compose-cli": patch +--- + +Add `compose install`/`i` and `compose remove`/`rm` commands for managing facet registrations in `compose.json`, including versioned batch manifests. diff --git a/.changeset/fuzzy-local-facet-reference.md b/.changeset/fuzzy-local-facet-reference.md new file mode 100644 index 00000000..3f9bb8f6 --- /dev/null +++ b/.changeset/fuzzy-local-facet-reference.md @@ -0,0 +1,5 @@ +--- +"@perfect-abstractions/compose-cli": patch +--- + +Resolve bare facet names in `compose install` by fuzzy-matching the project's Solidity sources, so a local `path.sol:ContractName` reference is only needed when several sources match; interactive runs pick the source from a list. diff --git a/.changeset/remove-path-reference.md b/.changeset/remove-path-reference.md new file mode 100644 index 00000000..8c2d2c87 --- /dev/null +++ b/.changeset/remove-path-reference.md @@ -0,0 +1,5 @@ +--- +"@perfect-abstractions/compose-cli": patch +--- + +Accept `path.sol:ContractName` references in `compose remove`/`rm` and remove manifests, so they resolve to the registered facet name the way `compose install` does instead of being skipped as unregistered. diff --git a/README.md b/README.md index 35d1461c..3860f7f0 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ npx @perfect-abstractions/compose-cli init ### Foundry ```bash -forge install Perfect-Abstractions/Compose@tag=compose@0.0.6 +forge install Perfect-Abstractions/Compose@tag=compose@0.0.7 ``` ### Hardhat / NPM diff --git a/cli/README.md b/cli/README.md index 7510435f..76da910f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -9,7 +9,7 @@ npx @perfect-abstractions/compose-cli init ``` This starts an interactive flow to scaffold a new diamond project with: -- Base preset selection (Counter, ERC-20, ERC-721, ERC-1155, ERC-6909) +- Base preset selection (Counter, ERC-20, ERC-721, ERC-721 Enumerable, ERC-1155, ERC-6909) - Extension facet selection - Ownership and access control configuration - Compose library facet selection @@ -18,32 +18,41 @@ This starts an interactive flow to scaffold a new diamond project with: ## Usage ```bash -compose init [options] +compose init [options] [project-name] +compose install [facet-ref...] [options] +compose remove [facet-ref...] [options] compose catalog compose info -compose validate -compose --version | -v +compose validate [options] +compose build +compose rpc [options] +compose inspect
[options] +compose help [command] +compose --version | -V compose --help | -h ``` +``` ### `compose init` Scaffold a new Compose diamond project. **Options:** -- `--name `: directory name for the new project -- `--framework `: target framework +- `[project-name]`: directory name for the new project +- `--framework `: target framework (default: foundry) - `--toolbox `: Hardhat toolbox (default: ethers) -- `--base `: base preset (counter, erc-20, erc-721, erc-1155, erc-6909) -- `--ownership `: ownership model -- `--access-control `: access control facets to include -- `--libraries `: Compose library facets to include -- `--extensions `: extension facets to include -- `--examples`: include local example facets -- `--yes`: non-interactive mode with sensible defaults -- `--no-install-deps`: skip dependency installation - -When `--yes` is not provided, `compose init` will prompt for any values you omit. +- `--base `: base preset — `counter`, `erc-20`, `erc-721`, `erc-721-enumerable`, `erc-1155`, `erc-6909`, or `none` for a blank project +- `--ownership `: ownership base — `owner` or `owner-two-step`; omit the flag for no ownership +- `--ownership-extensions `: comma-separated ownership extension facets +- `--access-control `: comma-separated access control bases (for example `access-control`) +- `--access-control-extensions `: comma-separated access control extension facets +- `--libraries `: comma-separated Compose library facets +- `--extensions `: comma-separated extension facets +- `--out `: parent directory for the project (defaults to the current directory) +- `--yes`: non-interactive mode; requires `--base` +- `--install-deps` / `--no-install-deps`: install or skip framework dependencies + +Selection flags apply to `--yes` runs. Without `--yes`, only `[project-name]` and `--out` are honored and `compose init` prompts for the framework, toolbox, base, extensions, libraries, ownership, access control, and dependency installation. ### `compose catalog` @@ -57,7 +66,84 @@ Display a summary of the local project: - Storage slot annotations - Validation warnings -### `compose validate` (Coming Soon...) +### `compose install` / `compose i` + +Register facets in the desired composition for a diamond in `compose.json`. +This edits project configuration only; it does not deploy facets or change a deployed diamond. + +```bash +# Register Compose catalog facets +compose install ERC20BurnFacet DiamondUpgradeFacet --diamond my-token + +# Register a local facet (the source file must already be in the project) +compose i src/facets/FeeFacet.sol:FeeFacet --diamond my-token + +# Register a local facet by contract name; the source file is located by fuzzy match +compose i FeeFacet --diamond my-token + +# Create a reviewable batch file without applying it +compose install --create-manifest add-fees --diamond my-token + +# Apply a batch file from compose/facet-batches/add-fees.json +compose install --manifest add-fees +``` + +**Options:** +- `--diamond `: target diamond; inferred when the project defines one diamond, otherwise prompted interactively or required in non-interactive use +- `--project-root `: start searching for the project from this directory +- `--manifest `: apply an install manifest; a simple name resolves under `compose/facet-batches/` +- `--create-manifest `: create a manifest in `compose/facet-batches/` without changing `compose.json` +- `--yes`: skip interactive confirmation prompts + +Catalog facets backed by local Compose templates are copied into the framework's Solidity source tree. Local `path.sol:ContractName` references are registered in place. A bare name that is not in the catalog is fuzzy-matched against the project's Solidity sources (file name first, then declared contract names, then substring matches); several matches prompt interactively outside of `--yes`/non-interactive use. Package facets require their package source to be available in the project. + +### `compose remove` / `compose rm` + +Unregister facets from the desired composition. This does not delete Solidity files or package dependencies, and it does not modify an already deployed diamond. +The CLI keeps at least one facet registered per diamond so the project remains validatable. + +```bash +compose remove FeeFacet --diamond my-token +compose rm --manifest remove-legacy +compose remove --create-manifest remove-legacy --diamond my-token +``` + +The options are the same as `compose install`. Facet arguments take the same forms as install — a catalog or project contract name, or a `path.sol:ContractName` reference — and both resolve to the registered facet name. In an interactive terminal, direct removals ask for confirmation. `--manifest` accepts a file path or a name under `compose/facet-batches/`. + +### Facet batch manifests + +Manifests are versioned JSON files and can be reviewed or edited before applying. Install manifests accept catalog names or explicit local/package references: + +```json +{ + "version": 1, + "operation": "install", + "diamonds": { + "my-token": [ + { "name": "ERC20BurnFacet" }, + { + "name": "FeeFacet", + "source": "local", + "contract": "src/facets/FeeFacet.sol:FeeFacet" + } + ] + } +} +``` + +Remove manifests list registered facet names: + +```json +{ + "version": 1, + "operation": "remove", + "diamonds": { "my-token": ["LegacyFacet"] } +} +``` + +Applying a batch validates the resulting source configuration before writing `compose.json`. It checks selector collisions and virtual storage layout compatibility. Install/remove commands do not update `compose.lock` or refresh the deploy script generated by `init`; deployment tooling will consume the configured facet set in a later workflow. + +### `compose validate` Run static analysis on the local codebase: - Storage layout validation @@ -65,19 +151,45 @@ Run static analysis on the local codebase: - `exportSelectors` consistency checks - Missing facet registration warnings +**Options:** +- `--project-root `: start searching for the project from this directory + Exit code non-zero on failure (CI-friendly). +### `compose build` + +Compile the project if artifacts are stale or missing. + +### `compose rpc` + +Check RPC connectivity and optionally inspect contract bytecode. + +**Options:** +- `--chain `: chain key from `compose.json` (default: `local`) +- `--address
`: contract address to check or inspect + +### `compose inspect` + +Inspect a deployed diamond's facets and selectors. + +**Arguments:** +- `
`: diamond contract address to inspect + +**Options:** +- `--chain `: chain key from `compose.json` (default: `local`) + ## Base Presets Each base preset provides a starting point for common diamond patterns: | Base | Description | Required Facets | |------|-------------|-----------------| -| **Counter** (local) | Simple counter with increment/decrement | CounterDataFacet, CounterIncrementFacet, CounterDecrementFacet | +| **Counter** (local) | Simple counter with increment/decrement | CounterDataFacet, CounterIncrementFacet | | **ERC-20** (package) | Fungible token standard | ERC20DataFacet, ERC20ApproveFacet, ERC20TransferFacet | | **ERC-721** (package) | Non-fungible token standard | ERC721DataFacet, ERC721ApproveFacet, ERC721TransferFacet | +| **ERC-721 Enumerable** (package) | ERC-721 with token enumeration | ERC721ApproveFacet, ERC721DataFacet, ERC721EnumerableDataFacet, ERC721EnumerableTransferFacet | | **ERC-1155** (package) | Multi-token standard | ERC1155DataFacet, ERC1155ApproveFacet, ERC1155TransferFacet | -| **ERC-6909** (package) | Minimal multi-token standard | ERC6909DataFacet, ERC6909TransferFacet | +| **ERC-6909** (package) | Minimal multi-token standard | ERC6909DataFacet, ERC6909TransferFacet, ERC6909ApproveFacet, ERC6909OperatorFacet | Each base has compatible extension facets that are filtered during interactive selection to prevent cross-standard conflicts. @@ -95,20 +207,20 @@ Follow the prompts to select framework, base, extensions, ownership, and access ```bash # Foundry project with Counter base -compose init --name my-counter --framework foundry --base counter --ownership owner --yes +compose init my-counter --framework foundry --base counter --ownership owner --yes # Hardhat project with ERC-20 base and extensions -compose init --name my-token \ +compose init my-token \ --framework hardhat \ --toolbox ethers \ --base erc-20 \ --ownership owner-two-step \ - --extensions ERC20BurnFacet,ERC20MintFacet \ + --extensions ERC20BurnFacet,ERC20PermitFacet \ --libraries ERC165Facet \ --yes # Hardhat project with Viem toolbox -compose init --name my-nft \ +compose init my-nft \ --framework hardhat \ --toolbox viem \ --base erc-721 \ diff --git a/cli/src/comander.ts b/cli/src/comander.ts index 899fecd4..b71634d7 100644 --- a/cli/src/comander.ts +++ b/cli/src/comander.ts @@ -32,6 +32,28 @@ export function buildProgram(): Command { .option("--no-install-deps", "Skip dependency installation") .option("--toolbox ", "Hardhat toolbox", "ethers"); + program + .command("install") + .alias("i") + .description("Register facets in compose.json without deploying them") + .argument("[facets...]", "Catalog facet names, project contract names, or local path:Contract references") + .option("--diamond ", "Diamond name from compose.json") + .option("--project-root ", "Compose project directory") + .option("--manifest ", "Apply an install batch manifest") + .option("--create-manifest ", "Create an install batch manifest without applying it") + .option("--yes", "Skip confirmation prompts") + + program + .command("remove") + .alias("rm") + .description("Unregister facets from compose.json without changing a deployment") + .argument("[facets...]", "Facet names or local path:Contract references to unregister") + .option("--diamond ", "Diamond name from compose.json") + .option("--project-root ", "Compose project directory") + .option("--manifest ", "Apply a remove batch manifest") + .option("--create-manifest ", "Create a remove batch manifest without applying it") + .option("--yes", "Skip confirmation prompts"); + program .command("validate") .description("Validate selectors and virtual storage layouts") @@ -74,13 +96,17 @@ export function parseArgs(argv: string[]): { command: string; flags: Record c.name() === command); + const invokedCommand = program.args[0] ?? ""; + const commandInstance = program.commands.find( + (candidate) => candidate.name() === invokedCommand || candidate.aliases().includes(invokedCommand), + ); if (!commandInstance) { - return { command, flags: {} }; + return { command: invokedCommand, flags: {} }; } + const command = commandInstance.name(); + const opts = commandInstance.opts(); const flags: Record = { ...opts }; @@ -89,11 +115,10 @@ export function parseArgs(argv: string[]): { command: string; flags: Record typeof arg === "string" && arg !== command, - ); - if (positionalArgs.length > 0) { + const positionalArgs = commandInstance.args.filter((arg): arg is string => typeof arg === "string"); + if (command === "install" || command === "remove") { + if (positionalArgs.length > 0) flags.facets = positionalArgs; + } else if (positionalArgs.length > 0) { if (command === "inspect" && !flags.address) { flags.address = positionalArgs[0]; } else if (!flags.projectName) { diff --git a/cli/src/modules/deployGeneration/renderers.ts b/cli/src/modules/deployGeneration/renderers.ts index e054f51f..039f35d4 100644 --- a/cli/src/modules/deployGeneration/renderers.ts +++ b/cli/src/modules/deployGeneration/renderers.ts @@ -1,6 +1,7 @@ import { DeployFacetEntry, DeployGenerationModel } from "./types"; import { toCamelCase } from "../../utils/strings"; import { loadTemplate } from "../../utils/codegen"; +import { renderFacetSections } from "../facetSections"; /** * Renders a Foundry (Solidity) deploy script that deploys a Diamond proxy @@ -20,8 +21,6 @@ export async function renderFoundryDeployScript(model: DeployGenerationModel): P return `import {${facet.contractName}} from "${importPath}";`; }) .join("\n"); - const baseFacets = model.facets.filter((facet) => facet.group === "base"); - const libraryFacets = model.facets.filter((facet) => facet.group === "library"); let index = 0; const renderDeployLine = (facet: DeployFacetEntry): string => { @@ -31,8 +30,7 @@ export async function renderFoundryDeployScript(model: DeployGenerationModel): P return line; }; - const baseLines = baseFacets.map(renderDeployLine).join("\n"); - const libraryLines = libraryFacets.map(renderDeployLine).join("\n"); + const { baseLines, libraryLines } = renderFacetSections(model.facets, renderDeployLine, "\n"); return template .replace("{{IMPORTS}}", imports) @@ -68,30 +66,6 @@ function renderHardhatViemFacetDeploy(facet: DeployFacetEntry): string { facets.push(${variableName}.address);`; } -/** - * Renders base and library facet deploy sections for a Hardhat deploy script - * using the provided per-facet renderer. - * - * @param model - The deploy generation model with facet entries. - * @param renderFacetDeploy - Callback to render a single facet's deploy code. - * @returns An object with `baseLines` and `libraryLines` strings. - */ -function renderHardhatDeploySections( - model: DeployGenerationModel, - renderFacetDeploy: (facet: DeployFacetEntry) => string, -): { baseLines: string; libraryLines: string } { - const baseLines = model.facets - .filter((facet) => facet.group === "base") - .map(renderFacetDeploy) - .join("\n\n"); - const libraryLines = model.facets - .filter((facet) => facet.group === "library") - .map(renderFacetDeploy) - .join("\n\n"); - - return { baseLines, libraryLines }; -} - /** * Renders a complete Hardhat + ethers.js deploy script that deploys a Diamond proxy. * @@ -101,7 +75,7 @@ function renderHardhatDeploySections( export async function renderHardhatEthersDeployScript(model: DeployGenerationModel): Promise { const [template, { baseLines, libraryLines }] = await Promise.all([ loadTemplate("deploy", "hardhat-ethers-deploy.ts"), - renderHardhatDeploySections(model, renderHardhatEthersFacetDeploy), + renderFacetSections(model.facets, renderHardhatEthersFacetDeploy, "\n\n"), ]); return template @@ -118,7 +92,7 @@ export async function renderHardhatEthersDeployScript(model: DeployGenerationMod export async function renderHardhatViemDeployScript(model: DeployGenerationModel): Promise { const [template, { baseLines, libraryLines }] = await Promise.all([ loadTemplate("deploy", "hardhat-viem-deploy.ts"), - renderHardhatDeploySections(model, renderHardhatViemFacetDeploy), + renderFacetSections(model.facets, renderHardhatViemFacetDeploy, "\n\n"), ]); return template diff --git a/cli/src/modules/facetConfig/facetResolver.ts b/cli/src/modules/facetConfig/facetResolver.ts new file mode 100644 index 00000000..e7ee6aee --- /dev/null +++ b/cli/src/modules/facetConfig/facetResolver.ts @@ -0,0 +1,493 @@ +import { constants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { BasesCatalog, FacetEntry } from "../config/types"; +import { + parsePackageName, + planImportClosureTargets, + resolveLocalSolidityImportClosure, + toPosixPath, +} from "../../utils/files"; +import { + contractNameFromSourcePath, + isComposePackagePath, + resolveCatalogSourceForRead, +} from "../../utils/soliditySources"; +import { removeSolidityComments } from "../../utils/solidityText"; +import type { + FacetInstallReference, + FacetRegistration, + ResolvedFacetInstall, +} from "./types"; + +/** A project Solidity source that fuzzy-matched a bare facet reference. */ +export type LocalContractMatch = { + /** Project-relative POSIX path of the matching source. */ + source: string; + /** Contract name to register for the matching source. */ + contractName: string; +}; + +/** Hooks that let the caller resolve ambiguous bare facet references. */ +export type FacetReferenceResolutionOptions = { + /** + * Called when a bare facet name matches several project sources. + * Returns the chosen project-relative source path. + */ + selectLocalMatch?: (candidates: string[]) => Promise; +}; + +/** Dependency and generated directories excluded from bare-name source scans. */ +const IGNORED_SOURCE_DIRECTORIES = new Set([ + "node_modules", + "lib", + "out", + "cache", + "artifacts", + "broadcast", + "broadcasts", + "typechain-types", + "coverage", + "dist", +]); + +const CONTRACT_DECLARATION = /\b(?:abstract\s+)?(?:contract|library|interface)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g; + +type CatalogCandidate = { + name: string; + entry: FacetEntry; + group: "diamond" | "libraries" | "facets"; +}; + +function catalogCandidates(catalog: BasesCatalog): CatalogCandidate[] { + const candidates: CatalogCandidate[] = []; + const add = (facets: Record | undefined, group: CatalogCandidate["group"]): void => { + for (const [name, entry] of Object.entries(facets ?? {})) candidates.push({ name, entry, group }); + }; + + add(catalog.globals.diamond?.required, "diamond"); + add(catalog.globals.diamond?.optional, "diamond"); + add(catalog.globals.libraries?.required, "libraries"); + add(catalog.globals.libraries?.optional, "libraries"); + add(catalog.globals.examples?.required, "facets"); + add(catalog.globals.examples?.optional, "facets"); + for (const definition of Object.values(catalog.features)) { + add(definition.required, "facets"); + add(definition.optional, "facets"); + } + + const unique = new Map(); + for (const candidate of candidates) { + const key = `${candidate.name}:${JSON.stringify(candidate.entry)}`; + unique.set(key, candidate); + } + return [...unique.values()]; +} + +export function listCatalogFacetNames(catalog: BasesCatalog): string[] { + const candidates = catalogCandidates(catalog); + const pathsByName = new Map>(); + for (const candidate of candidates) { + const definitions = pathsByName.get(candidate.name) ?? new Set(); + definitions.add(JSON.stringify(candidate.entry)); + pathsByName.set(candidate.name, definitions); + } + return [...pathsByName] + .filter(([, paths]) => paths.size === 1) + .map(([name]) => name) + .sort((left, right) => left.localeCompare(right)); +} + +export function listAvailableCatalogFacetNames( + catalog: BasesCatalog, + registeredFacetNames: Iterable, +): string[] { + const registered = new Set(registeredFacetNames); + return listCatalogFacetNames(catalog).filter((name) => !registered.has(name)); +} + +/** Contract suffix of a `path.sol:ContractName` reference, or null when the value carries none. */ +export function contractReferenceName(contract: string): string | null { + const match = contract.match(/:([A-Za-z_$][A-Za-z0-9_$]*)$/); + return match?.[1] ?? null; +} + +function projectRelativePath(projectRoot: string, sourcePath: string): string { + const relative = path.relative(projectRoot, sourcePath); + if ( + !relative || + relative === "." || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`Facet source must be inside the Compose project: ${sourcePath}`); + } + return toPosixPath(relative); +} + +function registrationFromCatalog(candidate: CatalogCandidate): ResolvedFacetInstall { + const { name, entry, group } = candidate; + const contractName = contractNameFromSourcePath(entry.path); + if (isComposePackagePath(entry.path)) { + return { + name, + contractName, + targetGroup: group, + registration: { + source: "package", + contract: contractName, + package: parsePackageName(entry.path), + ...(entry.mod ? { mod: entry.mod } : {}), + constructor: entry.constructor, + ...(entry.erc165 ? { erc165: entry.erc165 } : {}), + }, + }; + } + + return { + name, + contractName, + targetGroup: group, + catalogSource: resolveCatalogSourceForRead(entry.path), + registration: { + source: "local", + // Set to the project-relative path once the catalog file has been copied. + contract: "", + ...(entry.mod ? { mod: entry.mod } : {}), + constructor: entry.constructor, + ...(entry.erc165 ? { erc165: entry.erc165 } : {}), + }, + }; +} + +export async function resolveFacetInstallReference( + reference: string | FacetInstallReference, + catalog: BasesCatalog, + projectRoot: string, + options?: FacetReferenceResolutionOptions, +): Promise { + if (typeof reference === "string") { + const catalogMatches = catalogCandidates(catalog).filter((candidate) => candidate.name === reference); + if (catalogMatches.length === 1) return registrationFromCatalog(catalogMatches[0]); + if (catalogMatches.length > 1) { + const candidates = catalogMatches.map((candidate) => candidate.entry.path).join("\n "); + throw new Error(`Facet name '${reference}' is ambiguous. Use a source path or manifest reference:\n ${candidates}`); + } + + const contractName = contractReferenceName(reference); + if (contractName) { + return resolveLocalReference(reference, contractName, projectRoot); + } + + const matched = await resolveFuzzyLocalReference(reference, projectRoot, options); + if (matched) return matched; + throw new Error( + `Unknown catalog facet '${reference}'. Use a catalog name, path.sol:ContractName, or the name of a contract in this project.`, + ); + } + + if (!reference.source) { + const matches = catalogCandidates(catalog).filter((candidate) => candidate.name === reference.name); + if (matches.length === 1) return registrationFromCatalog(matches[0]); + if (matches.length > 1) { + throw new Error(`Facet name '${reference.name}' is ambiguous; specify its source in the manifest.`); + } + const matched = await resolveFuzzyLocalReference(reference.name, projectRoot, options); + if (matched) return matched; + throw new Error(`Unknown catalog facet '${reference.name}'. Add a local or package source to the manifest.`); + } + + if (reference.source === "local") { + const contractName = contractReferenceName(reference.contract); + if (!contractName) { + throw new Error(`Local facet '${reference.name}' must use path.sol:ContractName.`); + } + if (contractName !== reference.name) { + throw new Error(`Local facet key '${reference.name}' must match contract '${contractName}'.`); + } + return resolveLocalReference(reference.contract, contractName, projectRoot, reference.name); + } + + const contractName = reference.contract.includes(":") + ? contractReferenceName(reference.contract) + : reference.contract; + if (!contractName || !reference.package.trim()) { + throw new Error(`Package facet '${reference.name}' must define a package and contract.`); + } + if (contractName !== reference.name) { + throw new Error(`Package facet key '${reference.name}' must match contract '${contractName}'.`); + } + const registration: FacetRegistration = { + source: "package", + contract: contractName, + package: reference.package, + constructor: undefined, + }; + return { + name: reference.name, + contractName, + registration, + }; +} + +async function resolveLocalReference( + reference: string, + contractName: string, + projectRoot: string, + facetName = contractName, +): Promise { + const separator = reference.lastIndexOf(":"); + const source = reference.slice(0, separator); + const absolutePath = path.resolve(projectRoot, source); + await fs.access(absolutePath).catch(() => { + throw new Error(`Facet source not found: ${source}`); + }); + const registration: FacetRegistration = { + source: "local", + contract: `${projectRelativePath(projectRoot, absolutePath)}:${contractName}`, + constructor: undefined, + }; + return { + name: facetName, + contractName, + registration, + }; +} + +/** + * Lists every Solidity source in the project that can back a bare facet + * reference, skipping dependency and generated directories. + * + * @param projectRoot - The Compose project root to scan. + * @returns Project-relative POSIX paths of `.sol` files, in directory order. + */ +async function listProjectSoliditySources(projectRoot: string): Promise { + const sources: string[] = []; + + const walk = async (directory: string): Promise => { + const entries = await fs.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name.startsWith(".") || IGNORED_SOURCE_DIRECTORIES.has(entry.name)) continue; + await walk(target); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".sol")) { + sources.push(toPosixPath(path.relative(projectRoot, target))); + } + } + }; + + await walk(projectRoot); + return sources; +} + +/** Returns the contract, library, and interface names declared by a source. */ +function declaredContractNames(source: string): string[] { + const names: string[] = []; + const pattern = new RegExp(CONTRACT_DECLARATION, "g"); + for (const match of removeSolidityComments(source).matchAll(pattern)) { + if (match[1]) names.push(match[1]); + } + return names; +} + +/** + * Picks the contract name to register for a matched source: a declared name + * equal to the reference wins, then a declared name equal to the file name, + * then the file name itself. + */ +function contractNameForMatch(source: string, declared: string[], reference: string): string { + const lowerReference = reference.toLowerCase(); + const fileName = path.basename(source, path.extname(source)); + return ( + declared.find((name) => name.toLowerCase() === lowerReference) ?? + declared.find((name) => name.toLowerCase() === fileName.toLowerCase()) ?? + fileName + ); +} + +/** + * Fuzzy-matches a bare facet reference against the project's Solidity sources. + * + * Match tiers, in order: file name equality, declared contract name equality, + * then substring containment in either direction. Only the best non-empty tier + * is returned. + * + * @param reference - A bare facet/contract name with no `path.sol:` prefix. + * @param projectRoot - The Compose project root to scan. + * @returns Matching sources, best tier first. + */ +export async function findLocalContractMatches( + reference: string, + projectRoot: string, +): Promise { + const sources = await listProjectSoliditySources(projectRoot); + const lowerReference = reference.toLowerCase(); + + const fileNameMatches = sources.filter( + (source) => path.basename(source, path.extname(source)).toLowerCase() === lowerReference, + ); + if (fileNameMatches.length > 0) { + return Promise.all(fileNameMatches.map(async (source) => ({ + source, + contractName: contractNameForMatch( + source, + declaredContractNames(await fs.readFile(path.join(projectRoot, source), "utf8")), + reference, + ), + }))); + } + + const scanned = await Promise.all(sources.map(async (source) => ({ + source, + declared: declaredContractNames(await fs.readFile(path.join(projectRoot, source), "utf8")), + }))); + + const declaredExact = scanned + .filter((file) => file.declared.some((name) => name.toLowerCase() === lowerReference)) + .map((file) => ({ + source: file.source, + contractName: contractNameForMatch(file.source, file.declared, reference), + })); + if (declaredExact.length > 0) return declaredExact; + + const fuzzy: LocalContractMatch[] = []; + for (const file of scanned) { + const fileName = path.basename(file.source, path.extname(file.source)); + const contains = (candidate: string): boolean => + candidate.toLowerCase().includes(lowerReference) || lowerReference.includes(candidate.toLowerCase()); + const declaredHit = file.declared.find(contains); + if (declaredHit) { + fuzzy.push({ source: file.source, contractName: declaredHit }); + } else if (contains(fileName)) { + fuzzy.push({ source: file.source, contractName: contractNameForMatch(file.source, file.declared, reference) }); + } + } + return fuzzy; +} + +/** + * Resolves a bare facet reference to the project source it names, prompting + * (or failing with the candidate list) when several sources match. + * + * @param reference - A bare facet/contract name with no `path.sol:` prefix. + * @param projectRoot - The Compose project root to scan. + * @param options - Optional ambiguity resolution hooks. + * @returns The resolved facet, or `null` when no project source matches. + */ +async function resolveFuzzyLocalReference( + reference: string, + projectRoot: string, + options?: FacetReferenceResolutionOptions, +): Promise { + const matches = await findLocalContractMatches(reference, projectRoot); + if (matches.length === 0) return null; + + let chosen = matches[0]; + if (matches.length > 1) { + const sources = matches.map((match) => match.source); + const selected = options?.selectLocalMatch + ? await options.selectLocalMatch(sources) + : undefined; + const picked = matches.find((match) => match.source === selected); + if (!picked) { + throw new Error( + `Facet '${reference}' matches several project sources:\n ${sources.join("\n ")}\nUse path.sol:ContractName to choose one.`, + ); + } + chosen = picked; + } + + return resolveLocalReference(`${chosen.source}:${chosen.contractName}`, chosen.contractName, projectRoot); +} + +export function withCatalogCopyTarget( + facet: ResolvedFacetInstall, + projectRoot: string, + contractSourceRoot: string, + copiedSourcePath: string, +): ResolvedFacetInstall { + if (!facet.catalogSource) return facet; + const sourcePath = path.resolve(copiedSourcePath); + const targetName = path.basename(sourcePath, path.extname(sourcePath)); + const targetPath = path.join(contractSourceRoot, facet.targetGroup ?? "facets", path.basename(sourcePath)); + return { + ...facet, + registration: { + ...facet.registration, + contract: `${projectRelativePath(projectRoot, targetPath)}:${facet.contractName ?? targetName}`, + }, + }; +} + +export async function copyCatalogFacetSources( + facets: ResolvedFacetInstall[], + projectRoot: string, + contractSourceRoot: string, +): Promise<{ facets: ResolvedFacetInstall[]; createdFiles: string[] }> { + const localCatalogFacets = facets.filter((facet) => facet.catalogSource); + if (localCatalogFacets.length === 0) return { facets, createdFiles: [] }; + + const seeds = localCatalogFacets.map((facet) => ({ + source: path.resolve(facet.catalogSource as string), + target: path.join( + contractSourceRoot, + facet.targetGroup ?? "facets", + `${facet.contractName ?? facet.name}.sol`, + ), + })); + + const closure = await resolveLocalSolidityImportClosure(seeds.map((seed) => seed.source)); + const copyTargets = new Map(); + for (const [source, target] of planImportClosureTargets(seeds, closure, contractSourceRoot)) { + const existingSource = copyTargets.get(target); + if (existingSource && existingSource !== source) { + throw new Error(`Multiple catalog files would be copied to ${target}.`); + } + copyTargets.set(target, source); + } + + for (const [target, source] of copyTargets) { + try { + const existingContent = await fs.readFile(target); + const sourceContent = await fs.readFile(source); + if (!existingContent.equals(sourceContent)) { + throw new Error(`Facet source destination already exists with different contents: ${target}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + const createdFiles: string[] = []; + try { + for (const [target, source] of copyTargets) { + if (await fs.access(target).then(() => true).catch(() => false)) continue; + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.copyFile(source, target, constants.COPYFILE_EXCL); + createdFiles.push(target); + } + } catch (error) { + await Promise.all(createdFiles.map((filePath) => fs.rm(filePath, { force: true }))); + throw error; + } + + return { + facets: facets.map((facet) => + facet.catalogSource + ? withCatalogCopyTarget( + facet, + projectRoot, + contractSourceRoot, + path.join( + contractSourceRoot, + facet.targetGroup ?? "facets", + `${facet.contractName ?? facet.name}.sol`, + ), + ) + : facet, + ), + createdFiles, + }; +} diff --git a/cli/src/modules/facetConfig/manifest.ts b/cli/src/modules/facetConfig/manifest.ts new file mode 100644 index 00000000..baa55d12 --- /dev/null +++ b/cli/src/modules/facetConfig/manifest.ts @@ -0,0 +1,172 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathExists } from "../../utils/files"; +import { contractReferenceName } from "./facetResolver"; +import type { + FacetBatch, + FacetBatchOperation, + FacetInstallReference, + InstallFacetBatch, + RemoveFacetBatch, +} from "./types"; + +export const FACET_BATCH_DIRECTORY = path.join("compose", "facet-batches"); + +const RESERVED_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasFilePath(value: string): boolean { + return path.isAbsolute(value) || value.includes("/") || value.includes("\\"); +} + +function withJsonExtension(value: string): string { + return path.extname(value) ? value : `${value}.json`; +} + +function validateSafeName(name: string, kind: string): void { + if (RESERVED_OBJECT_KEYS.has(name)) { + throw new Error(`${kind} name '${name}' is reserved and cannot be used.`); + } +} + +export function resolveFacetBatchPath(projectRoot: string, reference: string): string { + if (!reference.trim()) throw new Error("Manifest name or path cannot be empty."); + if (hasFilePath(reference)) return path.resolve(projectRoot, reference); + return path.resolve(projectRoot, FACET_BATCH_DIRECTORY, withJsonExtension(reference)); +} + +export async function resolveExistingFacetBatchPath( + projectRoot: string, + reference: string, +): Promise { + const directPath = path.resolve(projectRoot, reference); + if ((hasFilePath(reference) || path.extname(reference) !== "") && await pathExists(directPath)) return directPath; + + const defaultPath = resolveFacetBatchPath(projectRoot, reference); + if (await pathExists(defaultPath)) return defaultPath; + + throw new Error(`Facet batch manifest not found: ${reference}`); +} + +export function validateFacetBatch(value: unknown, expectedOperation?: FacetBatchOperation): FacetBatch { + if (!isRecord(value)) throw new Error("Facet batch manifest must be a JSON object."); + if (value.version !== 1) throw new Error("Unsupported facet batch manifest version; expected version 1."); + if (value.operation !== "install" && value.operation !== "remove") { + throw new Error("Facet batch manifest operation must be 'install' or 'remove'."); + } + if (expectedOperation && value.operation !== expectedOperation) { + throw new Error(`This command expects an '${expectedOperation}' manifest, but received '${value.operation}'.`); + } + if (!isRecord(value.diamonds) || Object.keys(value.diamonds).length === 0) { + throw new Error("Facet batch manifest must contain at least one diamond."); + } + + const diamonds: Record = {}; + for (const [diamondName, facetsValue] of Object.entries(value.diamonds)) { + if (!diamondName.trim() || !Array.isArray(facetsValue) || facetsValue.length === 0) { + throw new Error(`Manifest diamond '${diamondName || ""}' must have a non-empty facet list.`); + } + validateSafeName(diamondName, "Diamond"); + + if (value.operation === "remove") { + const names = facetsValue.map((facet) => { + if (typeof facet !== "string" || !facet.trim()) { + throw new Error(`Remove manifest for '${diamondName}' must contain facet names as strings.`); + } + // Remove accepts the same `path.sol:ContractName` form install does; the registration key is the contract name. + const name = contractReferenceName(facet.trim()) ?? facet.trim(); + validateSafeName(name, "Facet"); + return name; + }); + if (new Set(names).size !== names.length) { + throw new Error(`Remove manifest for '${diamondName}' contains duplicate facet names.`); + } + diamonds[diamondName] = names; + continue; + } + + const references = facetsValue.map((facet) => validateInstallReference(facet, diamondName)); + const names = references.map((reference) => reference.name); + if (new Set(names).size !== names.length) { + throw new Error(`Install manifest for '${diamondName}' contains duplicate facet names.`); + } + diamonds[diamondName] = references; + } + + return value.operation === "install" + ? { version: 1, operation: "install", diamonds: diamonds as Record } + : { version: 1, operation: "remove", diamonds: diamonds as Record }; +} + +function validateInstallReference(value: unknown, diamondName: string): FacetInstallReference { + if (typeof value === "string" && value.trim()) { + const localReference = value.trim(); + const contract = localReference.match(/^.+:([A-Za-z_$][A-Za-z0-9_$]*)$/)?.[1]; + validateSafeName(contract ?? localReference, "Facet"); + return contract + ? { name: contract, source: "local", contract: localReference } + : { name: localReference }; + } + if (!isRecord(value) || typeof value.name !== "string" || !value.name.trim()) { + throw new Error(`Install manifest for '${diamondName}' contains an invalid facet reference.`); + } + + const name = value.name.trim(); + validateSafeName(name, "Facet"); + if (value.source === undefined) return { name }; + if (value.source === "local") { + if (typeof value.contract !== "string" || !/^.+:([A-Za-z_$][A-Za-z0-9_$]*)$/.test(value.contract.trim())) { + throw new Error(`Local facet '${name}' must use path.sol:ContractName.`); + } + return { name, source: "local", contract: value.contract.trim() }; + } + if (value.source === "package") { + if (typeof value.contract !== "string" || !value.contract.trim()) { + throw new Error(`Package facet '${name}' must define a contract name.`); + } + if (typeof value.package !== "string" || !value.package.trim()) { + throw new Error(`Package facet '${name}' must define its package name.`); + } + return { + name, + source: "package", + contract: value.contract.trim(), + package: value.package.trim(), + }; + } + + throw new Error(`Facet '${name}' source must be 'local' or 'package'.`); +} + +export function createInstallFacetBatch( + diamonds: InstallFacetBatch["diamonds"], +): InstallFacetBatch { + return validateFacetBatch({ version: 1, operation: "install", diamonds }, "install") as InstallFacetBatch; +} + +export function createRemoveFacetBatch( + diamonds: RemoveFacetBatch["diamonds"], +): RemoveFacetBatch { + return validateFacetBatch({ version: 1, operation: "remove", diamonds }, "remove") as RemoveFacetBatch; +} + +export async function readFacetBatch( + filePath: string, + expectedOperation: FacetBatchOperation, +): Promise { + let value: unknown; + try { + value = JSON.parse(await fs.readFile(filePath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown JSON parse error"; + throw new Error(`Unable to read facet batch manifest ${filePath}: ${message}`, { cause: error }); + } + return validateFacetBatch(value, expectedOperation); +} + +export function serializeFacetBatch(batch: FacetBatch): string { + return `${JSON.stringify(batch, null, 2)}\n`; +} diff --git a/cli/src/modules/facetConfig/module.ts b/cli/src/modules/facetConfig/module.ts new file mode 100644 index 00000000..4e62b8ed --- /dev/null +++ b/cli/src/modules/facetConfig/module.ts @@ -0,0 +1,500 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { DependencyKey } from "../../resolver/dependencyKey"; +import { DependencyResolver } from "../../resolver/dependencyResolver"; +import type { ComposeContext, ModuleState } from "../../context/types"; +import { ConfigModule } from "../config/module"; +import type { BasesCatalog } from "../config/types"; +import { ValidationModule } from "../validation/module"; +import { loadValidationProject } from "../validation/project"; +import { loadPrompts, checkboxTheme, selectTheme } from "../../utils/inquirer"; +import { atomicWriteFile, pathExists, toPosixPath } from "../../utils/files"; +import { dim, yellow } from "../../utils/terminal"; +import type { IFrameworkAdapter } from "../../adapters/IFrameworkAdapter/interface"; +import type { IHashingAdapter } from "../../adapters/IHashingAdapter/interface"; +import { + createInstallFacetBatch, + createRemoveFacetBatch, + readFacetBatch, + resolveExistingFacetBatchPath, + resolveFacetBatchPath, + serializeFacetBatch, +} from "./manifest"; +import { + contractReferenceName, + copyCatalogFacetSources, + listAvailableCatalogFacetNames, + listCatalogFacetNames, + resolveFacetInstallReference, + type FacetReferenceResolutionOptions, +} from "./facetResolver"; +import { loadComposeProject, writeComposeProject } from "./projectConfig"; +import type { LoadedComposeProject } from "./projectConfig"; +import { showFacetManifestCreated, showFacetMutation } from "./output"; +import type { + ComposeDiamondConfig, + FacetBatch, + FacetBatchOperation, + FacetInstallReference, + FacetMutationResult, + ResolvedFacetInstall, +} from "./types"; + +function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +function moduleState(ctx: ComposeContext, key: string): ModuleState | undefined { + return ctx.state[key] as ModuleState | undefined; +} + +/** Resolution hooks that let interactive installs pick among fuzzy source matches. */ +function facetReferenceOptions(): FacetReferenceResolutionOptions | undefined { + if (!isInteractive()) return undefined; + return { + selectLocalMatch: async (candidates: string[]) => { + const { select } = await loadPrompts(); + return select({ + message: "Multiple project sources match this facet; select one:", + choices: candidates.map((candidate) => ({ name: candidate, value: candidate })), + theme: selectTheme, + }); + }, + }; +} + +function frameworkDependency(framework: string): DependencyKey { + if (framework === "foundry") return DependencyKey.Foundry; + if (framework === "hardhat") return DependencyKey.Hardhat; + throw new Error(`Unsupported framework in compose.json: ${framework || "missing"}.`); +} + +function catalogManifestReference(value: string): FacetInstallReference { + const name = contractReferenceName(value); + return name ? { name, source: "local", contract: value } : { name: value }; +} + +function sameRegistration(left: unknown, right: unknown): boolean { + if (!left || !right || typeof left !== "object" || typeof right !== "object") return false; + const a = left as Record; + const b = right as Record; + return a.source === b.source && a.contract === b.contract && a.package === b.package; +} + +function getDiamondOrThrow( + diamonds: Record, + diamondName: string, +): ComposeDiamondConfig { + if (!Object.hasOwn(diamonds, diamondName)) { + throw new Error(`Diamond '${diamondName}' is not defined in compose.json.`); + } + return diamonds[diamondName]; +} + +async function selectDiamond(ctx: ComposeContext, names: string[], operation: string): Promise { + if (typeof ctx.param.diamond === "string" && ctx.param.diamond.trim()) { + if (!names.includes(ctx.param.diamond)) { + throw new Error(`Diamond '${ctx.param.diamond}' not found. Available: ${names.join(", ")}`); + } + return ctx.param.diamond; + } + if (names.length === 1) return names[0]; + if (!isInteractive()) { + throw new Error(`Specify --diamond for this project. Available: ${names.join(", ")}`); + } + const { select } = await loadPrompts(); + return select({ + message: `Select the diamond to ${operation}:`, + choices: names.map((name) => ({ name, value: name })), + theme: selectTheme, + }); +} + +/** Shared selection skeleton: explicit arguments win, otherwise one multi-select prompt. */ +async function selectFacetNames( + ctx: ComposeContext, + options: { + promptMessage: string; + withoutArgumentsMessage: string; + choices: () => Promise; + }, +): Promise { + if (Array.isArray(ctx.param.facets) && ctx.param.facets.length > 0) { + return ctx.param.facets.filter((facet): facet is string => typeof facet === "string"); + } + if (!isInteractive()) throw new Error(options.withoutArgumentsMessage); + + const names = await options.choices(); + const { checkbox } = await loadPrompts(); + const selected = await checkbox({ + message: options.promptMessage, + choices: names.map((name) => ({ name, value: name })), + theme: checkboxTheme, + }); + if (selected.length === 0) throw new Error("No facets selected."); + return selected; +} + +async function selectInstallReferences( + ctx: ComposeContext, + catalog: BasesCatalog, + diamondName: string, + registeredFacetNames: Iterable, +): Promise { + return selectFacetNames(ctx, { + promptMessage: "Select facets to register:", + withoutArgumentsMessage: "Provide facet names or path.sol:Contract references, or use --manifest.", + choices: async () => { + const registeredNames = [...registeredFacetNames]; + const registered = new Set(registeredNames); + const registeredCatalogFacets = listCatalogFacetNames(catalog) + .filter((name) => registered.has(name)); + if (registeredCatalogFacets.length > 0) { + console.log(dim(`Already registered on ${diamondName}: ${registeredCatalogFacets.join(", ")}`)); + } + const availableFacetNames = listAvailableCatalogFacetNames(catalog, registeredNames); + if (availableFacetNames.length === 0) { + throw new Error(`All selectable catalog facets are already registered on '${diamondName}'. Use a local path reference to add a custom facet.`); + } + return availableFacetNames; + }, + }); +} + +async function selectRemoveFacetNames( + ctx: ComposeContext, + diamondName: string, + configuredFacets: Record, +): Promise { + const names = await selectFacetNames(ctx, { + promptMessage: `Select facets to unregister from ${diamondName}:`, + withoutArgumentsMessage: "Provide facet names to remove, or use --manifest.", + choices: async () => { + const configured = Object.keys(configuredFacets); + if (configured.length === 0) throw new Error(`Diamond '${diamondName}' has no registered facets.`); + return configured; + }, + }); + // Callers pass the same `path.sol:ContractName` reference they used to install; the key is the contract name. + return names.map((name) => contractReferenceName(name) ?? name); +} + +async function createManifest( + ctx: ComposeContext, + project: LoadedComposeProject, + operation: FacetBatchOperation, + diamondName: string, + references: Array, +): Promise { + const projectRoot = project.root; + const manifestName = String(ctx.param.createManifest ?? "").trim(); + if (!manifestName) throw new Error("Provide a manifest name with --create-manifest ."); + + const manifestPath = resolveFacetBatchPath(projectRoot, manifestName); + if (await pathExists(manifestPath)) { + throw new Error(`Manifest already exists: ${manifestPath}. Choose another name.`); + } + + const batch: FacetBatch = operation === "install" + ? createInstallFacetBatch({ + [diamondName]: references.map((reference) => + typeof reference === "string" ? catalogManifestReference(reference) : reference, + ), + }) + : createRemoveFacetBatch({ [diamondName]: references as string[] }); + await atomicWriteFile(manifestPath, serializeFacetBatch(batch)); + + const result: FacetMutationResult = { + operation, + projectRoot, + composeJsonPath: project.path, + diamondChanges: [{ + diamond: diamondName, + // Install reaches this with resolved references; remove with plain names. + facets: references.map((reference) => + typeof reference === "string" ? reference : reference.name, + ), + }], + changed: false, + manifestPath, + }; + showFacetManifestCreated(result); + return ctx; +} + +async function normalizeInstallManifestReferences( + references: string[], + catalog: BasesCatalog, + projectRoot: string, + options?: FacetReferenceResolutionOptions, +): Promise { + const normalized: FacetInstallReference[] = []; + for (const reference of references) { + const resolved = await resolveFacetInstallReference(reference, catalog, projectRoot, options); + const localContract = resolved.registration.source === "local" && !resolved.catalogSource + ? resolved.registration.contract + : ""; + normalized.push(localContract + ? { + name: resolved.name, + source: "local", + contract: localContract, + } + : { name: resolved.name }); + } + return normalized; +} + +async function validateCandidateConfig( + ctx: ComposeContext, + projectConfig: Record, +): Promise { + const project = await loadValidationProject(ctx, projectConfig); + const framework = frameworkDependency(String(ctx.param.framework ?? "")); + const dependencies = await DependencyResolver.resolve([ + { key: DependencyKey.Hashing }, + { key: framework }, + ]); + const adapter = dependencies[framework] as IFrameworkAdapter | undefined; + const hashing = dependencies[DependencyKey.Hashing] as IHashingAdapter | undefined; + if (!adapter || !hashing) throw new Error("Source-validation dependencies were not resolved."); + + ctx = await ValidationModule.validateProjectSources(ctx, project, adapter, hashing); + await ValidationModule.showReport(ctx); + const validation = moduleState<{ checkedFacets: number }>(ctx, "validationSourceProject"); + if (!validation?.success) { + throw new Error(validation?.error?.message ?? "Facet source validation failed."); + } + return ctx; +} + +/** Per-operation steps for {@link applyBatch}: what differs, nothing else. */ +type FacetBatchMutation = { + operation: FacetBatchOperation; + editDiamond(options: { + diamondName: string; + diamond: ComposeDiamondConfig; + entries: unknown; + stagedFiles: string[]; + }): Promise<{ facets: string[]; changed: boolean }>; + revert?(options: { stagedFiles: string[] }): Promise; +}; + +/** + * Shared write recipe: clone, edit every batch diamond, validate the candidate, + * then write once. Nothing here is specific to installing or removing. + */ +async function applyBatch( + ctx: ComposeContext, + project: LoadedComposeProject, + batch: FacetBatch, + mutation: FacetBatchMutation, +): Promise { + const projectConfig = structuredClone(project.config); + const diamonds = projectConfig.diamonds; + const changes: FacetMutationResult["diamondChanges"] = []; + const stagedFiles: string[] = []; + let changed = false; + let committed = false; + + try { + for (const [diamondName, entries] of Object.entries(batch.diamonds)) { + const edit = await mutation.editDiamond({ + diamondName, + diamond: getDiamondOrThrow(diamonds, diamondName), + entries, + stagedFiles, + }); + changed = changed || edit.changed; + changes.push({ diamond: diamondName, facets: edit.facets }); + } + + // Staged sources are as unverified as a registration change, and only install stages. + if (changed || stagedFiles.length > 0) { + ctx = await validateCandidateConfig(ctx, projectConfig); + } + if (changed) { + await writeComposeProject(project, projectConfig); + ctx.config.composeJson = projectConfig; + } + committed = true; + } catch (error) { + if (!committed && mutation.revert) { + await mutation.revert({ stagedFiles }); + } + throw error; + } + + const result: FacetMutationResult = { + operation: mutation.operation, + projectRoot: project.root, + composeJsonPath: project.path, + diamondChanges: changes, + changed, + ...(stagedFiles.length > 0 + ? { materializedFiles: stagedFiles.map((filePath) => toPosixPath(path.relative(project.root, filePath))) } + : {}), + }; + ctx.state.facetMutation = { success: true, result, error: null }; + showFacetMutation(result); + return ctx; +} + +async function applyInstallBatch( + ctx: ComposeContext, + project: LoadedComposeProject, + batch: FacetBatch, +): Promise { + const { adapter } = await resolveFrameworkAdapter(ctx); + const contractSourceRoot = adapter.getContractSourceRoot(project.root); + const catalog = ctx.config.bases as BasesCatalog; + const resolution = facetReferenceOptions(); + + return applyBatch(ctx, project, batch, { + operation: "install", + editDiamond: async ({ diamondName, diamond, entries, stagedFiles }) => { + const resolved: ResolvedFacetInstall[] = []; + for (const reference of entries as FacetInstallReference[]) { + resolved.push(await resolveFacetInstallReference(reference, catalog, project.root, resolution)); + } + + const copied = await copyCatalogFacetSources(resolved, project.root, contractSourceRoot); + stagedFiles.push(...copied.createdFiles); + + const installedNames: string[] = []; + for (const facet of copied.facets) { + if (Object.hasOwn(diamond.facets, facet.name)) { + if (!sameRegistration(diamond.facets[facet.name], facet.registration)) { + throw new Error(`Facet '${facet.name}' is already registered to a different source in '${diamondName}'.`); + } + continue; + } + diamond.facets[facet.name] = facet.registration; + installedNames.push(facet.name); + } + return { facets: installedNames, changed: installedNames.length > 0 }; + }, + revert: async ({ stagedFiles }) => { + await Promise.all(stagedFiles.map((filePath) => fs.rm(filePath, { force: true }))); + }, + }); +} + +async function applyRemoveBatch( + ctx: ComposeContext, + project: LoadedComposeProject, + batch: FacetBatch, +): Promise { + return applyBatch(ctx, project, batch, { + operation: "remove", + editDiamond: async ({ diamondName, diamond, entries }) => { + const removed: string[] = []; + for (const facetName of entries as string[]) { + if (!Object.hasOwn(diamond.facets, facetName)) { + console.warn(yellow(`Facet '${facetName}' is not registered on '${diamondName}'; skipping.`)); + continue; + } + delete diamond.facets[facetName]; + removed.push(facetName); + } + if (Object.keys(diamond.facets).length === 0) { + throw new Error(`Cannot remove every registered facet from '${diamondName}'.`); + } + return { facets: removed, changed: removed.length > 0 }; + }, + }); +} + +async function resolveFrameworkAdapter(ctx: ComposeContext): Promise<{ + adapter: IFrameworkAdapter; +}> { + const framework = frameworkDependency(String(ctx.param.framework ?? "")); + const dependencies = await DependencyResolver.resolve([{ key: framework }]); + const adapter = dependencies[framework] as IFrameworkAdapter | undefined; + if (!adapter) throw new Error(`${String(ctx.param.framework)} adapter was not resolved.`); + return { adapter }; +} + +async function confirmRemoval(ctx: ComposeContext, names: string[]): Promise { + if (ctx.param.yes || !isInteractive()) return; + const { confirm } = await loadPrompts(); + const accepted = await confirm({ + message: `Unregister ${names.join(", ")}? This only changes compose.json.`, + default: false, + }); + if (!accepted) throw new Error("Facet removal cancelled."); +} + +export const FacetConfigModule = { + async execute(ctx: ComposeContext, operation: FacetBatchOperation): Promise { + if (ctx.param.manifest && ctx.param.createManifest) { + throw new Error("Use either --manifest or --create-manifest, not both."); + } + if (ctx.param.manifest && Array.isArray(ctx.param.facets) && ctx.param.facets.length > 0) { + throw new Error("Use either facet arguments or --manifest, not both."); + } + const project = await loadComposeProject(ctx); + const names = Object.keys(project.config.diamonds); + const selectedDiamond = ctx.param.manifest + ? undefined + : await selectDiamond(ctx, names, operation === "install" ? "install facets into" : "remove facets from"); + + if (ctx.param.manifest && ctx.param.diamond) { + throw new Error("The diamond is defined by the manifest; omit --diamond when using --manifest."); + } + + if (operation === "install") { + ctx = await ConfigModule.loadBasesCatalog(ctx); + const catalog = ctx.config.bases as BasesCatalog; + if (ctx.param.createManifest) { + const diamond = project.config.diamonds[selectedDiamond as string]; + const references = await selectInstallReferences(ctx, catalog, selectedDiamond as string, Object.keys(diamond.facets)); + const normalized = await normalizeInstallManifestReferences( + references, + catalog, + project.root, + facetReferenceOptions(), + ); + return createManifest(ctx, project, operation, selectedDiamond as string, normalized); + } + if (ctx.param.manifest) { + const manifestPath = await resolveExistingFacetBatchPath(project.root, String(ctx.param.manifest)); + const batch = await readFacetBatch(manifestPath, "install"); + return applyInstallBatch(ctx, project, batch); + } + const diamond = project.config.diamonds[selectedDiamond as string]; + const references = await selectInstallReferences(ctx, catalog, selectedDiamond as string, Object.keys(diamond.facets)); + return applyInstallBatch( + ctx, + project, + createInstallFacetBatch({ [selectedDiamond as string]: references.map(catalogManifestReference) }), + ); + } + + if (ctx.param.createManifest) { + const refs = await selectRemoveFacetNames( + ctx, + selectedDiamond as string, + project.config.diamonds[selectedDiamond as string].facets, + ); + return createManifest(ctx, project, operation, selectedDiamond as string, refs); + } + if (ctx.param.manifest) { + const manifestPath = await resolveExistingFacetBatchPath(project.root, String(ctx.param.manifest)); + const batch = await readFacetBatch(manifestPath, "remove"); + const facetNames = Object.values(batch.diamonds).flat() as string[]; + await confirmRemoval(ctx, facetNames); + return applyRemoveBatch(ctx, project, batch); + } + + const targetDiamond = selectedDiamond as string; + const refs = await selectRemoveFacetNames( + ctx, + targetDiamond, + project.config.diamonds[targetDiamond].facets, + ); + await confirmRemoval(ctx, refs); + const batch = createRemoveFacetBatch({ [targetDiamond]: refs }); + return applyRemoveBatch(ctx, project, batch); + }, +}; diff --git a/cli/src/modules/facetConfig/output.ts b/cli/src/modules/facetConfig/output.ts new file mode 100644 index 00000000..40e5ff6f --- /dev/null +++ b/cli/src/modules/facetConfig/output.ts @@ -0,0 +1,20 @@ +import { green, yellow } from "../../utils/terminal"; +import type { FacetMutationResult } from "./types"; + +export function showFacetManifestCreated(result: FacetMutationResult): void { + console.log(green(`\n✔ Created ${result.operation} facet manifest`)); + console.log(` ${result.manifestPath}`); + console.log(`\nReview the file, then run 'compose ${result.operation} --manifest ${result.manifestPath}'.\n`); +} + +export function showFacetMutation(result: FacetMutationResult): void { + const action = result.operation === "install" ? "Registered" : "Unregistered"; + console.log(green(`\n✔ ${action} facets in Compose configuration`)); + for (const change of result.diamondChanges) { + console.log(` ${change.diamond}: ${change.facets.length > 0 ? change.facets.join(", ") : "no changes"}`); + } + if (result.changed) console.log(` Updated: ${result.composeJsonPath}`); + else console.log(yellow(" Configuration already matched the requested facet set.")); + for (const filePath of result.materializedFiles ?? []) console.log(` Copied: ${filePath}`); + console.log(yellow(" No deployed diamond or compose.lock entry was changed.\n")); +} diff --git a/cli/src/modules/facetConfig/projectConfig.ts b/cli/src/modules/facetConfig/projectConfig.ts new file mode 100644 index 00000000..1e23e66f --- /dev/null +++ b/cli/src/modules/facetConfig/projectConfig.ts @@ -0,0 +1,68 @@ +import { ComposeContext } from "../../context/types"; +import { atomicWriteFile } from "../../utils/files"; +import { locateComposeProjectFile, readComposeProjectFile } from "../../utils/composeProjectFile"; +import type { ComposeProjectConfig } from "./types"; + +export type LoadedComposeProject = { + path: string; + root: string; + config: ComposeProjectConfig; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function loadComposeProject(ctx: ComposeContext): Promise { + const startDirectory = String(ctx.param.projectRoot ?? process.cwd()); + const composeProjectFile = await locateComposeProjectFile(startDirectory); + if (!composeProjectFile) { + throw new Error("compose.json not found. Run 'compose init' or navigate to a Compose project directory."); + } + + let raw: unknown; + try { + raw = await readComposeProjectFile(composeProjectFile.path); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown JSON parse error"; + throw new Error(`Unable to parse ${composeProjectFile.path}: ${message}`, { cause: error }); + } + + if (!isRecord(raw) || !isRecord(raw.diamonds) || Object.keys(raw.diamonds).length === 0) { + throw new Error(`${composeProjectFile.path} must contain a non-empty diamonds object.`); + } + + for (const [diamondName, diamond] of Object.entries(raw.diamonds)) { + if (!diamondName.trim() || !isRecord(diamond) || typeof diamond.contract !== "string") { + throw new Error(`Diamond '${diamondName || ""}' must define a contract reference.`); + } + if (diamond.facets === undefined) diamond.facets = {}; + if (!isRecord(diamond.facets)) { + throw new Error(`Diamond '${diamondName}' facets must be an object.`); + } + } + + const config = raw as ComposeProjectConfig; + if (config.framework !== "foundry" && config.framework !== "hardhat") { + throw new Error(`Unsupported framework in compose.json: ${String(config.framework ?? "missing")}.`); + } + + const projectRoot = composeProjectFile.root; + ctx.param.projectRoot = projectRoot; + ctx.param.framework = config.framework; + ctx.config.composeJson = config as Record; + ctx.state.facetProjectConfig = { + success: true, + result: { path: composeProjectFile.path, projectRoot }, + error: null, + }; + + return { path: composeProjectFile.path, root: projectRoot, config }; +} + +export async function writeComposeProject( + project: LoadedComposeProject, + config: ComposeProjectConfig, +): Promise { + await atomicWriteFile(project.path, `${JSON.stringify(config, null, 2)}\n`); +} diff --git a/cli/src/modules/facetConfig/types.ts b/cli/src/modules/facetConfig/types.ts new file mode 100644 index 00000000..3ee61c73 --- /dev/null +++ b/cli/src/modules/facetConfig/types.ts @@ -0,0 +1,91 @@ +import type { ConstructorEntry, Erc165Entry } from "../config/types"; + +export type FacetRegistration = { + source: "local" | "package"; + contract: string; + package?: string; + mod?: string; + constructor: ConstructorEntry[] | undefined; + erc165?: Erc165Entry; +}; + +/** + * A diamond entry in `compose.json`. + * + * Entries read back from disk are only validated as an object container + * (`projectConfig.ts`), so stored facet values are compared defensively as + * `unknown` (`sameRegistration`) rather than trusted as `FacetRegistration`. + */ +export type ComposeDiamondConfig = { + contract: string; + facets: Record; + [key: string]: unknown; +}; + +export type ComposeProjectConfig = { + project?: string; + compose?: string; + framework?: string; + diamonds: Record; + [key: string]: unknown; +}; + +export type CatalogFacetReference = { + name: string; + source?: undefined; +}; + +export type LocalFacetReference = { + name: string; + source: "local"; + contract: string; +}; + +export type PackageFacetReference = { + name: string; + source: "package"; + contract: string; + package: string; +}; + +export type FacetInstallReference = + | CatalogFacetReference + | LocalFacetReference + | PackageFacetReference; + +export type InstallFacetBatch = { + version: 1; + operation: "install"; + diamonds: Record; +}; + +export type RemoveFacetBatch = { + version: 1; + operation: "remove"; + diamonds: Record; +}; + +export type FacetBatch = InstallFacetBatch | RemoveFacetBatch; + +export type ResolvedFacetInstall = { + name: string; + contractName?: string; + registration: FacetRegistration; + catalogSource?: string; + targetGroup?: "diamond" | "libraries" | "facets"; +}; + +export type FacetBatchOperation = "install" | "remove"; + +export type FacetMutationResult = { + operation: FacetBatchOperation; + projectRoot: string; + composeJsonPath: string; + diamondChanges: Array<{ + diamond: string; + facets: string[]; + }>; + changed: boolean; + materializedFiles?: string[]; + manifestPath?: string; +}; diff --git a/cli/src/modules/facetSections.ts b/cli/src/modules/facetSections.ts new file mode 100644 index 00000000..061993fd --- /dev/null +++ b/cli/src/modules/facetSections.ts @@ -0,0 +1,29 @@ +type GroupedFacet = { group: "base" | "library" }; + +export type RenderedFacetSections = { + baseLines: string; + libraryLines: string; +}; + +/** Renders base facets first, followed by library facets, into separate sections. */ +export function renderFacetSections( + facets: readonly T[], + renderFacet: (facet: T) => string, + separator: string, +): RenderedFacetSections { + const baseFacets: T[] = []; + const libraryFacets: T[] = []; + + for (const facet of facets) { + if (facet.group === "base") { + baseFacets.push(facet); + } else { + libraryFacets.push(facet); + } + } + + return { + baseLines: baseFacets.map(renderFacet).join(separator), + libraryLines: libraryFacets.map(renderFacet).join(separator), + }; +} diff --git a/cli/src/modules/info/loader.ts b/cli/src/modules/info/loader.ts index 14fc2a22..31172962 100644 --- a/cli/src/modules/info/loader.ts +++ b/cli/src/modules/info/loader.ts @@ -1,7 +1,6 @@ -import fs from "node:fs/promises"; import { ComposeContext } from "../../context/types"; import { DiamondInfo, FacetInfo } from "./types"; -import { findFileAncestor } from "../../utils/files"; +import { locateComposeProjectFile, readComposeProjectFile } from "../../utils/composeProjectFile"; /** * Reads and parses compose.json, storing the result in ctx.state.infoLoader. @@ -11,9 +10,9 @@ import { findFileAncestor } from "../../utils/files"; */ export async function loadComposeJson(ctx: ComposeContext): Promise { const startDir = String(ctx.param.projectRoot ?? process.cwd()); - const composeJsonPath = await findFileAncestor(startDir, "compose.json"); + const composeProjectFile = await locateComposeProjectFile(startDir); - if (!composeJsonPath) { + if (!composeProjectFile) { ctx.state.infoLoader = { success: false, result: null, @@ -26,14 +25,13 @@ export async function loadComposeJson(ctx: ComposeContext): Promise { - const candidates = [ - path.join(projectRoot, "node_modules", packageName), - path.join(projectRoot, "lib", "Compose", "src"), - ]; - - for (const candidate of candidates) { - try { - const entries = await fs.readdir(candidate, { recursive: true, withFileTypes: true }); - for (const entry of entries) { - if (entry.isFile() && entry.name === `${facetName}.sol`) { - return path.join(entry.parentPath ?? entry.path, entry.name); - } - } - } catch { - // Directory doesn't exist, continue - } - } - - return null; + const matches = await findPackageFacetSourceMatches(projectRoot, packageName, facetName, { + ignoreReadErrors: true, + stopAfterFirstMatch: true, + }); + return matches[0] ?? null; } /** diff --git a/cli/src/modules/pipelineBuilder/module.ts b/cli/src/modules/pipelineBuilder/module.ts index df1788e9..2d112d21 100644 --- a/cli/src/modules/pipelineBuilder/module.ts +++ b/cli/src/modules/pipelineBuilder/module.ts @@ -6,6 +6,8 @@ import { BuildPipeline } from "../../pipelines/buildPipeline"; import { RPCPipeline } from "../../pipelines/rpcPipeline"; import { InspectPipeline } from "../../pipelines/inspectPipeline"; import { ValidatePipeline } from "../../pipelines/validatePipeline"; +import { InstallPipeline } from "../../pipelines/installPipeline"; +import { RemovePipeline } from "../../pipelines/removePipeline"; /** * Pipeline builder module that routes CLI commands to their corresponding pipelines. @@ -76,6 +78,20 @@ export const PipelineBuilderModule = { error: null, }; return InspectPipeline.execute(ctx); + case "install": + ctx.state.commandSelected = { + success: true, + result: { command: ctx.param.command, diamond: ctx.param.diamond }, + error: null, + }; + return InstallPipeline.execute(ctx); + case "remove": + ctx.state.commandSelected = { + success: true, + result: { command: ctx.param.command, diamond: ctx.param.diamond }, + error: null, + }; + return RemovePipeline.execute(ctx); default: ctx.state.commandRouting = { success: false, diff --git a/cli/src/modules/scaffolding/module.ts b/cli/src/modules/scaffolding/module.ts index 40e7c7b6..eb82bef8 100644 --- a/cli/src/modules/scaffolding/module.ts +++ b/cli/src/modules/scaffolding/module.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { IFrameworkAdapter } from "../../adapters/IFrameworkAdapter/interface"; import { ComposeContext, ModuleState } from "../../context/types"; import { VERSION } from "../../utils/metadata"; -import { copyFileIfMissing, parsePackageName, resolveLocalSolidityImportClosure, toPosixPath } from "../../utils/files"; +import { copyFileIfMissing, parsePackageName, planImportClosureTargets, resolveLocalSolidityImportClosure, toPosixPath } from "../../utils/files"; import { extractSolidityContractBody, parseExportedSelectorSignatures, parseSolidityFunctions } from "../../utils/solidityText"; import { contractNameFromSourcePath, isComposePackagePath, resolveCatalogSourceForRead } from "../../utils/soliditySources"; import { SeedFile, FacetScanResult, ScaffoldMapEntry } from "./types"; @@ -61,22 +61,9 @@ export const ScaffoldingModule = { const seedSources = allSeeds.map((x) => x.source); const closure = await resolveLocalSolidityImportClosure(seedSources); - - const sourceToTarget = new Map(); - for (const seed of allSeeds) { - sourceToTarget.set(path.resolve(seed.source), seed.target); - } - - for (const file of closure) { - const directTarget = sourceToTarget.get(path.resolve(file)); - if (directTarget) { - await copyFileIfMissing(file, directTarget); - continue; - } - - const parentSeed = allSeeds.find((seed) => file.startsWith(path.dirname(path.resolve(seed.source)))); - const baseTargetDir = parentSeed ? path.dirname(parentSeed.target) : contractSourceRoot; - await copyFileIfMissing(file, path.join(baseTargetDir, path.basename(file))); + const plannedTargets = planImportClosureTargets(allSeeds, closure, contractSourceRoot); + for (const [source, target] of plannedTargets) { + await copyFileIfMissing(source, target); } const localTargetByFacetName = new Map(localFacets.map((facet, index) => [facet.name, allSeeds[index].target])); diff --git a/cli/src/modules/testGeneration/renderers.ts b/cli/src/modules/testGeneration/renderers.ts index dff21314..f2699d4a 100644 --- a/cli/src/modules/testGeneration/renderers.ts +++ b/cli/src/modules/testGeneration/renderers.ts @@ -1,6 +1,7 @@ import { TestFacetEntry, TestGenerationModel } from "./types"; import { toCamelCase } from "../../utils/strings"; import { loadTemplate } from "../../utils/codegen"; +import { renderFacetSections } from "../facetSections"; /** * Checks if an import path refers to an npm package (starts with "@"). @@ -31,8 +32,6 @@ export async function renderFoundryTest(model: TestGenerationModel): Promise facet.group === "base"); - const libraryFacets = model.facets.filter((facet) => facet.group === "library"); let index = 0; const renderSetupLine = (facet: TestFacetEntry): string => { @@ -41,8 +40,7 @@ export async function renderFoundryTest(model: TestGenerationModel): Promise string, -): { baseLines: string; libraryLines: string } { - const baseLines = model.facets - .filter((facet) => facet.group === "base") - .map(renderFacetDeploy) - .join("\n\n"); - const libraryLines = model.facets - .filter((facet) => facet.group === "library") - .map(renderFacetDeploy) - .join("\n\n"); - - return { baseLines, libraryLines }; -} - /** * Renders a complete Hardhat + ethers.js test file for the Diamond. * @@ -109,7 +83,7 @@ function renderHardhatDeploySections( export async function renderHardhatEthersTest(model: TestGenerationModel): Promise { const [template, { baseLines, libraryLines }] = await Promise.all([ loadTemplate("test", "hardhat-ethers-test.ts"), - renderHardhatDeploySections(model, renderHardhatEthersFacetDeploy), + renderFacetSections(model.facets, renderHardhatEthersFacetDeploy, "\n\n"), ]); return template @@ -127,7 +101,7 @@ export async function renderHardhatEthersTest(model: TestGenerationModel): Promi export async function renderHardhatViemTest(model: TestGenerationModel): Promise { const [template, { baseLines, libraryLines }] = await Promise.all([ loadTemplate("test", "hardhat-viem-test.ts"), - renderHardhatDeploySections(model, renderHardhatViemFacetDeploy), + renderFacetSections(model.facets, renderHardhatViemFacetDeploy, "\n\n"), ]); return template diff --git a/cli/src/modules/validation/astSelectors.ts b/cli/src/modules/validation/astSelectors.ts index 18a29be8..d9f3336e 100644 --- a/cli/src/modules/validation/astSelectors.ts +++ b/cli/src/modules/validation/astSelectors.ts @@ -1,12 +1,16 @@ import { SolidityAstSource } from "../../adapters/IFrameworkAdapter/interface"; import { matchesAstSource } from "./astIdentity"; +import { + astNode, + childNodes, + numberArray, + stringValue, + walkAst, + walkAstWithAncestors, + type AstNode, +} from "./astUtils"; import { FacetReference, FacetScanResult, FunctionInfo } from "./types"; -type AstNode = Record & { - id?: number; - nodeType: string; -}; - type AstIndex = { nodesById: Map; contractsById: Map; @@ -123,7 +127,7 @@ function collectExportedSignatures( ): string[] { const signatures = new Set(); - walkAst(exportFunction.body, (node, ancestors) => { + walkAstWithAncestors(exportFunction.body, (node, ancestors) => { if (node.nodeType === "MemberAccess" && node.memberName === "selector") { const expression = astNode(node.expression); const declarationId = expression && typeof expression.referencedDeclaration === "number" @@ -226,27 +230,6 @@ function buildAstIndex(sources: SolidityAstSource[]): AstIndex { return { nodesById, contractsById, sourceByContractId }; } -function walkAst( - value: unknown, - visitor: (node: AstNode, ancestors: AstNode[]) => void, - ancestors: AstNode[] = [], -): void { - if (Array.isArray(value)) { - for (const child of value) walkAst(child, visitor, ancestors); - return; - } - - const node = astNode(value); - if (!node) return; - - visitor(node, ancestors); - for (const child of Object.values(node)) { - if (child && typeof child === "object") { - walkAst(child, visitor, [...ancestors, node]); - } - } -} - function isKeccak256Call(node: AstNode): boolean { if (node.nodeType !== "FunctionCall") return false; const expression = astNode(node.expression); @@ -259,24 +242,3 @@ function isBytes4Conversion(node: AstNode): boolean { const typeName = expression && astNode(expression.typeName); return typeName?.nodeType === "ElementaryTypeName" && typeName.name === "bytes4"; } - -function childNodes(node: AstNode | null, key: string): AstNode[] { - const value = node?.[key]; - return Array.isArray(value) - ? value.map(astNode).filter((child): child is AstNode => child !== null) - : []; -} - -function astNode(value: unknown): AstNode | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const candidate = value as Record; - return typeof candidate.nodeType === "string" ? candidate as AstNode : null; -} - -function numberArray(value: unknown): number[] { - return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number") : []; -} - -function stringValue(value: unknown): string { - return typeof value === "string" ? value : ""; -} diff --git a/cli/src/modules/validation/astUtils.ts b/cli/src/modules/validation/astUtils.ts new file mode 100644 index 00000000..851390ae --- /dev/null +++ b/cli/src/modules/validation/astUtils.ts @@ -0,0 +1,90 @@ +export type AstNode = Record & { + id?: number; + nodeType: string; +}; + +export function astNode(value: unknown): AstNode | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as Record; + return typeof candidate.nodeType === "string" ? candidate as AstNode : null; +} + +export function childNodes(node: AstNode | null, key: string): AstNode[] { + const value = node?.[key]; + return Array.isArray(value) + ? value.map(astNode).filter((child): child is AstNode => child !== null) + : []; +} + +export function numberArray(value: unknown): number[] { + return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number") : []; +} + +export function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +type AstVisitor = ( + node: AstNode, + ancestors: readonly AstNode[] | null, + ownerContractId: number | null, +) => void; + +/** Walks AST nodes in object and array order without collecting traversal context. */ +export function walkAst(value: unknown, visitor: (node: AstNode) => void): void { + walkAstInternal(value, (node) => visitor(node), null, null, false); +} + +/** Walks AST nodes while passing the ancestors of each node (excluding that node). */ +export function walkAstWithAncestors( + value: unknown, + visitor: (node: AstNode, ancestors: readonly AstNode[]) => void, +): void { + walkAstInternal(value, (node, ancestors) => visitor(node, ancestors ?? []), [], null, false); +} + +/** Walks AST nodes while tracking the current enclosing contract ID. */ +export function walkAstWithContract( + value: unknown, + ownerContractId: number | null, + visitor: (node: AstNode, ownerContractId: number | null) => void, +): void { + walkAstInternal( + value, + (node, _ancestors, currentOwnerContractId) => visitor(node, currentOwnerContractId), + null, + ownerContractId, + true, + ); +} + +function walkAstInternal( + value: unknown, + visitor: AstVisitor, + ancestors: readonly AstNode[] | null, + ownerContractId: number | null, + trackContractOwner: boolean, +): void { + if (Array.isArray(value)) { + for (const child of value) { + walkAstInternal(child, visitor, ancestors, ownerContractId, trackContractOwner); + } + return; + } + + const node = astNode(value); + if (!node) return; + + const currentOwnerContractId = trackContractOwner && + node.nodeType === "ContractDefinition" && typeof node.id === "number" + ? node.id + : ownerContractId; + visitor(node, ancestors, currentOwnerContractId); + + const childAncestors = ancestors === null ? null : [...ancestors, node]; + for (const child of Object.values(node)) { + if (child && typeof child === "object") { + walkAstInternal(child, visitor, childAncestors, currentOwnerContractId, trackContractOwner); + } + } +} diff --git a/cli/src/modules/validation/module.ts b/cli/src/modules/validation/module.ts index c8b87f95..616e94fe 100644 --- a/cli/src/modules/validation/module.ts +++ b/cli/src/modules/validation/module.ts @@ -23,6 +23,8 @@ import { import { showIncomplete, showReport, showSuccess } from "./output"; import { getResolvedFacetSources, resolveFacetSources } from "./sourceResolution"; import { IFrameworkAdapter } from "../../adapters/IFrameworkAdapter/interface"; +import type { IHashingAdapter } from "../../adapters/IHashingAdapter/interface"; +import type { ValidationProject } from "./project"; /** * Validates facet scans for selector export guidance and collision-free layouts. @@ -42,6 +44,44 @@ export const ValidationModule = { getVirtualStorageLayoutValidationState, getResolvedFacetSources, + /** Runs source-only selector and virtual-storage validation for a project model. */ + async validateProjectSources( + ctx: ComposeContext, + project: ValidationProject, + adapter: IFrameworkAdapter, + hashing: IHashingAdapter, + ): Promise { + const sources = await adapter.compileAst( + ctx, + project.facetSources.map((facet) => facet.sourcePath), + ); + const scopes = project.diamonds.map((diamond) => ({ + diamondName: diamond.name, + facets: diamond.facets, + })); + + ctx = ValidationModule.scanFacetSelectors(ctx, sources, project.facetSources); + ctx = ValidationModule.buildVirtualStorageLayout( + ctx, + sources, + project.facetSources, + scopes, + ); + ctx = await ValidationModule.validateSelectorExports(ctx); + ctx = await ValidationModule.detectSelectorCollisions(ctx, { hashing, scopes }); + + const selectorCollisions = getSelectorCollisionValidationState(ctx); + const virtualStorageLayout = getVirtualStorageLayoutValidationState(ctx); + const success = selectorCollisions?.success === true && virtualStorageLayout?.success === true; + const error = selectorCollisions?.error ?? virtualStorageLayout?.error ?? null; + ctx.state.validationSourceProject = { + success, + result: { checkedFacets: project.facetSources.length }, + error, + }; + return ctx; + }, + /** Resolves selected Compose package facets into compiler input paths. */ async resolveComposeFacetSources( ctx: ComposeContext, diff --git a/cli/src/modules/validation/project.ts b/cli/src/modules/validation/project.ts index 5e655053..885d4d23 100644 --- a/cli/src/modules/validation/project.ts +++ b/cli/src/modules/validation/project.ts @@ -1,7 +1,6 @@ -import fs from "node:fs/promises"; import path from "node:path"; import { ComposeContext } from "../../context/types"; -import { findFileAncestor } from "../../utils/files"; +import { locateComposeProjectFile, readComposeProjectFile } from "../../utils/composeProjectFile"; import { ResolvedFacetSource } from "./types"; import { ComposeProjectFacet, @@ -50,19 +49,19 @@ type PendingFacet = { /** Loads validation inputs from the nearest Compose project definition. */ export async function loadValidationProject( ctx: ComposeContext, + composeJsonOverride?: Record, ): Promise { - const startDirectory = path.resolve(String(ctx.param.projectRoot ?? process.cwd())); - const composeJsonPath = await findFileAncestor(startDirectory, "compose.json"); - if (!composeJsonPath) { + const startDirectory = String(ctx.param.projectRoot ?? process.cwd()); + const composeProjectFile = await locateComposeProjectFile(startDirectory); + if (!composeProjectFile) { throw new Error( "compose.json not found. Run 'compose init' first or navigate to a Compose project directory.", ); } - const composeJson = JSON.parse( - await fs.readFile(composeJsonPath, "utf8"), - ) as ComposeProjectDefinition; - const projectRoot = path.dirname(composeJsonPath); + const composeJson = (composeJsonOverride as ComposeProjectDefinition | undefined) ?? + (await readComposeProjectFile(composeProjectFile.path)) as ComposeProjectDefinition; + const projectRoot = composeProjectFile.root; const configuredFramework = String(composeJson.framework ?? ""); const framework = String(ctx.param.framework ?? configuredFramework); if (framework !== "foundry" && framework !== "hardhat") { @@ -141,7 +140,7 @@ export async function loadValidationProject( ctx.state.validationProject = { success: true, result: { - composeJsonPath, + composeJsonPath: composeProjectFile.path, projectRoot, framework, diamonds, diff --git a/cli/src/modules/validation/projectSourceResolution.ts b/cli/src/modules/validation/projectSourceResolution.ts index b28760d2..24490c02 100644 --- a/cli/src/modules/validation/projectSourceResolution.ts +++ b/cli/src/modules/validation/projectSourceResolution.ts @@ -1,5 +1,5 @@ -import fs from "node:fs/promises"; import path from "node:path"; +import { findPackageFacetSourceMatches } from "../../utils/packageFacetSources"; import { ResolvedFacetSource } from "./types"; export type ComposeProjectFacet = { @@ -45,25 +45,7 @@ async function findPackageFacetSource( packageName: string, facetName: string, ): Promise { - const candidates = [ - path.join(projectRoot, "node_modules", packageName), - path.join(projectRoot, "lib", "Compose", "src"), - ]; - const matches: string[] = []; - - for (const candidate of candidates) { - try { - const entries = await fs.readdir(candidate, { recursive: true, withFileTypes: true }); - for (const entry of entries) { - if (entry.isFile() && entry.name === `${facetName}.sol`) { - matches.push(path.join(entry.parentPath, entry.name)); - } - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - } - + const matches = await findPackageFacetSourceMatches(projectRoot, packageName, facetName); const uniqueMatches = [...new Set(matches.map((match) => path.resolve(match)))]; if (uniqueMatches.length === 0) { throw new Error(`Package facet source not found: ${packageName}/${facetName}.sol`); diff --git a/cli/src/modules/validation/virtualStorageLayout.ts b/cli/src/modules/validation/virtualStorageLayout.ts index 4ff2546d..e438eebc 100644 --- a/cli/src/modules/validation/virtualStorageLayout.ts +++ b/cli/src/modules/validation/virtualStorageLayout.ts @@ -1,6 +1,15 @@ import { keccak256, stringToBytes, toHex } from "viem"; import { SolidityAstSource } from "../../adapters/IFrameworkAdapter/interface"; import { matchesAstSource } from "./astIdentity"; +import { + astNode, + childNodes, + numberArray, + stringValue, + walkAst, + walkAstWithContract, + type AstNode, +} from "./astUtils"; import { DiamondValidationScope, FacetReference, @@ -58,11 +67,6 @@ export function buildScopedVirtualStorageLayout( }; } -type AstNode = Record & { - id?: number; - nodeType: string; -}; - type ContainerKind = "mapping" | "dynamic-array" | "fixed-array"; type ChildLayout = { @@ -296,7 +300,7 @@ function emitRecord(options: { }; originsByRecord.set(record, analysis.origins); const records = [record]; - const warnings = analysis.warnings.map((warning) => ({ + const warnings: VirtualStorageLayoutWarning[] = analysis.warnings.map((warning) => ({ sourceName: options.root.sourceName, contractName: options.root.contractName, storagePath: warning.storagePath, @@ -1248,62 +1252,10 @@ function arraysEqual(left: T[], right: T[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } -function walkAst(value: unknown, visitor: (node: AstNode) => void): void { - if (Array.isArray(value)) { - for (const child of value) walkAst(child, visitor); - return; - } - const node = astNode(value); - if (!node) return; - visitor(node); - for (const child of Object.values(node)) { - if (child && typeof child === "object") walkAst(child, visitor); - } -} - -function walkAstWithContract( - value: unknown, - ownerContractId: number | null, - visitor: (node: AstNode, ownerContractId: number | null) => void, -): void { - if (Array.isArray(value)) { - for (const child of value) walkAstWithContract(child, ownerContractId, visitor); - return; - } - const node = astNode(value); - if (!node) return; - const nextOwner = node.nodeType === "ContractDefinition" && typeof node.id === "number" - ? node.id - : ownerContractId; - visitor(node, nextOwner); - for (const child of Object.values(node)) { - if (child && typeof child === "object") walkAstWithContract(child, nextOwner, visitor); - } -} - -function childNodes(node: AstNode | null, key: string): AstNode[] { - const value = node?.[key]; - return Array.isArray(value) - ? value.map(astNode).filter((child): child is AstNode => child !== null) - : []; -} - -function astNode(value: unknown): AstNode | null { - return isRecord(value) && typeof value.nodeType === "string" ? value as AstNode : null; -} - -function numberArray(value: unknown): number[] { - return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number") : []; -} - function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function stringValue(value: unknown): string { - return typeof value === "string" ? value : ""; -} - function hexByte(value: number): string { return `0x${value.toString(16).padStart(2, "0")}`; } diff --git a/cli/src/pipelines/installPipeline.ts b/cli/src/pipelines/installPipeline.ts new file mode 100644 index 00000000..c20d7e1a --- /dev/null +++ b/cli/src/pipelines/installPipeline.ts @@ -0,0 +1,8 @@ +import type { ComposeContext } from "../context/types"; +import { FacetConfigModule } from "../modules/facetConfig/module"; + +export const InstallPipeline = { + async execute(ctx: ComposeContext): Promise { + return FacetConfigModule.execute(ctx, "install"); + }, +}; diff --git a/cli/src/pipelines/removePipeline.ts b/cli/src/pipelines/removePipeline.ts new file mode 100644 index 00000000..b128577b --- /dev/null +++ b/cli/src/pipelines/removePipeline.ts @@ -0,0 +1,8 @@ +import type { ComposeContext } from "../context/types"; +import { FacetConfigModule } from "../modules/facetConfig/module"; + +export const RemovePipeline = { + async execute(ctx: ComposeContext): Promise { + return FacetConfigModule.execute(ctx, "remove"); + }, +}; diff --git a/cli/src/pipelines/validatePipeline.ts b/cli/src/pipelines/validatePipeline.ts index 1fe9244a..e663af04 100644 --- a/cli/src/pipelines/validatePipeline.ts +++ b/cli/src/pipelines/validatePipeline.ts @@ -32,27 +32,7 @@ export const ValidatePipeline = { throw new Error(`${framework} adapter was not resolved.`); } - const sources = await adapter.compileAst( - ctx, - project.facetSources.map((facet) => facet.sourcePath), - ); - const scopes = project.diamonds.map((diamond) => ({ - diamondName: diamond.name, - facets: diamond.facets, - })); - - ctx = ValidationModule.scanFacetSelectors(ctx, sources, project.facetSources); - ctx = ValidationModule.buildVirtualStorageLayout( - ctx, - sources, - project.facetSources, - scopes, - ); - ctx = await ValidationModule.validateSelectorExports(ctx); - ctx = await ValidationModule.detectSelectorCollisions(ctx, { - hashing: deps.hashing, - scopes, - }); + ctx = await ValidationModule.validateProjectSources(ctx, project, adapter, deps.hashing); const selectorCollisions = ValidationModule.getSelectorCollisionValidationState(ctx); const virtualStorageLayout = ValidationModule.getVirtualStorageLayoutValidationState(ctx); diff --git a/cli/src/utils/composeProjectFile.ts b/cli/src/utils/composeProjectFile.ts new file mode 100644 index 00000000..2a147579 --- /dev/null +++ b/cli/src/utils/composeProjectFile.ts @@ -0,0 +1,23 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { findFileAncestor } from "./files"; + +export type ComposeProjectFile = { + path: string; + root: string; +}; + +/** Finds the nearest compose.json and returns its path and containing project root. */ +export async function locateComposeProjectFile( + startDirectory: string, +): Promise { + const composeJsonPath = await findFileAncestor(path.resolve(startDirectory), "compose.json"); + return composeJsonPath + ? { path: composeJsonPath, root: path.dirname(composeJsonPath) } + : null; +} + +/** Reads and parses compose.json without applying a module-specific schema. */ +export async function readComposeProjectFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; +} diff --git a/cli/src/utils/files.ts b/cli/src/utils/files.ts index c12d0dd7..2312ae60 100644 --- a/cli/src/utils/files.ts +++ b/cli/src/utils/files.ts @@ -162,6 +162,64 @@ export function parsePackageName(importPath: string): string { return parts[0]; } +/** A seed file pair describing a Solidity source and its copy destination. */ +export type SeedCopyPlan = { + source: string; + target: string; +}; + +/** + * Plans copy destinations for a set of seed Solidity files and their local + * import closure, preserving the relative folder structure each file has + * under its seed's directory. + * + * Preserving the relative structure keeps the seed's relative imports + * (`./libraries/Lib.sol`) resolving after the files are copied into the + * project. Dependencies outside every seed directory fall back to + * `fallbackDir` keyed by basename. + * + * @param seeds - The seed source/target pairs being copied. + * @param closure - The full import closure (including the seeds themselves). + * @param fallbackDir - Destination directory for dependencies outside every seed directory. + * @returns A Map from resolved source path to resolved target path. + */ +export function planImportClosureTargets( + seeds: SeedCopyPlan[], + closure: Iterable, + fallbackDir: string, +): Map { + const seedTargets = new Map( + seeds.map((seed) => [path.resolve(seed.source), path.resolve(seed.target)]), + ); + const targets = new Map(); + + for (const file of closure) { + const resolvedSource = path.resolve(file); + const seedTarget = seedTargets.get(resolvedSource); + if (seedTarget) { + targets.set(resolvedSource, seedTarget); + continue; + } + + const parent = seeds.find((seed) => { + const relative = path.relative(path.dirname(path.resolve(seed.source)), resolvedSource); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); + }); + if (parent) { + const relative = path.relative(path.dirname(path.resolve(parent.source)), resolvedSource); + targets.set( + resolvedSource, + path.resolve(path.dirname(path.resolve(parent.target)), relative), + ); + continue; + } + + targets.set(resolvedSource, path.resolve(fallbackDir, path.basename(resolvedSource))); + } + + return targets; +} + /** * Resolves the transitive closure of local Solidity imports reachable from a set of seed source files. * Follows relative imports (`./` or `../`) recursively, skipping already-visited files. diff --git a/cli/src/utils/packageFacetSources.ts b/cli/src/utils/packageFacetSources.ts new file mode 100644 index 00000000..74468ad5 --- /dev/null +++ b/cli/src/utils/packageFacetSources.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export type PackageFacetSearchOptions = { + /** Skip unreadable search roots, as the info scanner historically does. */ + ignoreReadErrors?: boolean; + /** Stop scanning as soon as the first matching source is found. */ + stopAfterFirstMatch?: boolean; +}; + +/** Finds matching Solidity source files in the project's Node and Foundry package roots. */ +export async function findPackageFacetSourceMatches( + projectRoot: string, + packageName: string, + facetName: string, + options: PackageFacetSearchOptions = {}, +): Promise { + const candidates = [ + path.join(projectRoot, "node_modules", packageName), + path.join(projectRoot, "lib", "Compose", "src"), + ]; + const matches: string[] = []; + + for (const candidate of candidates) { + try { + const entries = await fs.readdir(candidate, { recursive: true, withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name === `${facetName}.sol`) { + matches.push(path.join(entry.parentPath ?? entry.path, entry.name)); + if (options.stopAfterFirstMatch) return matches; + } + } + } catch (error) { + if ( + options.ignoreReadErrors || + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + continue; + } + throw error; + } + } + + return matches; +} diff --git a/cli/test/comander.test.ts b/cli/test/comander.test.ts index 669259db..34407125 100644 --- a/cli/test/comander.test.ts +++ b/cli/test/comander.test.ts @@ -110,3 +110,42 @@ describe("inspect command", () => { }); }); }); + +describe("facet configuration commands", () => { + it("parses direct install arguments and batch manifest options", () => { + expect(parseArgs([ + "node", + "compose", + "install", + "ERC20BurnFacet", + "src/facets/FeeFacet.sol:FeeFacet", + "--diamond", + "Token", + "--project-root", + "./project", + ])).toEqual({ + command: "install", + flags: { + facets: ["ERC20BurnFacet", "src/facets/FeeFacet.sol:FeeFacet"], + diamond: "Token", + projectRoot: "./project", + }, + }); + + expect(parseArgs(["node", "compose", "install", "--manifest", "add-fees"])).toEqual({ + command: "install", + flags: { manifest: "add-fees" }, + }); + }); + + it("normalizes the i and rm aliases to canonical command names", () => { + expect(parseArgs(["node", "compose", "i", "FacetA", "--diamond", "Token"])).toEqual({ + command: "install", + flags: { facets: ["FacetA"], diamond: "Token" }, + }); + expect(parseArgs(["node", "compose", "rm", "FacetA", "--diamond", "Token"])).toEqual({ + command: "remove", + flags: { facets: ["FacetA"], diamond: "Token" }, + }); + }); +}); diff --git a/cli/test/modules/deployGeneration/renderers.test.ts b/cli/test/modules/deployGeneration/renderers.test.ts new file mode 100644 index 00000000..d08d0dc4 --- /dev/null +++ b/cli/test/modules/deployGeneration/renderers.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + renderHardhatEthersDeployScript, + renderHardhatViemDeployScript, +} from "../../../src/modules/deployGeneration/renderers"; +import type { DeployGenerationModel } from "../../../src/modules/deployGeneration/types"; + +const model: DeployGenerationModel = { + outputPath: "scripts/deploy.ts", + facets: [ + { + facetName: "LibraryFirstFacet", + contractName: "LibraryFirstFacet", + importPath: "facets/LibraryFirstFacet.sol", + source: "library-required", + origin: "local", + group: "library", + }, + { + facetName: "BaseFirstFacet", + contractName: "BaseFirstFacet", + importPath: "facets/BaseFirstFacet.sol", + source: "base-required", + origin: "local", + group: "base", + }, + { + facetName: "BaseSecondFacet", + contractName: "BaseSecondFacet", + importPath: "facets/BaseSecondFacet.sol", + source: "extension", + origin: "local", + group: "base", + }, + ], +}; + +function expectBaseSectionsBeforeLibraries(source: string): void { + const baseFirst = source.indexOf("const baseFirstFacet ="); + const baseSecond = source.indexOf("const baseSecondFacet ="); + const library = source.indexOf("const libraryFirstFacet ="); + + expect(baseFirst).toBeGreaterThanOrEqual(0); + expect(baseSecond).toBeGreaterThan(baseFirst); + expect(library).toBeGreaterThan(baseSecond); +} + +describe("Hardhat deploy renderers", () => { + it("renders ethers deployment and logging after grouping base facets first", async () => { + const source = await renderHardhatEthersDeployScript(model); + + expectBaseSectionsBeforeLibraries(source); + expect(source).toContain("await baseFirstFacet.waitForDeployment();"); + expect(source).toContain('console.log("BaseFirstFacet:", await baseFirstFacet.getAddress());'); + expect(source).toContain("facets.push(await baseFirstFacet.getAddress());"); + }); + + it("renders viem deployment and address access after grouping base facets first", async () => { + const source = await renderHardhatViemDeployScript(model); + + expectBaseSectionsBeforeLibraries(source); + expect(source).toContain('console.log("BaseFirstFacet:", baseFirstFacet.address);'); + expect(source).toContain("facets.push(baseFirstFacet.address);"); + expect(source).not.toContain("waitForDeployment"); + }); +}); diff --git a/cli/test/modules/facetConfig/facetResolver.test.ts b/cli/test/modules/facetConfig/facetResolver.test.ts new file mode 100644 index 00000000..f2ffeebb --- /dev/null +++ b/cli/test/modules/facetConfig/facetResolver.test.ts @@ -0,0 +1,146 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { BasesCatalog } from "../../../src/modules/config/types"; +import { + findLocalContractMatches, + listAvailableCatalogFacetNames, + resolveFacetInstallReference, +} from "../../../src/modules/facetConfig/facetResolver"; + +const temporaryRoots: string[] = []; + +async function createProject(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-facet-resolver-")); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, "src", "facets"), { recursive: true }); + return root; +} + +async function writeSource(root: string, relativePath: string, contents: string): Promise { + const target = path.join(root, relativePath); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, contents, "utf8"); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("facet install choices", () => { + it("omits catalog facets already registered on the selected diamond", () => { + const catalog: BasesCatalog = { + globals: { + diamond: { + label: "Diamond", + required: { DiamondInspectFacet: { path: "@perfect-abstractions/compose/diamond/DiamondInspectFacet.sol" } }, + optional: { DiamondUpgradeFacet: { path: "@perfect-abstractions/compose/diamond/DiamondUpgradeFacet.sol" } }, + }, + libraries: { + label: "Libraries", + required: {}, + optional: { ERC165Facet: { path: "@perfect-abstractions/compose/interfaceDetection/ERC165/ERC165Facet.sol" } }, + }, + }, + features: { + "erc-20": { + label: "ERC-20", + required: { ERC20DataFacet: { path: "@perfect-abstractions/compose/token/ERC20/Data/ERC20DataFacet.sol" } }, + optional: { ERC20BurnFacet: { path: "@perfect-abstractions/compose/token/ERC20/Burn/ERC20BurnFacet.sol" } }, + }, + }, + }; + + expect(listAvailableCatalogFacetNames(catalog, ["DiamondInspectFacet", "ERC20DataFacet"])).toEqual([ + "DiamondUpgradeFacet", + "ERC165Facet", + "ERC20BurnFacet", + ]); + }); +}); + +describe("bare facet references", () => { + it("resolves a unique project source without a path", async () => { + const root = await createProject(); + await writeSource(root, "src/facets/FeeFacet.sol", "contract FeeFacet {}\n"); + + const resolved = await resolveFacetInstallReference("FeeFacet", { globals: {}, features: {} }, root); + + expect(resolved.name).toBe("FeeFacet"); + expect(resolved.registration).toEqual({ + source: "local", + contract: "src/facets/FeeFacet.sol:FeeFacet", + constructor: undefined, + }); + }); + + it("matches a declared contract inside a differently named source", async () => { + const root = await createProject(); + await writeSource(root, "src/facets/fees.sol", "contract FeeCollector {}\n"); + + const resolved = await resolveFacetInstallReference("FeeCollector", { globals: {}, features: {} }, root); + + expect(resolved.registration).toMatchObject({ contract: "src/facets/fees.sol:FeeCollector" }); + }); + + it("prefers the catalog over a project source with the same name", async () => { + const root = await createProject(); + await writeSource(root, "src/facets/FeeFacet.sol", "contract FeeFacet {}\n"); + const catalog: BasesCatalog = { + globals: {}, + features: { + fees: { + label: "Fees", + required: {}, + optional: { FeeFacet: { path: "@perfect-abstractions/compose/token/ERC20/Fee/FeeFacet.sol" } }, + }, + }, + }; + + const resolved = await resolveFacetInstallReference("FeeFacet", catalog, root); + + expect(resolved.registration).toMatchObject({ source: "package", package: "@perfect-abstractions/compose" }); + }); + + it("lists every match when several sources match and no selector is available", async () => { + const root = await createProject(); + await writeSource(root, "src/facets/FeeFacet.sol", "contract FeeFacet {}\n"); + await writeSource(root, "src/libraries/alt/FeeFacet.sol", "contract FeeFacet {}\n"); + + await expect(resolveFacetInstallReference("FeeFacet", { globals: {}, features: {} }, root)) + .rejects.toThrow(/matches several project sources:[\s\S]*src\/facets\/FeeFacet\.sol[\s\S]*src\/libraries\/alt\/FeeFacet\.sol/); + }); + + it("uses the selector's choice when several sources match", async () => { + const root = await createProject(); + await writeSource(root, "src/facets/FeeFacet.sol", "contract FeeFacet {}\n"); + await writeSource(root, "src/libraries/alt/FeeFacet.sol", "contract FeeFacet {}\n"); + + const resolved = await resolveFacetInstallReference( + "FeeFacet", + { globals: {}, features: {} }, + root, + { selectLocalMatch: async (candidates) => candidates[1] }, + ); + + expect(resolved.registration).toMatchObject({ contract: "src/libraries/alt/FeeFacet.sol:FeeFacet" }); + }); + + it("ignores dependency and generated directories", async () => { + const root = await createProject(); + await writeSource(root, "node_modules/pkg/FeeFacet.sol", "contract FeeFacet {}\n"); + await writeSource(root, "out/FeeFacet.sol", "contract FeeFacet {}\n"); + + expect(await findLocalContractMatches("FeeFacet", root)).toEqual([]); + }); + + it("falls back to substring matches when no exact source exists", async () => { + const root = await createProject(); + await writeSource(root, "src/facets/FeeFacetV2.sol", "contract FeeFacetV2 {}\n"); + + expect(await findLocalContractMatches("FeeFacet", root)).toEqual([ + { source: "src/facets/FeeFacetV2.sol", contractName: "FeeFacetV2" }, + ]); + }); +}); diff --git a/cli/test/modules/facetConfig/manifest.test.ts b/cli/test/modules/facetConfig/manifest.test.ts new file mode 100644 index 00000000..7d8f5ea0 --- /dev/null +++ b/cli/test/modules/facetConfig/manifest.test.ts @@ -0,0 +1,98 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createInstallFacetBatch, + createRemoveFacetBatch, + readFacetBatch, + resolveFacetBatchPath, + serializeFacetBatch, + validateFacetBatch, +} from "../../../src/modules/facetConfig/manifest"; + +const temporaryRoots: string[] = []; + +async function createRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-facet-batch-")); + temporaryRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("facet batch manifests", () => { + it("resolves simple names under compose/facet-batches", async () => { + const root = await createRoot(); + expect(resolveFacetBatchPath(root, "add-fees")).toBe( + path.join(root, "compose", "facet-batches", "add-fees.json"), + ); + }); + + it("round-trips install and remove manifests", async () => { + const root = await createRoot(); + const install = createInstallFacetBatch({ + Token: [ + { name: "ERC20BurnFacet" }, + "src/facets/TransferFacet.sol:TransferFacet", + { + name: "FeeFacet", + source: "local", + contract: "src/facets/FeeFacet.sol:FeeFacet", + }, + ], + }); + const remove = createRemoveFacetBatch({ Token: ["LegacyFacet"] }); + + const installPath = path.join(root, "install.json"); + const removePath = path.join(root, "remove.json"); + await fs.writeFile(installPath, serializeFacetBatch(install), "utf8"); + await fs.writeFile(removePath, serializeFacetBatch(remove), "utf8"); + + expect(await readFacetBatch(installPath, "install")).toEqual(install); + expect(await readFacetBatch(removePath, "remove")).toEqual(remove); + }); + + it("rejects invalid versions, empty batches, and operation mismatches", () => { + expect(() => validateFacetBatch({ version: 2, operation: "install", diamonds: { Token: ["A"] } })) + .toThrow("Unsupported facet batch manifest version"); + expect(() => validateFacetBatch({ version: 1, operation: "install", diamonds: {} })) + .toThrow("at least one diamond"); + expect(() => validateFacetBatch({ version: 1, operation: "install", diamonds: { Token: ["A", "A"] } })) + .toThrow("contains duplicate facet names"); + expect(() => validateFacetBatch({ version: 1, operation: "remove", diamonds: { Token: ["A"] } }, "install")) + .toThrow("expects an 'install' manifest"); + expect(() => validateFacetBatch({ + version: 1, + operation: "install", + diamonds: { Token: [{ name: "FeeFacet", source: "local", contract: "FeeFacet.sol" }] }, + })).toThrow("must use path.sol:ContractName"); + }); + + it("rejects reserved object keys as diamond and facet names", () => { + for (const name of ["__proto__", "constructor", "prototype"]) { + expect(() => createRemoveFacetBatch({ Token: [name] })).toThrow("Facet name"); + expect(() => createInstallFacetBatch({ Token: [{ name }] })).toThrow("Facet name"); + expect(() => createRemoveFacetBatch({ [name]: ["LegacyFacet"] })).toThrow("Diamond name"); + } + }); + + it("normalizes remove references to the registered facet name", async () => { + const root = await createRoot(); + + expect(createRemoveFacetBatch({ Token: ["src/facets/FeeFacet.sol:FeeFacet"] }).diamonds.Token) + .toEqual(["FeeFacet"]); + expect(() => createRemoveFacetBatch({ Token: ["FeeFacet", "src/facets/FeeFacet.sol:FeeFacet"] })) + .toThrow("contains duplicate facet names"); + expect(() => createRemoveFacetBatch({ Token: ["src/facets/FeeFacet.sol:__proto__"] })) + .toThrow("Facet name"); + + const manifestPath = path.join(root, "remove.json"); + await fs.writeFile(manifestPath, serializeFacetBatch( + createRemoveFacetBatch({ Token: ["src/facets/FeeFacet.sol:FeeFacet"] }), + ), "utf8"); + expect((await readFacetBatch(manifestPath, "remove")).diamonds.Token).toEqual(["FeeFacet"]); + }); +}); diff --git a/cli/test/modules/facetConfig/module.test.ts b/cli/test/modules/facetConfig/module.test.ts new file mode 100644 index 00000000..742389f3 --- /dev/null +++ b/cli/test/modules/facetConfig/module.test.ts @@ -0,0 +1,512 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { IFrameworkAdapter, SolidityAstSource } from "../../../src/adapters/IFrameworkAdapter/interface"; +import type { IHashingAdapter } from "../../../src/adapters/IHashingAdapter/interface"; +import { Context } from "../../../src/context/context"; +import { DependencyKey } from "../../../src/resolver/dependencyKey"; +import { DependencyResolver } from "../../../src/resolver/dependencyResolver"; +import type { DependencyMap } from "../../../src/resolver/dependencyRegistry"; +import { ConfigModule } from "../../../src/modules/config/module"; +import type { BasesCatalog } from "../../../src/modules/config/types"; +import { FacetConfigModule } from "../../../src/modules/facetConfig/module"; +import { FACET_BATCH_DIRECTORY } from "../../../src/modules/facetConfig/manifest"; +import { contractNameFromSourcePath } from "../../../src/utils/soliditySources"; + +const temporaryRoots: string[] = []; +const emptyCatalog: BasesCatalog = { globals: {}, features: {} }; + +async function createProject(): Promise<{ + root: string; + ctx: ReturnType; + readConfig(): Promise>; +}> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-facet-config-")); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, "src", "facets"), { recursive: true }); + await fs.writeFile(path.join(root, "foundry.toml"), '[profile.default]\nsrc = "src"\n', "utf8"); + await fs.writeFile(path.join(root, "src", "facets", "ExistingFacet.sol"), "contract ExistingFacet {}\n", "utf8"); + await fs.writeFile(path.join(root, "src", "facets", "KeepFacet.sol"), "contract KeepFacet {}\n", "utf8"); + await fs.writeFile(path.join(root, "src", "facets", "RemoveFacet.sol"), "contract RemoveFacet {}\n", "utf8"); + const config = { + project: "test-project", + compose: "0.3.0", + framework: "foundry", + diamonds: { + TestDiamond: { + contract: "src/Diamond.sol:Diamond", + facets: { + ExistingFacet: { source: "local", contract: "src/facets/ExistingFacet.sol:ExistingFacet" }, + KeepFacet: { source: "local", contract: "src/facets/KeepFacet.sol:KeepFacet" }, + RemoveFacet: { source: "local", contract: "src/facets/RemoveFacet.sol:RemoveFacet" }, + }, + }, + }, + chains: { local: { rpc: "http://127.0.0.1:8545", chainId: 31337 } }, + }; + await fs.writeFile(path.join(root, "compose.json"), `${JSON.stringify(config, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(root, "compose.lock"), "keep-lock-untouched\n", "utf8"); + + const ctx = Context.create(); + ctx.param.projectRoot = root; + ctx.param.framework = "foundry"; + return { + root, + ctx, + readConfig: async () => JSON.parse(await fs.readFile(path.join(root, "compose.json"), "utf8")), + }; +} + +function createAstSource(sourcePath: string, id: number): SolidityAstSource { + const contractName = contractNameFromSourcePath(sourcePath); + return { + sourceName: sourcePath, + ast: { + id, + nodeType: "SourceUnit", + src: "0:0:0", + absolutePath: sourcePath, + nodes: [{ + id: id + 1, + nodeType: "ContractDefinition", + name: contractName, + contractKind: "contract", + linearizedBaseContracts: [id + 1], + nodes: [], + src: "0:0:0", + }], + }, + }; +} + +function mockValidationDependencies(): IFrameworkAdapter { + const adapter = { + getContractSourceRoot: (root: string) => path.join(root, "src"), + getScriptRoot: (root: string) => path.join(root, "script"), + getTestRoot: (root: string) => path.join(root, "test"), + getArtifactDir: (root: string) => path.join(root, "out"), + compile: vi.fn(async () => undefined), + compileAst: vi.fn(async (_ctx, sourcePaths: string[]) => + sourcePaths.map((sourcePath, index) => createAstSource(sourcePath, index * 10 + 1)), + ), + resolveSoliditySourcePath: vi.fn(async (_ctx, sourcePath: string) => sourcePath), + initProject: vi.fn(async () => undefined), + writeConfig: vi.fn(async () => undefined), + } as unknown as IFrameworkAdapter; + const hashing: IHashingAdapter = { keccak256: () => "0x" as `0x${string}` }; + + vi.spyOn(DependencyResolver, "resolve").mockImplementation(async (requests) => { + const dependencies: Partial = {}; + for (const request of requests) { + if (request.key === DependencyKey.Foundry) dependencies[DependencyKey.Foundry] = adapter; + if (request.key === DependencyKey.Hashing) dependencies[DependencyKey.Hashing] = hashing; + } + return dependencies; + }); + vi.spyOn(ConfigModule, "loadBasesCatalog").mockImplementation(async (ctx) => { + ctx.config.bases = emptyCatalog; + return ctx; + }); + return adapter; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("FacetConfigModule", () => { + it("registers local facets atomically and leaves deployment state untouched", async () => { + const project = await createProject(); + mockValidationDependencies(); + project.ctx.param.facets = ["src/facets/FeeFacet.sol:FeeFacet"]; + await fs.writeFile(path.join(project.root, "src", "facets", "FeeFacet.sol"), "contract FeeFacet {}\n", "utf8"); + + const result = await FacetConfigModule.execute(project.ctx, "install"); + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + + expect(result.status.success).toBe(true); + expect(diamonds.TestDiamond.facets.FeeFacet).toEqual({ + source: "local", + contract: "src/facets/FeeFacet.sol:FeeFacet", + }); + expect(await fs.readFile(path.join(project.root, "compose.lock"), "utf8")).toBe("keep-lock-untouched\n"); + }); + + it("installs a bare contract name by fuzzy-matching the project source", async () => { + const project = await createProject(); + mockValidationDependencies(); + project.ctx.param.facets = ["FeeFacet"]; + await fs.writeFile(path.join(project.root, "src", "facets", "FeeFacet.sol"), "contract FeeFacet {}\n", "utf8"); + + const result = await FacetConfigModule.execute(project.ctx, "install"); + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + + expect(result.status.success).toBe(true); + expect(diamonds.TestDiamond.facets.FeeFacet).toEqual({ + source: "local", + contract: "src/facets/FeeFacet.sol:FeeFacet", + }); + }); + + it("rejects an ambiguous bare contract name when no picker is available", async () => { + const project = await createProject(); + mockValidationDependencies(); + project.ctx.param.facets = ["FeeFacet"]; + await fs.writeFile(path.join(project.root, "src", "facets", "FeeFacet.sol"), "contract FeeFacet {}\n", "utf8"); + await fs.mkdir(path.join(project.root, "src", "alt"), { recursive: true }); + await fs.writeFile(path.join(project.root, "src", "alt", "FeeFacet.sol"), "contract FeeFacet {}\n", "utf8"); + + await expect(FacetConfigModule.execute(project.ctx, "install")) + .rejects.toThrow("matches several project sources"); + }); + + it("treats registering an identical facet as an idempotent no-op", async () => { + const project = await createProject(); + mockValidationDependencies(); + project.ctx.param.facets = ["src/facets/ExistingFacet.sol:ExistingFacet"]; + + const result = await FacetConfigModule.execute(project.ctx, "install"); + + expect(result.state.facetMutation).toMatchObject({ success: true, result: { changed: false } }); + }); + + it("does not write config when an install source is missing", async () => { + const project = await createProject(); + mockValidationDependencies(); + const before = await fs.readFile(path.join(project.root, "compose.json"), "utf8"); + project.ctx.param.facets = ["src/facets/MissingFacet.sol:MissingFacet"]; + + await expect(FacetConfigModule.execute(project.ctx, "install")).rejects.toThrow("Facet source not found"); + + expect(await fs.readFile(path.join(project.root, "compose.json"), "utf8")).toBe(before); + }); + + it("does not write config when candidate facet validation fails", async () => { + const project = await createProject(); + const adapter = mockValidationDependencies(); + vi.mocked(adapter.compileAst).mockResolvedValue([]); + const before = await fs.readFile(path.join(project.root, "compose.json"), "utf8"); + project.ctx.param.facets = ["src/facets/FeeFacet.sol:FeeFacet"]; + await fs.writeFile(path.join(project.root, "src", "facets", "FeeFacet.sol"), "contract FeeFacet {}\n", "utf8"); + + await expect(FacetConfigModule.execute(project.ctx, "install")).rejects.toThrow("Facet contract not found in Solidity AST"); + + expect(await fs.readFile(path.join(project.root, "compose.json"), "utf8")).toBe(before); + }); + + it("unregisters only config entries and leaves Solidity sources and lock state in place", async () => { + const project = await createProject(); + mockValidationDependencies(); + project.ctx.param.facets = ["RemoveFacet"]; + + const result = await FacetConfigModule.execute(project.ctx, "remove"); + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + + expect(result.status.success).toBe(true); + expect(diamonds.TestDiamond.facets.RemoveFacet).toBeUndefined(); + expect(diamonds.TestDiamond.facets.KeepFacet).toBeDefined(); + await expect(fs.access(path.join(project.root, "src", "facets", "RemoveFacet.sol"))).resolves.toBeUndefined(); + expect(await fs.readFile(path.join(project.root, "compose.lock"), "utf8")).toBe("keep-lock-untouched\n"); + }); + + it("unregisters a local facet addressed by its path.sol:Contract reference", async () => { + const project = await createProject(); + mockValidationDependencies(); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + project.ctx.param.facets = ["src/facets/RemoveFacet.sol:RemoveFacet"]; + + const result = await FacetConfigModule.execute(project.ctx, "remove"); + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + + expect(result.state.facetMutation).toMatchObject({ success: true, result: { changed: true } }); + expect(console.warn).not.toHaveBeenCalledWith(expect.stringContaining("is not registered")); + expect(diamonds.TestDiamond.facets.RemoveFacet).toBeUndefined(); + expect(diamonds.TestDiamond.facets.KeepFacet).toBeDefined(); + expect(diamonds.TestDiamond.facets.ExistingFacet).toBeDefined(); + }); + + it("treats inherited facet names as unregistered no-ops", async () => { + const project = await createProject(); + const adapter = mockValidationDependencies(); + const before = await fs.readFile(path.join(project.root, "compose.json"), "utf8"); + project.ctx.param.facets = ["toString", "hasOwnProperty", "valueOf"]; + vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const result = await FacetConfigModule.execute(project.ctx, "remove"); + + expect(result.state.facetMutation).toMatchObject({ success: true, result: { changed: false } }); + expect(adapter.compileAst).not.toHaveBeenCalled(); + expect(await fs.readFile(path.join(project.root, "compose.json"), "utf8")).toBe(before); + }); + + it("registers a facet whose name is inherited from Object.prototype", async () => { + const project = await createProject(); + mockValidationDependencies(); + project.ctx.param.facets = ["src/facets/toString.sol:toString"]; + await fs.writeFile(path.join(project.root, "src", "facets", "toString.sol"), "contract toString {}\n", "utf8"); + + await FacetConfigModule.execute(project.ctx, "install"); + + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + expect(Object.hasOwn(diamonds.TestDiamond.facets, "toString")).toBe(true); + }); + + it("rejects inherited diamond names instead of treating them as configured", async () => { + const project = await createProject(); + const manifestRoot = path.join(project.root, FACET_BATCH_DIRECTORY); + await fs.mkdir(manifestRoot, { recursive: true }); + await fs.writeFile(path.join(manifestRoot, "inherited-diamond.json"), JSON.stringify({ + version: 1, + operation: "remove", + diamonds: { toString: ["RemoveFacet"] }, + }), "utf8"); + project.ctx.param.manifest = "inherited-diamond"; + + await expect(FacetConfigModule.execute(project.ctx, "remove")) + .rejects.toThrow("Diamond 'toString' is not defined in compose.json."); + }); + + it("rejects reserved facet names supplied directly to remove", async () => { + const project = await createProject(); + const before = await fs.readFile(path.join(project.root, "compose.json"), "utf8"); + project.ctx.param.facets = ["__proto__"]; + + await expect(FacetConfigModule.execute(project.ctx, "remove")) + .rejects.toThrow("Facet name '__proto__' is reserved"); + + expect(await fs.readFile(path.join(project.root, "compose.json"), "utf8")).toBe(before); + }); + + it("creates an install manifest under compose/facet-batches without changing compose.json", async () => { + const project = await createProject(); + vi.spyOn(ConfigModule, "loadBasesCatalog").mockImplementation(async (ctx) => { + ctx.config.bases = emptyCatalog; + return ctx; + }); + const before = await fs.readFile(path.join(project.root, "compose.json"), "utf8"); + project.ctx.param.createManifest = "register-fee"; + project.ctx.param.facets = ["src/facets/FeeFacet.sol:FeeFacet"]; + await fs.writeFile(path.join(project.root, "src", "facets", "FeeFacet.sol"), "contract FeeFacet {}\n", "utf8"); + + await FacetConfigModule.execute(project.ctx, "install"); + + const manifestPath = path.join(project.root, FACET_BATCH_DIRECTORY, "register-fee.json"); + expect(JSON.parse(await fs.readFile(manifestPath, "utf8"))).toEqual({ + version: 1, + operation: "install", + diamonds: { + TestDiamond: [{ + name: "FeeFacet", + source: "local", + contract: "src/facets/FeeFacet.sol:FeeFacet", + }], + }, + }); + expect(await fs.readFile(path.join(project.root, "compose.json"), "utf8")).toBe(before); + }); + + it("applies a remove manifest by its default-folder name", async () => { + const project = await createProject(); + mockValidationDependencies(); + const manifestRoot = path.join(project.root, FACET_BATCH_DIRECTORY); + await fs.mkdir(manifestRoot, { recursive: true }); + await fs.writeFile(path.join(manifestRoot, "remove-legacy.json"), JSON.stringify({ + version: 1, + operation: "remove", + diamonds: { TestDiamond: ["RemoveFacet"] }, + }), "utf8"); + project.ctx.param.manifest = "remove-legacy"; + + const result = await FacetConfigModule.execute(project.ctx, "remove"); + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + + expect(result.status.success).toBe(true); + expect(diamonds.TestDiamond.facets.RemoveFacet).toBeUndefined(); + expect(diamonds.TestDiamond.facets.KeepFacet).toBeDefined(); + }); + + it("applies an install manifest by its default-folder name", async () => { + const project = await createProject(); + mockValidationDependencies(); + await fs.writeFile(path.join(project.root, "src", "facets", "FeeFacet.sol"), "contract FeeFacet {}\n", "utf8"); + const manifestRoot = path.join(project.root, FACET_BATCH_DIRECTORY); + await fs.mkdir(manifestRoot, { recursive: true }); + await fs.writeFile(path.join(manifestRoot, "add-fees.json"), JSON.stringify({ + version: 1, + operation: "install", + diamonds: { + TestDiamond: [{ + name: "FeeFacet", + source: "local", + contract: "src/facets/FeeFacet.sol:FeeFacet", + }], + }, + }), "utf8"); + project.ctx.param.manifest = "add-fees"; + + const result = await FacetConfigModule.execute(project.ctx, "install"); + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + + expect(result.status.success).toBe(true); + expect(diamonds.TestDiamond.facets.FeeFacet).toEqual({ + source: "local", + contract: "src/facets/FeeFacet.sol:FeeFacet", + }); + }); + + it("registers package facets without copying dependency sources", async () => { + const project = await createProject(); + mockValidationDependencies(); + const packageRoot = path.join(project.root, "node_modules", "test-facets"); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, "PackageFacet.sol"), "contract PackageFacet {}\n", "utf8"); + const manifestRoot = path.join(project.root, FACET_BATCH_DIRECTORY); + await fs.mkdir(manifestRoot, { recursive: true }); + await fs.writeFile(path.join(manifestRoot, "package-facet.json"), JSON.stringify({ + version: 1, + operation: "install", + diamonds: { + TestDiamond: [{ + name: "PackageFacet", + source: "package", + package: "test-facets", + contract: "PackageFacet", + }], + }, + }), "utf8"); + project.ctx.param.manifest = "package-facet"; + + await FacetConfigModule.execute(project.ctx, "install"); + + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + expect(diamonds.TestDiamond.facets.PackageFacet).toEqual({ + source: "package", + contract: "PackageFacet", + package: "test-facets", + }); + await expect(fs.access(path.join(project.root, "src", "facets", "PackageFacet.sol"))).rejects.toThrow(); + }); + + it("copies catalog-local facets into the framework source tree", async () => { + const project = await createProject(); + mockValidationDependencies(); + vi.spyOn(ConfigModule, "loadBasesCatalog").mockImplementation(async (ctx) => { + ctx.config.bases = { + globals: {}, + features: { + counter: { + label: "Counter", + required: {}, + optional: { + CounterIncrementFacet: { + path: "./src/templates/counter/CounterIncrementFacet.sol", + }, + }, + }, + }, + } satisfies BasesCatalog; + return ctx; + }); + project.ctx.param.facets = ["CounterIncrementFacet"]; + + await FacetConfigModule.execute(project.ctx, "install"); + + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + expect(diamonds.TestDiamond.facets.CounterIncrementFacet.contract) + .toBe("src/facets/CounterIncrementFacet.sol:CounterIncrementFacet"); + await expect(fs.access(path.join(project.root, "src", "facets", "CounterIncrementFacet.sol"))).resolves.toBeUndefined(); + }); + + it("preserves nested import paths when copying catalog-local facets", async () => { + const project = await createProject(); + mockValidationDependencies(); + const templateRoot = path.join(project.root, "catalog", "import-review"); + await fs.mkdir(path.join(templateRoot, "libraries"), { recursive: true }); + await fs.mkdir(path.join(templateRoot, "mods"), { recursive: true }); + await fs.writeFile( + path.join(templateRoot, "NestedImportFacet.sol"), + [ + 'import {LibFee} from "./libraries/LibFee.sol";', + 'import {calculateReviewFee} from "./mods/FeeMod.sol";', + "contract NestedImportFacet {}", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile(path.join(templateRoot, "libraries", "LibFee.sol"), "library LibFee {}\n", "utf8"); + await fs.writeFile( + path.join(templateRoot, "mods", "FeeMod.sol"), + "function calculateReviewFee() pure returns (uint256) { return 1; }\n", + "utf8", + ); + vi.spyOn(ConfigModule, "loadBasesCatalog").mockImplementation(async (ctx) => { + ctx.config.bases = { + globals: {}, + features: { + "import-review": { + label: "Import Review", + required: {}, + optional: { + NestedImportFacet: { + path: path.join(templateRoot, "NestedImportFacet.sol"), + }, + }, + }, + }, + } satisfies BasesCatalog; + return ctx; + }); + project.ctx.param.facets = ["NestedImportFacet"]; + + await FacetConfigModule.execute(project.ctx, "install"); + + const config = await project.readConfig(); + const diamonds = config.diamonds as Record }>; + expect(diamonds.TestDiamond.facets.NestedImportFacet.contract) + .toBe("src/facets/NestedImportFacet.sol:NestedImportFacet"); + await expect(fs.access(path.join(project.root, "src", "facets", "NestedImportFacet.sol"))).resolves.toBeUndefined(); + await expect(fs.access(path.join(project.root, "src", "facets", "libraries", "LibFee.sol"))).resolves.toBeUndefined(); + await expect(fs.access(path.join(project.root, "src", "facets", "mods", "FeeMod.sol"))).resolves.toBeUndefined(); + }); + + it("removes staged catalog source files when candidate validation fails", async () => { + const project = await createProject(); + const adapter = mockValidationDependencies(); + vi.mocked(adapter.compileAst).mockResolvedValue([]); + vi.spyOn(ConfigModule, "loadBasesCatalog").mockImplementation(async (ctx) => { + ctx.config.bases = { + globals: {}, + features: { + counter: { + label: "Counter", + required: {}, + optional: { + CounterIncrementFacet: { + path: "./src/templates/counter/CounterIncrementFacet.sol", + }, + }, + }, + }, + } satisfies BasesCatalog; + return ctx; + }); + const before = await fs.readFile(path.join(project.root, "compose.json"), "utf8"); + project.ctx.param.facets = ["CounterIncrementFacet"]; + + await expect(FacetConfigModule.execute(project.ctx, "install")).rejects.toThrow("Facet contract not found in Solidity AST"); + + expect(await fs.readFile(path.join(project.root, "compose.json"), "utf8")).toBe(before); + await expect(fs.access(path.join(project.root, "src", "facets", "CounterIncrementFacet.sol"))).rejects.toThrow(); + }); +}); diff --git a/cli/test/modules/facetConfig/projectConfig.test.ts b/cli/test/modules/facetConfig/projectConfig.test.ts new file mode 100644 index 00000000..c433c018 --- /dev/null +++ b/cli/test/modules/facetConfig/projectConfig.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { Context } from "../../../src/context/context"; +import { loadComposeProject } from "../../../src/modules/facetConfig/projectConfig"; + +describe("facet config project loader", () => { + it("validates the project and defaults omitted facet maps", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-facet-project-")); + + try { + await fs.writeFile(path.join(root, "compose.json"), JSON.stringify({ + framework: "foundry", + diamonds: { Example: { contract: "src/Diamond.sol:Diamond" } }, + }), "utf8"); + const ctx = Context.create(); + ctx.param.projectRoot = root; + + const project = await loadComposeProject(ctx); + + expect(project).toEqual({ + path: path.join(root, "compose.json"), + root, + config: { + framework: "foundry", + diamonds: { + Example: { contract: "src/Diamond.sol:Diamond", facets: {} }, + }, + }, + }); + expect(ctx.param.projectRoot).toBe(root); + expect(ctx.param.framework).toBe("foundry"); + expect(ctx.config.composeJson).toEqual(project.config); + expect(ctx.state.facetProjectConfig).toMatchObject({ + success: true, + result: { path: project.path, projectRoot: root }, + error: null, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("wraps read and parse errors with the compose.json path and original cause", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-facet-project-invalid-")); + + try { + const composeJsonPath = path.join(root, "compose.json"); + await fs.writeFile(composeJsonPath, "{invalid", "utf8"); + const ctx = Context.create(); + ctx.param.projectRoot = root; + + await expect(loadComposeProject(ctx)).rejects.toMatchObject({ + message: expect.stringContaining(`Unable to parse ${composeJsonPath}:`), + cause: expect.any(SyntaxError), + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/test/modules/facetSections.test.ts b/cli/test/modules/facetSections.test.ts new file mode 100644 index 00000000..8f88c7d8 --- /dev/null +++ b/cli/test/modules/facetSections.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { renderFacetSections } from "../../src/modules/facetSections"; + +describe("renderFacetSections", () => { + it("preserves group order and renders base entries before libraries", () => { + const facets = [ + { name: "libraryFirst", group: "library" }, + { name: "baseFirst", group: "base" }, + { name: "librarySecond", group: "library" }, + { name: "baseSecond", group: "base" }, + ] as const; + const renderOrder: string[] = []; + + const sections = renderFacetSections(facets, (facet) => { + renderOrder.push(facet.name); + return `rendered:${facet.name}`; + }, "\n\n"); + + expect(sections).toEqual({ + baseLines: "rendered:baseFirst\n\nrendered:baseSecond", + libraryLines: "rendered:libraryFirst\n\nrendered:librarySecond", + }); + expect(renderOrder).toEqual(["baseFirst", "baseSecond", "libraryFirst", "librarySecond"]); + }); + + it("renders empty groups as empty strings", () => { + const sections = renderFacetSections( + [{ group: "base", name: "BaseFacet" }], + (facet) => facet.name, + "\n", + ); + + expect(sections).toEqual({ baseLines: "BaseFacet", libraryLines: "" }); + }); +}); diff --git a/cli/test/modules/info/loader.test.ts b/cli/test/modules/info/loader.test.ts new file mode 100644 index 00000000..0e9902e0 --- /dev/null +++ b/cli/test/modules/info/loader.test.ts @@ -0,0 +1,73 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { Context } from "../../../src/context/context"; +import { loadComposeJson } from "../../../src/modules/info/loader"; + +describe("info compose.json loader", () => { + it("stores a missing-file failure without throwing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-info-loader-missing-")); + + try { + const ctx = Context.create(); + ctx.param.projectRoot = root; + + await expect(loadComposeJson(ctx)).resolves.toBe(ctx); + expect(ctx.state.infoLoader).toMatchObject({ + success: false, + result: null, + error: { + code: "COMPOSE_JSON_NOT_FOUND", + message: "compose.json not found. Run 'compose init' first or navigate to a Compose project directory.", + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("loads the nearest file without changing the caller's project parameters", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-info-loader-")); + + try { + const nestedDirectory = path.join(root, "src", "facets"); + await fs.mkdir(nestedDirectory, { recursive: true }); + await fs.writeFile(path.join(root, "compose.json"), JSON.stringify({ project: "example" }), "utf8"); + + const ctx = Context.create(); + ctx.param.projectRoot = nestedDirectory; + + await loadComposeJson(ctx); + + expect(ctx.config.composeJson).toEqual({ project: "example" }); + expect(ctx.param.projectRoot).toBe(nestedDirectory); + expect(ctx.param.framework).toBeUndefined(); + expect(ctx.state.infoLoader).toMatchObject({ + success: true, + result: { + composeJsonPath: path.join(root, "compose.json"), + composeJson: { project: "example" }, + }, + error: null, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("lets JSON parse errors propagate without setting loader state", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-info-loader-invalid-")); + + try { + await fs.writeFile(path.join(root, "compose.json"), "{invalid", "utf8"); + const ctx = Context.create(); + ctx.param.projectRoot = root; + + await expect(loadComposeJson(ctx)).rejects.toBeInstanceOf(SyntaxError); + expect(ctx.state.infoLoader).toBeUndefined(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/test/modules/info/scanner.test.ts b/cli/test/modules/info/scanner.test.ts new file mode 100644 index 00000000..16a46a12 --- /dev/null +++ b/cli/test/modules/info/scanner.test.ts @@ -0,0 +1,70 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { Context } from "../../../src/context/context"; +import type { ModuleState } from "../../../src/context/types"; +import { ComposeProjectInfo } from "../../../src/modules/info/types"; +import { scanFacets } from "../../../src/modules/info/scanner"; + +describe("info package facet scanning", () => { + it("uses the first package source match", async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-info-package-facet-")); + + try { + const nodeSource = path.join(projectRoot, "node_modules", "example-package", "PackageFacet.sol"); + const foundrySource = path.join(projectRoot, "lib", "Compose", "src", "PackageFacet.sol"); + await fs.mkdir(path.dirname(nodeSource), { recursive: true }); + await fs.mkdir(path.dirname(foundrySource), { recursive: true }); + await fs.writeFile(nodeSource, ` + contract PackageFacet { + function fromNodeModules() external {} + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.fromNodeModules.selector); + } + } + `, "utf8"); + await fs.writeFile(foundrySource, ` + contract PackageFacet { + function fromFoundryLib() external {} + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.fromFoundryLib.selector); + } + } + `, "utf8"); + + const ctx = Context.create(); + const projectInfo: ComposeProjectInfo = { + project: "example", + composeVersion: "0.1.0", + framework: "foundry", + diamonds: [{ + name: "ExampleDiamond", + contract: "src/Diamond.sol:Diamond", + facets: [{ + name: "PackageFacet", + source: "package", + contract: "PackageFacet", + package: "example-package", + selectors: [], + storageSlots: [], + }], + }], + warnings: [], + }; + ctx.state.infoProject = { success: true, result: projectInfo, error: null }; + ctx.state.infoLoader = { + success: true, + result: { composeJsonPath: path.join(projectRoot, "compose.json") }, + error: null, + }; + + await scanFacets(ctx); + + const state = ctx.state.infoProject as ModuleState; + expect(state.result?.diamonds[0].facets[0].selectors).toEqual(["fromNodeModules()"]); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/test/modules/testGeneration/renderers.test.ts b/cli/test/modules/testGeneration/renderers.test.ts new file mode 100644 index 00000000..1e299ff8 --- /dev/null +++ b/cli/test/modules/testGeneration/renderers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + renderHardhatEthersTest, + renderHardhatViemTest, +} from "../../../src/modules/testGeneration/renderers"; +import type { TestGenerationModel } from "../../../src/modules/testGeneration/types"; + +const model: TestGenerationModel = { + outputPath: "test/Diamond.ts", + facetCount: 3, + facets: [ + { + facetName: "LibraryFirstFacet", + contractName: "LibraryFirstFacet", + importPath: "facets/LibraryFirstFacet.sol", + group: "library", + }, + { + facetName: "BaseFirstFacet", + contractName: "BaseFirstFacet", + importPath: "facets/BaseFirstFacet.sol", + group: "base", + }, + { + facetName: "BaseSecondFacet", + contractName: "BaseSecondFacet", + importPath: "facets/BaseSecondFacet.sol", + group: "base", + }, + ], +}; + +function expectBaseSectionsBeforeLibraries(source: string): void { + const baseFirst = source.indexOf("const baseFirstFacet ="); + const baseSecond = source.indexOf("const baseSecondFacet ="); + const library = source.indexOf("const libraryFirstFacet ="); + + expect(baseFirst).toBeGreaterThanOrEqual(0); + expect(baseSecond).toBeGreaterThan(baseFirst); + expect(library).toBeGreaterThan(baseSecond); +} + +describe("Hardhat test renderers", () => { + it("renders ethers setup with waits but without deploy-script logging", async () => { + const source = await renderHardhatEthersTest(model); + + expectBaseSectionsBeforeLibraries(source); + expect(source).toContain("await baseFirstFacet.waitForDeployment();"); + expect(source).toContain("facets.push(await baseFirstFacet.getAddress());"); + expect(source).not.toContain('console.log("BaseFirstFacet:");'); + expect(source).toContain("equal(3)"); + }); + + it("renders viem setup with address access and no deployment wait", async () => { + const source = await renderHardhatViemTest(model); + + expectBaseSectionsBeforeLibraries(source); + expect(source).toContain("facets.push(baseFirstFacet.address);"); + expect(source).not.toContain("waitForDeployment"); + expect(source).toContain("strictEqual(addresses.length, 3)"); + }); +}); diff --git a/cli/test/modules/validation/astUtils.test.ts b/cli/test/modules/validation/astUtils.test.ts new file mode 100644 index 00000000..6c8a3f7f --- /dev/null +++ b/cli/test/modules/validation/astUtils.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + astNode, + childNodes, + numberArray, + stringValue, + walkAst, + walkAstWithAncestors, + walkAstWithContract, + type AstNode, +} from "../../../src/modules/validation/astUtils"; + +describe("validation AST utilities", () => { + it("recognizes only non-array objects with a string node type", () => { + const node = { nodeType: "Identifier", name: "value" }; + + expect(astNode(node)).toBe(node); + expect(astNode(null)).toBeNull(); + expect(astNode([node])).toBeNull(); + expect(astNode({ name: "value" })).toBeNull(); + expect(astNode({ nodeType: 1 })).toBeNull(); + }); + + it("filters child arrays to AST nodes and safely converts primitive values", () => { + const parent = { + nodeType: "SourceUnit", + children: [ + { nodeType: "Identifier", name: "first" }, + null, + { name: "not-an-ast-node" }, + { nodeType: "Identifier", name: "second" }, + ], + } as AstNode; + + expect(childNodes(parent, "children").map((node) => node.name)).toEqual(["first", "second"]); + expect(childNodes(parent, "missing")).toEqual([]); + expect(numberArray([1, "2", 3, null])).toEqual([1, 3]); + expect(numberArray("1,2")).toEqual([]); + expect(stringValue("value")).toBe("value"); + expect(stringValue(1)).toBe(""); + }); + + it("walks in object and array order and reports ancestors without the current node", () => { + const ast: AstNode = { + nodeType: "SourceUnit", + nodes: [{ + nodeType: "ContractDefinition", + id: 10, + nodes: [{ + nodeType: "FunctionDefinition", + id: 11, + body: { + nodeType: "Block", + statements: [ + { nodeType: "Identifier", name: "first" }, + { nodeType: "Identifier", name: "second" }, + ], + }, + }], + }], + }; + const visited: string[] = []; + const identifierAncestors: string[][] = []; + + walkAst(ast, (node) => visited.push(node.nodeType)); + walkAstWithAncestors(ast, (node, ancestors) => { + if (node.nodeType === "Identifier") { + identifierAncestors.push(ancestors.map((ancestor) => ancestor.nodeType)); + } + }); + + expect(visited).toEqual([ + "SourceUnit", + "ContractDefinition", + "FunctionDefinition", + "Block", + "Identifier", + "Identifier", + ]); + expect(identifierAncestors).toEqual([ + ["SourceUnit", "ContractDefinition", "FunctionDefinition", "Block"], + ["SourceUnit", "ContractDefinition", "FunctionDefinition", "Block"], + ]); + }); + + it("tracks the current enclosing contract ID", () => { + const ast: AstNode = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "ContractDefinition", + id: 10, + nodes: [ + { nodeType: "FunctionDefinition", id: 11 }, + { + nodeType: "ContractDefinition", + id: 20, + nodes: [{ nodeType: "FunctionDefinition", id: 21 }], + }, + ], + }, + { nodeType: "FunctionDefinition", id: 30 }, + ], + }; + const ownership: Array<[string, number | null]> = []; + + walkAstWithContract(ast, null, (node, ownerContractId) => { + ownership.push([node.nodeType, ownerContractId]); + }); + + expect(ownership).toEqual([ + ["SourceUnit", null], + ["ContractDefinition", 10], + ["FunctionDefinition", 10], + ["ContractDefinition", 20], + ["FunctionDefinition", 20], + ["FunctionDefinition", null], + ]); + }); +}); diff --git a/cli/test/modules/validation/project.test.ts b/cli/test/modules/validation/project.test.ts index 6a45a77e..70496191 100644 --- a/cli/test/modules/validation/project.test.ts +++ b/cli/test/modules/validation/project.test.ts @@ -6,6 +6,21 @@ import { Context } from "../../../src/context/context"; import { loadValidationProject } from "../../../src/modules/validation/project"; describe("validation project loader", () => { + it("throws the existing not-found error when no compose.json exists", async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-validation-project-missing-")); + + try { + const ctx = Context.create(); + ctx.param.projectRoot = projectRoot; + + await expect(loadValidationProject(ctx)).rejects.toThrow( + "compose.json not found. Run 'compose init' first or navigate to a Compose project directory.", + ); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } + }); + it("loads framework and facet contract names from the nearest compose.json", async () => { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-validation-project-")); const nestedDirectory = path.join(projectRoot, "src", "facets"); @@ -202,4 +217,108 @@ describe("validation project loader", () => { await fs.rm(projectRoot, { recursive: true, force: true }); } }); + + it("rejects package facet sources when both package roots contain a match", async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-validation-package-ambiguous-")); + const packageSourcePath = path.join( + projectRoot, + "node_modules", + "@perfect-abstractions", + "compose", + "src", + "diamond", + "PackageFacet.sol", + ); + const foundrySourcePath = path.join( + projectRoot, + "lib", + "Compose", + "src", + "diamond", + "PackageFacet.sol", + ); + const ctx = Context.create(); + + try { + await fs.mkdir(path.dirname(packageSourcePath), { recursive: true }); + await fs.mkdir(path.dirname(foundrySourcePath), { recursive: true }); + await fs.writeFile(packageSourcePath, "contract PackageFacet {}", "utf8"); + await fs.writeFile(foundrySourcePath, "contract PackageFacet {}", "utf8"); + await fs.writeFile( + path.join(projectRoot, "compose.json"), + JSON.stringify({ + framework: "foundry", + diamonds: { + Example: { + contract: "src/Diamond.sol:Diamond", + facets: { + PackageFacet: { + source: "package", + contract: "PackageFacet", + package: "@perfect-abstractions/compose", + }, + }, + }, + }, + }), + "utf8", + ); + ctx.param.projectRoot = projectRoot; + + await expect(loadValidationProject(ctx)).rejects.toThrow( + /Package facet source is ambiguous: @perfect-abstractions\/compose\/PackageFacet\.sol/, + ); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("uses an in-memory config override while deriving the root from the discovered file", async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-validation-project-override-")); + + try { + await fs.writeFile(path.join(projectRoot, "compose.json"), JSON.stringify({ + framework: "foundry", + diamonds: { + DiskDiamond: { + contract: "src/DiskDiamond.sol:DiskDiamond", + facets: { + DiskFacet: { source: "local", contract: "src/DiskFacet.sol:DiskFacet" }, + }, + }, + }, + }), "utf8"); + const ctx = Context.create(); + ctx.param.projectRoot = projectRoot; + const override = { + framework: "foundry", + diamonds: { + CandidateDiamond: { + contract: "src/CandidateDiamond.sol:CandidateDiamond", + facets: { + CandidateFacet: { + source: "local", + contract: "src/CandidateFacet.sol:CandidateFacet", + }, + }, + }, + }, + }; + + const project = await loadValidationProject(ctx, override); + + expect(project.diamonds).toEqual([{ + name: "CandidateDiamond", + sourcePath: path.join(projectRoot, "src", "CandidateDiamond.sol"), + facets: [{ + contractName: "CandidateFacet", + sourcePath: path.join(projectRoot, "src", "CandidateFacet.sol"), + }], + }]); + expect(ctx.config.composeJson).toBe(override); + expect(ctx.param.projectRoot).toBe(projectRoot); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } + }); }); diff --git a/cli/test/utils/composeProjectFile.test.ts b/cli/test/utils/composeProjectFile.test.ts new file mode 100644 index 00000000..947bf8f2 --- /dev/null +++ b/cli/test/utils/composeProjectFile.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { locateComposeProjectFile, readComposeProjectFile } from "../../src/utils/composeProjectFile"; + +describe("compose project file utilities", () => { + it("locates the nearest compose.json and returns its project root", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-project-file-")); + + try { + const nestedRoot = path.join(root, "nested"); + const startDirectory = path.join(nestedRoot, "src", "facets"); + await fs.mkdir(startDirectory, { recursive: true }); + await fs.writeFile(path.join(root, "compose.json"), "{}", "utf8"); + await fs.writeFile(path.join(nestedRoot, "compose.json"), "{}", "utf8"); + + const location = await locateComposeProjectFile(path.relative(process.cwd(), startDirectory)); + + expect(location).toEqual({ + path: path.join(nestedRoot, "compose.json"), + root: nestedRoot, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("returns null when no compose.json exists", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-project-file-empty-")); + + try { + await expect(locateComposeProjectFile(root)).resolves.toBeNull(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("reads JSON without wrapping parse errors", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-project-file-json-")); + const filePath = path.join(root, "compose.json"); + + try { + await fs.writeFile(filePath, '{"project":"example"}', "utf8"); + await expect(readComposeProjectFile(filePath)).resolves.toEqual({ project: "example" }); + + await fs.writeFile(filePath, "{invalid", "utf8"); + await expect(readComposeProjectFile(filePath)).rejects.toBeInstanceOf(SyntaxError); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/test/utils/files.test.ts b/cli/test/utils/files.test.ts new file mode 100644 index 00000000..57892e8b --- /dev/null +++ b/cli/test/utils/files.test.ts @@ -0,0 +1,58 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { planImportClosureTargets } from "../../src/utils/files"; + +const root = path.resolve("/tmp", "plan-import-closure"); +const templates = path.join(root, "templates", "import-review"); +const projectSrc = path.join(root, "project", "src"); +const seedSource = path.join(templates, "NestedImportFacet.sol"); +const seedTarget = path.join(projectSrc, "facets", "NestedImportFacet.sol"); +const seeds = [{ source: seedSource, target: seedTarget }]; + +describe("planImportClosureTargets", () => { + it("maps seed files to their targets and preserves nested dependency structure", () => { + const libFee = path.join(templates, "libraries", "LibFee.sol"); + const feeMod = path.join(templates, "mods", "FeeMod.sol"); + + const planned = planImportClosureTargets(seeds, [seedSource, libFee, feeMod], projectSrc); + + expect(planned.get(path.resolve(seedSource))).toBe(seedTarget); + expect(planned.get(path.resolve(libFee))).toBe(path.join(projectSrc, "facets", "libraries", "LibFee.sol")); + expect(planned.get(path.resolve(feeMod))).toBe(path.join(projectSrc, "facets", "mods", "FeeMod.sol")); + }); + + it("resolves transitive dependencies relative to the seed that contains them", () => { + const feeMath = path.join(templates, "libraries", "internal", "FeeMath.sol"); + + const planned = planImportClosureTargets(seeds, [seedSource, feeMath], projectSrc); + + expect(planned.get(path.resolve(feeMath))).toBe( + path.join(projectSrc, "facets", "libraries", "internal", "FeeMath.sol"), + ); + }); + + it("falls back to the fallback directory for dependencies outside every seed", () => { + const sharedUtil = path.join(root, "templates", "shared", "Util.sol"); + + const planned = planImportClosureTargets(seeds, [seedSource, sharedUtil], projectSrc); + + expect(planned.get(path.resolve(sharedUtil))).toBe(path.join(projectSrc, "Util.sol")); + }); + + it("anchors dependencies to a seed when multiple seeds exist", () => { + const otherSeedSource = path.join(root, "templates", "other", "OtherFacet.sol"); + const otherSeedTarget = path.join(projectSrc, "facets", "OtherFacet.sol"); + const otherDep = path.join(root, "templates", "other", "helpers", "OtherHelper.sol"); + + const planned = planImportClosureTargets( + [...seeds, { source: otherSeedSource, target: otherSeedTarget }], + [otherSeedSource, otherDep], + projectSrc, + ); + + expect(planned.get(path.resolve(otherSeedSource))).toBe(otherSeedTarget); + expect(planned.get(path.resolve(otherDep))).toBe( + path.join(projectSrc, "facets", "helpers", "OtherHelper.sol"), + ); + }); +}); diff --git a/cli/test/utils/packageFacetSources.test.ts b/cli/test/utils/packageFacetSources.test.ts new file mode 100644 index 00000000..5253263a --- /dev/null +++ b/cli/test/utils/packageFacetSources.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { findPackageFacetSourceMatches } from "../../src/utils/packageFacetSources"; + +describe("package facet source search", () => { + it("returns all matches in Node-package then Foundry search order", async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-package-sources-")); + + try { + const nodeSource = path.join(projectRoot, "node_modules", "example-package", "src", "PackageFacet.sol"); + const foundrySource = path.join(projectRoot, "lib", "Compose", "src", "PackageFacet.sol"); + await fs.mkdir(path.dirname(nodeSource), { recursive: true }); + await fs.mkdir(path.dirname(foundrySource), { recursive: true }); + await fs.writeFile(nodeSource, "contract PackageFacet {}", "utf8"); + await fs.writeFile(foundrySource, "contract PackageFacet {}", "utf8"); + + await expect(findPackageFacetSourceMatches(projectRoot, "example-package", "PackageFacet")) + .resolves.toEqual([nodeSource, foundrySource]); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("can skip unreadable roots for callers that historically ignored search errors", async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-package-sources-unreadable-")); + + try { + const packageRoot = path.join(projectRoot, "node_modules", "example-package"); + const foundrySource = path.join(projectRoot, "lib", "Compose", "src", "PackageFacet.sol"); + await fs.mkdir(path.dirname(packageRoot), { recursive: true }); + await fs.writeFile(packageRoot, "not a directory", "utf8"); + await fs.mkdir(path.dirname(foundrySource), { recursive: true }); + await fs.writeFile(foundrySource, "contract PackageFacet {}", "utf8"); + + await expect(findPackageFacetSourceMatches(projectRoot, "example-package", "PackageFacet")) + .rejects.toMatchObject({ code: "ENOTDIR" }); + await expect(findPackageFacetSourceMatches( + projectRoot, + "example-package", + "PackageFacet", + { ignoreReadErrors: true }, + )).resolves.toEqual([foundrySource]); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("can stop before scanning later roots after the first match", async () => { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-package-sources-first-")); + + try { + const nodeSource = path.join(projectRoot, "node_modules", "example-package", "PackageFacet.sol"); + const foundryRoot = path.join(projectRoot, "lib", "Compose", "src"); + await fs.mkdir(path.dirname(nodeSource), { recursive: true }); + await fs.mkdir(path.dirname(foundryRoot), { recursive: true }); + await fs.writeFile(nodeSource, "contract PackageFacet {}", "utf8"); + await fs.writeFile(foundryRoot, "not a directory", "utf8"); + + await expect(findPackageFacetSourceMatches( + projectRoot, + "example-package", + "PackageFacet", + { stopAfterFirstMatch: true }, + )).resolves.toEqual([nodeSource]); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index e3ee5136..43b2f529 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -47,7 +47,7 @@ compose init ### Foundry ```bash -forge install Perfect-Abstractions/Compose@tag=compose@0.0.6 +forge install Perfect-Abstractions/Compose@tag=compose@0.0.7 ``` Create a `remappings.txt` file in the root of your project with the following content: