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: