From 34368417c18c7f9352f2d6f1e6102e6d943d8ecc Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 09:16:19 -0400 Subject: [PATCH 01/14] start: install and remove facet command --- .changeset/facet-install-remove-commands.md | 5 + cli/README.md | 95 +++- cli/src/comander.ts | 41 +- cli/src/modules/facetConfig/facetResolver.ts | 312 +++++++++++++ cli/src/modules/facetConfig/manifest.ts | 157 +++++++ cli/src/modules/facetConfig/module.ts | 441 ++++++++++++++++++ cli/src/modules/facetConfig/output.ts | 20 + cli/src/modules/facetConfig/projectConfig.ts | 69 +++ cli/src/modules/facetConfig/types.ts | 84 ++++ cli/src/modules/pipelineBuilder/module.ts | 16 + cli/src/modules/validation/module.ts | 40 ++ cli/src/modules/validation/project.ts | 3 +- .../validation/virtualStorageLayout.ts | 2 +- cli/src/pipelines/installPipeline.ts | 8 + cli/src/pipelines/removePipeline.ts | 8 + cli/src/pipelines/validatePipeline.ts | 22 +- cli/test/comander.test.ts | 39 ++ .../modules/facetConfig/facetResolver.test.ts | 35 ++ cli/test/modules/facetConfig/manifest.test.ts | 73 +++ cli/test/modules/facetConfig/module.test.ts | 361 ++++++++++++++ 20 files changed, 1793 insertions(+), 38 deletions(-) create mode 100644 .changeset/facet-install-remove-commands.md create mode 100644 cli/src/modules/facetConfig/facetResolver.ts create mode 100644 cli/src/modules/facetConfig/manifest.ts create mode 100644 cli/src/modules/facetConfig/module.ts create mode 100644 cli/src/modules/facetConfig/output.ts create mode 100644 cli/src/modules/facetConfig/projectConfig.ts create mode 100644 cli/src/modules/facetConfig/types.ts create mode 100644 cli/src/pipelines/installPipeline.ts create mode 100644 cli/src/pipelines/removePipeline.ts create mode 100644 cli/test/modules/facetConfig/facetResolver.test.ts create mode 100644 cli/test/modules/facetConfig/manifest.test.ts create mode 100644 cli/test/modules/facetConfig/module.test.ts 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/cli/README.md b/cli/README.md index 7510435f..e58159af 100644 --- a/cli/README.md +++ b/cli/README.md @@ -19,19 +19,26 @@ This starts an interactive flow to scaffold a new diamond project with: ```bash compose init [options] +compose install [facet-ref...] [options] +compose remove [facet-name...] [options] compose catalog compose info compose validate +compose build +compose rpc [options] +compose inspect
[options] compose --version | -v compose --help | -h ``` +`compose install` also has the short alias `compose i`. `compose remove` also has the alias `compose rm`. + ### `compose init` Scaffold a new Compose diamond project. **Options:** -- `--name `: directory name for the new project +- `[project-name]`: directory name for the new project - `--framework `: target framework - `--toolbox `: Hardhat toolbox (default: ethers) - `--base `: base preset (counter, erc-20, erc-721, erc-1155, erc-6909) @@ -39,9 +46,9 @@ Scaffold a new Compose diamond project. - `--access-control `: access control facets to include - `--libraries `: Compose library facets to include - `--extensions `: extension facets to include -- `--examples`: include local example facets +- `--out `: parent directory for the project (defaults to the current directory) - `--yes`: non-interactive mode with sensible defaults -- `--no-install-deps`: skip dependency installation +- `--install-deps` / `--no-install-deps`: install or skip framework dependencies When `--yes` is not provided, `compose init` will prompt for any values you omit. @@ -57,7 +64,81 @@ 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 + +# 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. 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`. 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 @@ -95,10 +176,10 @@ 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 \ @@ -108,7 +189,7 @@ compose init --name my-token \ --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..d269f0a6 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 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 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/facetConfig/facetResolver.ts b/cli/src/modules/facetConfig/facetResolver.ts new file mode 100644 index 00000000..ad21f173 --- /dev/null +++ b/cli/src/modules/facetConfig/facetResolver.ts @@ -0,0 +1,312 @@ +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, + resolveLocalSolidityImportClosure, + toPosixPath, +} from "../../utils/files"; +import { + contractNameFromSourcePath, + isComposePackagePath, + resolveCatalogSourceForRead, +} from "../../utils/soliditySources"; +import type { + FacetInstallReference, + FacetRegistration, + ResolvedFacetInstall, +} from "./types"; + +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)); +} + +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, +): 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) { + throw new Error(`Unknown catalog facet '${reference}'. Use a catalog name or path.sol:ContractName.`); + } + return resolveLocalReference(reference, contractName, projectRoot); + } + + 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.`); + } + 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, + }; +} + +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 sourceToSeed = new Map(seeds.map((seed) => [path.resolve(seed.source), seed])); + const copyTargets = new Map(); + + for (const source of await resolveLocalSolidityImportClosure(seeds.map((seed) => seed.source))) { + const resolvedSource = path.resolve(source); + let target = sourceToSeed.get(resolvedSource)?.target; + if (!target) { + const parent = seeds.find((seed) => { + const relative = path.relative(path.dirname(seed.source), resolvedSource); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); + }); + target = path.join(parent ? path.dirname(parent.target) : contractSourceRoot, path.basename(source)); + } + + const existingSource = copyTargets.get(path.resolve(target)); + if (existingSource && existingSource !== resolvedSource) { + throw new Error(`Multiple catalog files would be copied to ${target}.`); + } + copyTargets.set(path.resolve(target), resolvedSource); + } + + 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..68838a03 --- /dev/null +++ b/cli/src/modules/facetConfig/manifest.ts @@ -0,0 +1,157 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathExists } from "../../utils/files"; +import type { + FacetBatch, + FacetBatchOperation, + FacetInstallReference, + InstallFacetBatch, + RemoveFacetBatch, +} from "./types"; + +export const FACET_BATCH_DIRECTORY = path.join("compose", "facet-batches"); + +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`; +} + +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.`); + } + + 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.`); + } + return facet.trim(); + }); + 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]; + 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(); + 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..e9c8d285 --- /dev/null +++ b/cli/src/modules/facetConfig/module.ts @@ -0,0 +1,441 @@ +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 } 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 { + copyCatalogFacetSources, + listAvailableCatalogFacetNames, + listCatalogFacetNames, + resolveFacetInstallReference, +} from "./facetResolver"; +import { loadComposeProject, writeComposeProject } from "./projectConfig"; +import { showFacetManifestCreated, showFacetMutation } from "./output"; +import type { + 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; +} + +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 match = value.match(/:([A-Za-z_$][A-Za-z0-9_$]*)$/); + if (!match) return { name: value }; + return { name: match[1], source: "local", contract: 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 markFailure(ctx: ComposeContext, failedAt: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + ctx.status = { + success: false, + stopped: true, + failedAt, + error: { code: "FACET_CONFIG_FAILED", message, nativeError: error }, + }; +} + +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, + }); +} + +async function selectInstallReferences( + ctx: ComposeContext, + catalog: BasesCatalog, + diamondName: string, + registeredFacetNames: Iterable, +): 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("Provide facet names or path.sol:Contract references, or use --manifest."); + } + 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.`); + } + const { checkbox } = await loadPrompts(); + const selected = await checkbox({ + message: "Select facets to register:", + choices: availableFacetNames.map((name) => ({ name, value: name })), + theme: checkboxTheme, + }); + if (selected.length === 0) throw new Error("No facets selected."); + return selected; +} + +async function selectRemoveFacetNames( + ctx: ComposeContext, + diamondName: string, + configuredFacets: Record, +): 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("Provide facet names to remove, or use --manifest."); + + const names = Object.keys(configuredFacets); + if (names.length === 0) throw new Error(`Diamond '${diamondName}' has no registered facets.`); + const { checkbox } = await loadPrompts(); + const selected = await checkbox({ + message: `Select facets to unregister from ${diamondName}:`, + choices: names.map((name) => ({ name, value: name })), + theme: checkboxTheme, + }); + if (selected.length === 0) throw new Error("No facets selected."); + return selected; +} + +async function createManifest( + ctx: ComposeContext, + operation: FacetBatchOperation, + diamondName: string, + references: Array, +): Promise { + const projectRoot = String(ctx.param.projectRoot); + 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 projectConfigState = moduleState<{ path: string }>(ctx, "facetProjectConfig"); + const result: FacetMutationResult = { + operation, + projectRoot, + composeJsonPath: projectConfigState?.result?.path ?? "", + diamondChanges: [{ + diamond: diamondName, + facets: references.map((reference) => + typeof reference === "string" + ? operation === "install" && reference.includes(":") + ? reference.slice(reference.lastIndexOf(":") + 1) + : reference + : reference.name, + ), + }], + changed: false, + manifestPath, + }; + ctx.state.facetManifest = { success: true, result, error: null }; + showFacetManifestCreated(result); + return ctx; +} + +async function normalizeInstallManifestReferences( + references: string[], + catalog: BasesCatalog, + projectRoot: string, +): Promise { + const normalized: FacetInstallReference[] = []; + for (const reference of references) { + const resolved = await resolveFacetInstallReference(reference, catalog, projectRoot); + normalized.push(reference.includes(":") + ? { + name: resolved.name, + source: "local", + contract: resolved.registration.contract, + } + : { 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; +} + +async function applyInstallBatch(ctx: ComposeContext, batch: FacetBatch): Promise { + const project = await loadComposeProject(ctx); + const projectConfig = structuredClone(project.config) as Record; + const diamonds = projectConfig.diamonds as Record }>; + const { adapter } = await resolveFrameworkAdapter(ctx); + const contractSourceRoot = adapter.getContractSourceRoot(project.root); + const catalog = ctx.config.bases as BasesCatalog; + const changes: FacetMutationResult["diamondChanges"] = []; + const allCreatedFiles: string[] = []; + let changed = false; + let committed = false; + + try { + for (const [diamondName, references] of Object.entries(batch.diamonds)) { + const diamond = diamonds[diamondName]; + if (!diamond) throw new Error(`Diamond '${diamondName}' is not defined in compose.json.`); + const resolved: ResolvedFacetInstall[] = []; + for (const reference of references as FacetInstallReference[]) { + resolved.push(await resolveFacetInstallReference(reference, catalog, project.root)); + } + + const copied = await copyCatalogFacetSources(resolved, project.root, contractSourceRoot); + allCreatedFiles.push(...copied.createdFiles); + const installedNames: string[] = []; + for (const facet of copied.facets) { + const existing = diamond.facets[facet.name]; + if (existing) { + if (!sameRegistration(existing, 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); + changed = true; + } + changes.push({ diamond: diamondName, facets: installedNames }); + } + + if (changed || allCreatedFiles.length > 0) { + ctx = await validateCandidateConfig(ctx, projectConfig); + } + if (changed) { + await writeComposeProject(project, projectConfig as typeof project.config); + ctx.config.composeJson = projectConfig; + } + committed = true; + + const result: FacetMutationResult = { + operation: "install", + projectRoot: project.root, + composeJsonPath: project.path, + diamondChanges: changes, + changed, + materializedFiles: allCreatedFiles.map((filePath) => path.relative(project.root, filePath).replace(/\\/g, "/")), + }; + ctx.state.facetMutation = { success: true, result, error: null }; + showFacetMutation(result); + return ctx; + } catch (error) { + if (!committed) { + await Promise.all(allCreatedFiles.map((filePath) => fs.rm(filePath, { force: true }))); + } + throw error; + } +} + +async function applyRemoveBatch(ctx: ComposeContext, batch: FacetBatch): Promise { + const project = await loadComposeProject(ctx); + const projectConfig = structuredClone(project.config) as Record; + const diamonds = projectConfig.diamonds as Record }>; + const changes: FacetMutationResult["diamondChanges"] = []; + let changed = false; + + for (const [diamondName, facetNames] of Object.entries(batch.diamonds)) { + const diamond = diamonds[diamondName]; + if (!diamond) throw new Error(`Diamond '${diamondName}' is not defined in compose.json.`); + const removed: string[] = []; + for (const facetName of facetNames as string[]) { + if (!(facetName in diamond.facets)) { + console.warn(yellow(`Facet '${facetName}' is not registered on '${diamondName}'; skipping.`)); + continue; + } + delete diamond.facets[facetName]; + removed.push(facetName); + changed = true; + } + if (Object.keys(diamond.facets).length === 0) { + throw new Error(`Cannot remove every registered facet from '${diamondName}'.`); + } + changes.push({ diamond: diamondName, facets: removed }); + } + + if (changed) { + await validateCandidateConfig(ctx, projectConfig); + await writeComposeProject(project, projectConfig as typeof project.config); + ctx.config.composeJson = projectConfig; + } + + const result: FacetMutationResult = { + operation: "remove", + projectRoot: project.root, + composeJsonPath: project.path, + diamondChanges: changes, + changed, + }; + ctx.state.facetMutation = { success: true, result, error: null }; + showFacetMutation(result); + return ctx; +} + +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 { + try { + 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); + return createManifest(ctx, 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, 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, + 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, 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, 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, batch); + } catch (error) { + markFailure(ctx, `facet${operation === "install" ? "Install" : "Remove"}`, error); + throw error; + } + }, +}; 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..89973867 --- /dev/null +++ b/cli/src/modules/facetConfig/projectConfig.ts @@ -0,0 +1,69 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { ComposeContext } from "../../context/types"; +import { atomicWriteFile, findFileAncestor } from "../../utils/files"; +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 = path.resolve(String(ctx.param.projectRoot ?? process.cwd())); + const composeJsonPath = await findFileAncestor(startDirectory, "compose.json"); + if (!composeJsonPath) { + throw new Error("compose.json not found. Run 'compose init' or navigate to a Compose project directory."); + } + + let raw: unknown; + try { + raw = JSON.parse(await fs.readFile(composeJsonPath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown JSON parse error"; + throw new Error(`Unable to parse ${composeJsonPath}: ${message}`, { cause: error }); + } + + if (!isRecord(raw) || !isRecord(raw.diamonds) || Object.keys(raw.diamonds).length === 0) { + throw new Error(`${composeJsonPath} 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 = path.dirname(composeJsonPath); + ctx.param.projectRoot = projectRoot; + ctx.param.framework = config.framework; + ctx.config.composeJson = config as Record; + ctx.state.facetProjectConfig = { + success: true, + result: { path: composeJsonPath, projectRoot }, + error: null, + }; + + return { path: composeJsonPath, 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..4766ffb6 --- /dev/null +++ b/cli/src/modules/facetConfig/types.ts @@ -0,0 +1,84 @@ +import type { ConstructorEntry, Erc165Entry } from "../config/types"; + +export type FacetRegistration = { + source: "local" | "package"; + contract: string; + package?: string; + mod?: string; + constructor: ConstructorEntry[] | undefined; + erc165?: Erc165Entry; +}; + +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/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/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..0e68f623 100644 --- a/cli/src/modules/validation/project.ts +++ b/cli/src/modules/validation/project.ts @@ -50,6 +50,7 @@ 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"); @@ -59,7 +60,7 @@ export async function loadValidationProject( ); } - const composeJson = JSON.parse( + const composeJson = (composeJsonOverride as ComposeProjectDefinition | undefined) ?? JSON.parse( await fs.readFile(composeJsonPath, "utf8"), ) as ComposeProjectDefinition; const projectRoot = path.dirname(composeJsonPath); diff --git a/cli/src/modules/validation/virtualStorageLayout.ts b/cli/src/modules/validation/virtualStorageLayout.ts index 4ff2546d..d3335bff 100644 --- a/cli/src/modules/validation/virtualStorageLayout.ts +++ b/cli/src/modules/validation/virtualStorageLayout.ts @@ -296,7 +296,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, 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/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/facetConfig/facetResolver.test.ts b/cli/test/modules/facetConfig/facetResolver.test.ts new file mode 100644 index 00000000..d3a1e712 --- /dev/null +++ b/cli/test/modules/facetConfig/facetResolver.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import type { BasesCatalog } from "../../../src/modules/config/types"; +import { listAvailableCatalogFacetNames } from "../../../src/modules/facetConfig/facetResolver"; + +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", + ]); + }); +}); diff --git a/cli/test/modules/facetConfig/manifest.test.ts b/cli/test/modules/facetConfig/manifest.test.ts new file mode 100644 index 00000000..23d878a0 --- /dev/null +++ b/cli/test/modules/facetConfig/manifest.test.ts @@ -0,0 +1,73 @@ +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"); + }); +}); diff --git a/cli/test/modules/facetConfig/module.test.ts b/cli/test/modules/facetConfig/module.test.ts new file mode 100644 index 00000000..a093e555 --- /dev/null +++ b/cli/test/modules/facetConfig/module.test.ts @@ -0,0 +1,361 @@ +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("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("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("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(); + }); +}); From 067fe029cd435ef9e03862f9ab2be8fe193939f3 Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:01:21 -0400 Subject: [PATCH 02/14] refactor(cli): share validation AST traversal utilities --- cli/src/modules/validation/astSelectors.ts | 58 ++------- cli/src/modules/validation/astUtils.ts | 90 +++++++++++++ .../validation/virtualStorageLayout.ts | 66 ++-------- cli/test/modules/validation/astUtils.test.ts | 120 ++++++++++++++++++ 4 files changed, 229 insertions(+), 105 deletions(-) create mode 100644 cli/src/modules/validation/astUtils.ts create mode 100644 cli/test/modules/validation/astUtils.test.ts 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/virtualStorageLayout.ts b/cli/src/modules/validation/virtualStorageLayout.ts index d3335bff..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 = { @@ -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/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], + ]); + }); +}); From c61593a3273b845613e967bd21deb9ec1ad2014e Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:03:11 -0400 Subject: [PATCH 03/14] refactor(codegen): share facet section rendering --- cli/src/modules/deployGeneration/renderers.ts | 34 ++-------- cli/src/modules/facetSections.ts | 29 ++++++++ cli/src/modules/testGeneration/renderers.ts | 34 ++-------- .../deployGeneration/renderers.test.ts | 66 +++++++++++++++++++ cli/test/modules/facetSections.test.ts | 35 ++++++++++ .../modules/testGeneration/renderers.test.ts | 62 +++++++++++++++++ 6 files changed, 200 insertions(+), 60 deletions(-) create mode 100644 cli/src/modules/facetSections.ts create mode 100644 cli/test/modules/deployGeneration/renderers.test.ts create mode 100644 cli/test/modules/facetSections.test.ts create mode 100644 cli/test/modules/testGeneration/renderers.test.ts 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/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/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/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/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/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)"); + }); +}); From d67d5dc688cb42f8890789cfc489a1849673574b Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:03:38 -0400 Subject: [PATCH 04/14] refactor(cli): share package facet source lookup --- cli/src/modules/info/scanner.ts | 25 ++----- .../validation/projectSourceResolution.ts | 22 +----- cli/src/utils/packageFacetSources.ts | 45 ++++++++++++ cli/test/modules/info/scanner.test.ts | 70 ++++++++++++++++++ cli/test/modules/validation/project.test.ts | 54 ++++++++++++++ cli/test/utils/packageFacetSources.test.ts | 71 +++++++++++++++++++ 6 files changed, 248 insertions(+), 39 deletions(-) create mode 100644 cli/src/utils/packageFacetSources.ts create mode 100644 cli/test/modules/info/scanner.test.ts create mode 100644 cli/test/utils/packageFacetSources.test.ts diff --git a/cli/src/modules/info/scanner.ts b/cli/src/modules/info/scanner.ts index 1688359a..97ff6627 100644 --- a/cli/src/modules/info/scanner.ts +++ b/cli/src/modules/info/scanner.ts @@ -6,6 +6,7 @@ import { parseExportedSelectorSignatures, parseSolidityFunctions, } from "../../utils/solidityText"; +import { findPackageFacetSourceMatches } from "../../utils/packageFacetSources"; import { ScaffoldingModule } from "../scaffolding/module"; import { ComposeProjectInfo, DiamondInfo, FacetInfo } from "./types"; @@ -39,25 +40,11 @@ async function resolvePackageFacetPath( packageName: string, projectRoot: string, ): 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/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/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/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/validation/project.test.ts b/cli/test/modules/validation/project.test.ts index 6a45a77e..f9f1e208 100644 --- a/cli/test/modules/validation/project.test.ts +++ b/cli/test/modules/validation/project.test.ts @@ -202,4 +202,58 @@ 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 }); + } + }); }); 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 }); + } + }); +}); From 6ce73baabea7c78edbae20d6cf139f6752c71514 Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:04:14 -0400 Subject: [PATCH 05/14] refactor(cli): share compose.json project file discovery --- cli/src/modules/facetConfig/projectConfig.ts | 23 +++--- cli/src/modules/info/loader.ts | 12 ++- cli/src/modules/validation/project.ts | 18 ++--- cli/src/utils/composeProjectFile.ts | 23 ++++++ .../modules/facetConfig/projectConfig.test.ts | 62 ++++++++++++++++ cli/test/modules/info/loader.test.ts | 73 +++++++++++++++++++ cli/test/modules/validation/project.test.ts | 65 +++++++++++++++++ cli/test/utils/composeProjectFile.test.ts | 53 ++++++++++++++ 8 files changed, 300 insertions(+), 29 deletions(-) create mode 100644 cli/src/utils/composeProjectFile.ts create mode 100644 cli/test/modules/facetConfig/projectConfig.test.ts create mode 100644 cli/test/modules/info/loader.test.ts create mode 100644 cli/test/utils/composeProjectFile.test.ts diff --git a/cli/src/modules/facetConfig/projectConfig.ts b/cli/src/modules/facetConfig/projectConfig.ts index 89973867..1e23e66f 100644 --- a/cli/src/modules/facetConfig/projectConfig.ts +++ b/cli/src/modules/facetConfig/projectConfig.ts @@ -1,7 +1,6 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { ComposeContext } from "../../context/types"; -import { atomicWriteFile, findFileAncestor } from "../../utils/files"; +import { atomicWriteFile } from "../../utils/files"; +import { locateComposeProjectFile, readComposeProjectFile } from "../../utils/composeProjectFile"; import type { ComposeProjectConfig } from "./types"; export type LoadedComposeProject = { @@ -15,22 +14,22 @@ function isRecord(value: unknown): value is Record { } export async function loadComposeProject(ctx: ComposeContext): 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' or navigate to a Compose project directory."); } let raw: unknown; try { - raw = JSON.parse(await fs.readFile(composeJsonPath, "utf8")); + raw = await readComposeProjectFile(composeProjectFile.path); } catch (error) { const message = error instanceof Error ? error.message : "unknown JSON parse error"; - throw new Error(`Unable to parse ${composeJsonPath}: ${message}`, { cause: 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(`${composeJsonPath} must contain a non-empty diamonds object.`); + throw new Error(`${composeProjectFile.path} must contain a non-empty diamonds object.`); } for (const [diamondName, diamond] of Object.entries(raw.diamonds)) { @@ -48,17 +47,17 @@ export async function loadComposeProject(ctx: ComposeContext): Promise; ctx.state.facetProjectConfig = { success: true, - result: { path: composeJsonPath, projectRoot }, + result: { path: composeProjectFile.path, projectRoot }, error: null, }; - return { path: composeJsonPath, root: projectRoot, config }; + return { path: composeProjectFile.path, root: projectRoot, config }; } export async function writeComposeProject( 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, ): 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 = (composeJsonOverride as ComposeProjectDefinition | undefined) ?? 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") { @@ -142,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/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/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/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/validation/project.test.ts b/cli/test/modules/validation/project.test.ts index f9f1e208..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,6 +217,7 @@ 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( @@ -256,4 +272,53 @@ describe("validation project loader", () => { 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 }); + } + }); +}); From 2d30262d1e9cd3cacc2d491c9ee94f10725560c2 Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:08:43 -0400 Subject: [PATCH 06/14] fix(cli): reject inherited and reserved keys in facet config --- cli/src/modules/facetConfig/manifest.ts | 15 +++++- cli/src/modules/facetConfig/module.ts | 29 ++++++---- cli/test/modules/facetConfig/manifest.test.ts | 8 +++ cli/test/modules/facetConfig/module.test.ts | 53 +++++++++++++++++++ 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/cli/src/modules/facetConfig/manifest.ts b/cli/src/modules/facetConfig/manifest.ts index 68838a03..2bf559e0 100644 --- a/cli/src/modules/facetConfig/manifest.ts +++ b/cli/src/modules/facetConfig/manifest.ts @@ -11,6 +11,8 @@ import type { 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); } @@ -23,6 +25,12 @@ 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); @@ -60,13 +68,16 @@ export function validateFacetBatch(value: unknown, expectedOperation?: FacetBatc 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.`); } - return facet.trim(); + const name = 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.`); @@ -92,6 +103,7 @@ function validateInstallReference(value: unknown, diamondName: string): FacetIns 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 }; @@ -101,6 +113,7 @@ function validateInstallReference(value: unknown, diamondName: string): FacetIns } 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())) { diff --git a/cli/src/modules/facetConfig/module.ts b/cli/src/modules/facetConfig/module.ts index e9c8d285..f3a7a63d 100644 --- a/cli/src/modules/facetConfig/module.ts +++ b/cli/src/modules/facetConfig/module.ts @@ -63,6 +63,18 @@ function sameRegistration(left: unknown, right: unknown): boolean { return a.source === b.source && a.contract === b.contract && a.package === b.package; } +type MutableDiamondConfig = { contract: string; facets: Record }; + +function getDiamondOrThrow( + diamonds: Record, + diamondName: string, +): MutableDiamondConfig { + if (!Object.hasOwn(diamonds, diamondName)) { + throw new Error(`Diamond '${diamondName}' is not defined in compose.json.`); + } + return diamonds[diamondName]; +} + function markFailure(ctx: ComposeContext, failedAt: string, error: unknown): void { const message = error instanceof Error ? error.message : String(error); ctx.status = { @@ -239,7 +251,7 @@ async function validateCandidateConfig( async function applyInstallBatch(ctx: ComposeContext, batch: FacetBatch): Promise { const project = await loadComposeProject(ctx); const projectConfig = structuredClone(project.config) as Record; - const diamonds = projectConfig.diamonds as Record }>; + const diamonds = projectConfig.diamonds as Record; const { adapter } = await resolveFrameworkAdapter(ctx); const contractSourceRoot = adapter.getContractSourceRoot(project.root); const catalog = ctx.config.bases as BasesCatalog; @@ -250,8 +262,7 @@ async function applyInstallBatch(ctx: ComposeContext, batch: FacetBatch): Promis try { for (const [diamondName, references] of Object.entries(batch.diamonds)) { - const diamond = diamonds[diamondName]; - if (!diamond) throw new Error(`Diamond '${diamondName}' is not defined in compose.json.`); + const diamond = getDiamondOrThrow(diamonds, diamondName); const resolved: ResolvedFacetInstall[] = []; for (const reference of references as FacetInstallReference[]) { resolved.push(await resolveFacetInstallReference(reference, catalog, project.root)); @@ -261,9 +272,8 @@ async function applyInstallBatch(ctx: ComposeContext, batch: FacetBatch): Promis allCreatedFiles.push(...copied.createdFiles); const installedNames: string[] = []; for (const facet of copied.facets) { - const existing = diamond.facets[facet.name]; - if (existing) { - if (!sameRegistration(existing, facet.registration)) { + 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; @@ -306,16 +316,15 @@ async function applyInstallBatch(ctx: ComposeContext, batch: FacetBatch): Promis async function applyRemoveBatch(ctx: ComposeContext, batch: FacetBatch): Promise { const project = await loadComposeProject(ctx); const projectConfig = structuredClone(project.config) as Record; - const diamonds = projectConfig.diamonds as Record }>; + const diamonds = projectConfig.diamonds as Record; const changes: FacetMutationResult["diamondChanges"] = []; let changed = false; for (const [diamondName, facetNames] of Object.entries(batch.diamonds)) { - const diamond = diamonds[diamondName]; - if (!diamond) throw new Error(`Diamond '${diamondName}' is not defined in compose.json.`); + const diamond = getDiamondOrThrow(diamonds, diamondName); const removed: string[] = []; for (const facetName of facetNames as string[]) { - if (!(facetName in diamond.facets)) { + if (!Object.hasOwn(diamond.facets, facetName)) { console.warn(yellow(`Facet '${facetName}' is not registered on '${diamondName}'; skipping.`)); continue; } diff --git a/cli/test/modules/facetConfig/manifest.test.ts b/cli/test/modules/facetConfig/manifest.test.ts index 23d878a0..62872efa 100644 --- a/cli/test/modules/facetConfig/manifest.test.ts +++ b/cli/test/modules/facetConfig/manifest.test.ts @@ -70,4 +70,12 @@ describe("facet batch manifests", () => { 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"); + } + }); }); diff --git a/cli/test/modules/facetConfig/module.test.ts b/cli/test/modules/facetConfig/module.test.ts index a093e555..8e815f7d 100644 --- a/cli/test/modules/facetConfig/module.test.ts +++ b/cli/test/modules/facetConfig/module.test.ts @@ -185,6 +185,59 @@ describe("FacetConfigModule", () => { expect(await fs.readFile(path.join(project.root, "compose.lock"), "utf8")).toBe("keep-lock-untouched\n"); }); + 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) => { From 0ed342106b1d52bfb34d516c792b70a4f191cbf0 Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:09:08 -0400 Subject: [PATCH 07/14] refactor(cli): load compose.json once per facet command --- cli/src/modules/facetConfig/module.ts | 32 +++++++++++++++++---------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/cli/src/modules/facetConfig/module.ts b/cli/src/modules/facetConfig/module.ts index f3a7a63d..d9cfa348 100644 --- a/cli/src/modules/facetConfig/module.ts +++ b/cli/src/modules/facetConfig/module.ts @@ -27,6 +27,7 @@ import { resolveFacetInstallReference, } from "./facetResolver"; import { loadComposeProject, writeComposeProject } from "./projectConfig"; +import type { LoadedComposeProject } from "./projectConfig"; import { showFacetManifestCreated, showFacetMutation } from "./output"; import type { FacetBatch, @@ -161,11 +162,12 @@ async function selectRemoveFacetNames( async function createManifest( ctx: ComposeContext, + project: LoadedComposeProject, operation: FacetBatchOperation, diamondName: string, references: Array, ): Promise { - const projectRoot = String(ctx.param.projectRoot); + const projectRoot = project.root; const manifestName = String(ctx.param.createManifest ?? "").trim(); if (!manifestName) throw new Error("Provide a manifest name with --create-manifest ."); @@ -183,11 +185,10 @@ async function createManifest( : createRemoveFacetBatch({ [diamondName]: references as string[] }); await atomicWriteFile(manifestPath, serializeFacetBatch(batch)); - const projectConfigState = moduleState<{ path: string }>(ctx, "facetProjectConfig"); const result: FacetMutationResult = { operation, projectRoot, - composeJsonPath: projectConfigState?.result?.path ?? "", + composeJsonPath: project.path, diamondChanges: [{ diamond: diamondName, facets: references.map((reference) => @@ -248,8 +249,11 @@ async function validateCandidateConfig( return ctx; } -async function applyInstallBatch(ctx: ComposeContext, batch: FacetBatch): Promise { - const project = await loadComposeProject(ctx); +async function applyInstallBatch( + ctx: ComposeContext, + project: LoadedComposeProject, + batch: FacetBatch, +): Promise { const projectConfig = structuredClone(project.config) as Record; const diamonds = projectConfig.diamonds as Record; const { adapter } = await resolveFrameworkAdapter(ctx); @@ -313,8 +317,11 @@ async function applyInstallBatch(ctx: ComposeContext, batch: FacetBatch): Promis } } -async function applyRemoveBatch(ctx: ComposeContext, batch: FacetBatch): Promise { - const project = await loadComposeProject(ctx); +async function applyRemoveBatch( + ctx: ComposeContext, + project: LoadedComposeProject, + batch: FacetBatch, +): Promise { const projectConfig = structuredClone(project.config) as Record; const diamonds = projectConfig.diamonds as Record; const changes: FacetMutationResult["diamondChanges"] = []; @@ -402,17 +409,18 @@ export const FacetConfigModule = { 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); - return createManifest(ctx, operation, selectedDiamond as string, normalized); + 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, batch); + 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) }), ); } @@ -423,14 +431,14 @@ export const FacetConfigModule = { selectedDiamond as string, project.config.diamonds[selectedDiamond as string].facets, ); - return createManifest(ctx, operation, selectedDiamond as string, refs); + 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, batch); + return applyRemoveBatch(ctx, project, batch); } const targetDiamond = selectedDiamond as string; @@ -441,7 +449,7 @@ export const FacetConfigModule = { ); await confirmRemoval(ctx, refs); const batch = createRemoveFacetBatch({ [targetDiamond]: refs }); - return applyRemoveBatch(ctx, batch); + return applyRemoveBatch(ctx, project, batch); } catch (error) { markFailure(ctx, `facet${operation === "install" ? "Install" : "Remove"}`, error); throw error; From f4a2e608c3e026d8171a7cf75de3c8f5d47eb92e Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:10:38 -0400 Subject: [PATCH 08/14] refactor(cli): share facet install and remove batch flow --- cli/src/modules/facetConfig/module.ts | 258 +++++++++++++++----------- 1 file changed, 146 insertions(+), 112 deletions(-) diff --git a/cli/src/modules/facetConfig/module.ts b/cli/src/modules/facetConfig/module.ts index d9cfa348..04757cc5 100644 --- a/cli/src/modules/facetConfig/module.ts +++ b/cli/src/modules/facetConfig/module.ts @@ -8,7 +8,7 @@ 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 } from "../../utils/files"; +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"; @@ -105,59 +105,71 @@ async function selectDiamond(ctx: ComposeContext, names: string[], operation: st }); } -async function selectInstallReferences( +/** Shared selection skeleton: explicit arguments win, otherwise one multi-select prompt. */ +async function selectFacetNames( ctx: ComposeContext, - catalog: BasesCatalog, - diamondName: string, - registeredFacetNames: Iterable, + 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("Provide facet names or path.sol:Contract references, or use --manifest."); - } - 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.`); - } + if (!isInteractive()) throw new Error(options.withoutArgumentsMessage); + + const names = await options.choices(); const { checkbox } = await loadPrompts(); const selected = await checkbox({ - message: "Select facets to register:", - choices: availableFacetNames.map((name) => ({ name, value: name })), + 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 { - 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("Provide facet names to remove, or use --manifest."); - - const names = Object.keys(configuredFacets); - if (names.length === 0) throw new Error(`Diamond '${diamondName}' has no registered facets.`); - const { checkbox } = await loadPrompts(); - const selected = await checkbox({ - message: `Select facets to unregister from ${diamondName}:`, - choices: names.map((name) => ({ name, value: name })), - theme: checkboxTheme, + return selectFacetNames(ctx, { + promptMessage: `Select facets to unregister from ${diamondName}:`, + withoutArgumentsMessage: "Provide facet names to remove, or use --manifest.", + choices: async () => { + const names = Object.keys(configuredFacets); + if (names.length === 0) throw new Error(`Diamond '${diamondName}' has no registered facets.`); + return names; + }, }); - if (selected.length === 0) throw new Error("No facets selected."); - return selected; } async function createManifest( @@ -249,47 +261,49 @@ async function validateCandidateConfig( return ctx; } -async function applyInstallBatch( +/** Per-operation steps for {@link applyBatch}: what differs, nothing else. */ +type FacetBatchMutation = { + operation: FacetBatchOperation; + editDiamond(options: { + diamondName: string; + diamond: MutableDiamondConfig; + 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) as Record; const diamonds = projectConfig.diamonds as Record; - const { adapter } = await resolveFrameworkAdapter(ctx); - const contractSourceRoot = adapter.getContractSourceRoot(project.root); - const catalog = ctx.config.bases as BasesCatalog; const changes: FacetMutationResult["diamondChanges"] = []; - const allCreatedFiles: string[] = []; + const stagedFiles: string[] = []; let changed = false; let committed = false; try { - for (const [diamondName, references] of Object.entries(batch.diamonds)) { - const diamond = getDiamondOrThrow(diamonds, diamondName); - const resolved: ResolvedFacetInstall[] = []; - for (const reference of references as FacetInstallReference[]) { - resolved.push(await resolveFacetInstallReference(reference, catalog, project.root)); - } - - const copied = await copyCatalogFacetSources(resolved, project.root, contractSourceRoot); - allCreatedFiles.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); - changed = true; - } - changes.push({ diamond: diamondName, facets: installedNames }); + 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 }); } - if (changed || allCreatedFiles.length > 0) { + // 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) { @@ -297,70 +311,90 @@ async function applyInstallBatch( ctx.config.composeJson = projectConfig; } committed = true; - - const result: FacetMutationResult = { - operation: "install", - projectRoot: project.root, - composeJsonPath: project.path, - diamondChanges: changes, - changed, - materializedFiles: allCreatedFiles.map((filePath) => path.relative(project.root, filePath).replace(/\\/g, "/")), - }; - ctx.state.facetMutation = { success: true, result, error: null }; - showFacetMutation(result); - return ctx; } catch (error) { - if (!committed) { - await Promise.all(allCreatedFiles.map((filePath) => fs.rm(filePath, { force: true }))); + 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 applyRemoveBatch( +async function applyInstallBatch( ctx: ComposeContext, project: LoadedComposeProject, batch: FacetBatch, ): Promise { - const projectConfig = structuredClone(project.config) as Record; - const diamonds = projectConfig.diamonds as Record; - const changes: FacetMutationResult["diamondChanges"] = []; - let changed = false; + const { adapter } = await resolveFrameworkAdapter(ctx); + const contractSourceRoot = adapter.getContractSourceRoot(project.root); + const catalog = ctx.config.bases as BasesCatalog; - for (const [diamondName, facetNames] of Object.entries(batch.diamonds)) { - const diamond = getDiamondOrThrow(diamonds, diamondName); - const removed: string[] = []; - for (const facetName of facetNames as string[]) { - if (!Object.hasOwn(diamond.facets, facetName)) { - console.warn(yellow(`Facet '${facetName}' is not registered on '${diamondName}'; skipping.`)); - continue; + 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)); } - delete diamond.facets[facetName]; - removed.push(facetName); - changed = true; - } - if (Object.keys(diamond.facets).length === 0) { - throw new Error(`Cannot remove every registered facet from '${diamondName}'.`); - } - changes.push({ diamond: diamondName, facets: removed }); - } - if (changed) { - await validateCandidateConfig(ctx, projectConfig); - await writeComposeProject(project, projectConfig as typeof project.config); - ctx.config.composeJson = projectConfig; - } + const copied = await copyCatalogFacetSources(resolved, project.root, contractSourceRoot); + stagedFiles.push(...copied.createdFiles); - const result: FacetMutationResult = { + 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", - projectRoot: project.root, - composeJsonPath: project.path, - diamondChanges: changes, - changed, - }; - ctx.state.facetMutation = { success: true, result, error: null }; - showFacetMutation(result); - return ctx; + 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<{ From 6bfc063a5b0b353503bb7fead9183bd6f0c6f2b9 Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:11:19 -0400 Subject: [PATCH 09/14] refactor(cli): drop unreachable facet failure status and manifest state --- cli/src/modules/facetConfig/module.ts | 125 +++++++++++--------------- 1 file changed, 53 insertions(+), 72 deletions(-) diff --git a/cli/src/modules/facetConfig/module.ts b/cli/src/modules/facetConfig/module.ts index 04757cc5..9d283cd1 100644 --- a/cli/src/modules/facetConfig/module.ts +++ b/cli/src/modules/facetConfig/module.ts @@ -76,16 +76,6 @@ function getDiamondOrThrow( return diamonds[diamondName]; } -function markFailure(ctx: ComposeContext, failedAt: string, error: unknown): void { - const message = error instanceof Error ? error.message : String(error); - ctx.status = { - success: false, - stopped: true, - failedAt, - error: { code: "FACET_CONFIG_FAILED", message, nativeError: error }, - }; -} - 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)) { @@ -203,18 +193,14 @@ async function createManifest( composeJsonPath: project.path, diamondChanges: [{ diamond: diamondName, + // Install reaches this with resolved references; remove with plain names. facets: references.map((reference) => - typeof reference === "string" - ? operation === "install" && reference.includes(":") - ? reference.slice(reference.lastIndexOf(":") + 1) - : reference - : reference.name, + typeof reference === "string" ? reference : reference.name, ), }], changed: false, manifestPath, }; - ctx.state.facetManifest = { success: true, result, error: null }; showFacetManifestCreated(result); return ctx; } @@ -419,74 +405,69 @@ async function confirmRemoval(ctx: ComposeContext, names: string[]): Promise { - try { - 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 (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); - 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); - } + 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)); - 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); + const normalized = await normalizeInstallManifestReferences(references, catalog, project.root); + 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, "remove"); - const facetNames = Object.values(batch.diamonds).flat() as string[]; - await confirmRemoval(ctx, facetNames); - return applyRemoveBatch(ctx, project, batch); + 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) }), + ); + } - const targetDiamond = selectedDiamond as string; + if (ctx.param.createManifest) { const refs = await selectRemoveFacetNames( ctx, - targetDiamond, - project.config.diamonds[targetDiamond].facets, + selectedDiamond as string, + project.config.diamonds[selectedDiamond as string].facets, ); - await confirmRemoval(ctx, refs); - const batch = createRemoveFacetBatch({ [targetDiamond]: refs }); + 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); - } catch (error) { - markFailure(ctx, `facet${operation === "install" ? "Install" : "Remove"}`, error); - throw error; } + + 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); }, }; From 80c8926245ff74d276db2d71cec2d1fcdaa18a0a Mon Sep 17 00:00:00 2001 From: Max N Date: Tue, 13 Oct 2026 21:11:50 -0400 Subject: [PATCH 10/14] refactor(cli): reuse ComposeDiamondConfig for facet config reads --- cli/src/modules/facetConfig/module.ts | 15 +++++++-------- cli/src/modules/facetConfig/types.ts | 7 +++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/cli/src/modules/facetConfig/module.ts b/cli/src/modules/facetConfig/module.ts index 9d283cd1..3767a150 100644 --- a/cli/src/modules/facetConfig/module.ts +++ b/cli/src/modules/facetConfig/module.ts @@ -30,6 +30,7 @@ import { loadComposeProject, writeComposeProject } from "./projectConfig"; import type { LoadedComposeProject } from "./projectConfig"; import { showFacetManifestCreated, showFacetMutation } from "./output"; import type { + ComposeDiamondConfig, FacetBatch, FacetBatchOperation, FacetInstallReference, @@ -64,12 +65,10 @@ function sameRegistration(left: unknown, right: unknown): boolean { return a.source === b.source && a.contract === b.contract && a.package === b.package; } -type MutableDiamondConfig = { contract: string; facets: Record }; - function getDiamondOrThrow( - diamonds: Record, + diamonds: Record, diamondName: string, -): MutableDiamondConfig { +): ComposeDiamondConfig { if (!Object.hasOwn(diamonds, diamondName)) { throw new Error(`Diamond '${diamondName}' is not defined in compose.json.`); } @@ -252,7 +251,7 @@ type FacetBatchMutation = { operation: FacetBatchOperation; editDiamond(options: { diamondName: string; - diamond: MutableDiamondConfig; + diamond: ComposeDiamondConfig; entries: unknown; stagedFiles: string[]; }): Promise<{ facets: string[]; changed: boolean }>; @@ -269,8 +268,8 @@ async function applyBatch( batch: FacetBatch, mutation: FacetBatchMutation, ): Promise { - const projectConfig = structuredClone(project.config) as Record; - const diamonds = projectConfig.diamonds as Record; + const projectConfig = structuredClone(project.config); + const diamonds = projectConfig.diamonds; const changes: FacetMutationResult["diamondChanges"] = []; const stagedFiles: string[] = []; let changed = false; @@ -293,7 +292,7 @@ async function applyBatch( ctx = await validateCandidateConfig(ctx, projectConfig); } if (changed) { - await writeComposeProject(project, projectConfig as typeof project.config); + await writeComposeProject(project, projectConfig); ctx.config.composeJson = projectConfig; } committed = true; diff --git a/cli/src/modules/facetConfig/types.ts b/cli/src/modules/facetConfig/types.ts index 4766ffb6..3ee61c73 100644 --- a/cli/src/modules/facetConfig/types.ts +++ b/cli/src/modules/facetConfig/types.ts @@ -9,6 +9,13 @@ export type FacetRegistration = { 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; From 08226b5339901781e437282bfea1be45e6c06fea Mon Sep 17 00:00:00 2001 From: Max N Date: Wed, 14 Oct 2026 16:33:28 -0400 Subject: [PATCH 11/14] fix(cli): keep relative import paths when copying catalog facets Copy locally imported facet dependencies into the same relative folder structure they have under their facet's source directory, so relative imports such as ./libraries/LibFee.sol keep resolving after install. Applies the shared planImportClosureTargets helper to both the install pipeline and init scaffolding. --- .../cd-28-keep-relative-import-paths.md | 5 ++ cli/src/modules/facetConfig/facetResolver.ts | 23 +++----- cli/src/modules/scaffolding/module.ts | 21 ++----- cli/src/utils/files.ts | 58 +++++++++++++++++++ cli/test/modules/facetConfig/module.test.ts | 52 +++++++++++++++++ cli/test/utils/files.test.ts | 58 +++++++++++++++++++ 6 files changed, 184 insertions(+), 33 deletions(-) create mode 100644 .changeset/cd-28-keep-relative-import-paths.md create mode 100644 cli/test/utils/files.test.ts 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/cli/src/modules/facetConfig/facetResolver.ts b/cli/src/modules/facetConfig/facetResolver.ts index ad21f173..3a4f9894 100644 --- a/cli/src/modules/facetConfig/facetResolver.ts +++ b/cli/src/modules/facetConfig/facetResolver.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { BasesCatalog, FacetEntry } from "../config/types"; import { parsePackageName, + planImportClosureTargets, resolveLocalSolidityImportClosure, toPosixPath, } from "../../utils/files"; @@ -246,25 +247,15 @@ export async function copyCatalogFacetSources( `${facet.contractName ?? facet.name}.sol`, ), })); - const sourceToSeed = new Map(seeds.map((seed) => [path.resolve(seed.source), seed])); - const copyTargets = new Map(); - - for (const source of await resolveLocalSolidityImportClosure(seeds.map((seed) => seed.source))) { - const resolvedSource = path.resolve(source); - let target = sourceToSeed.get(resolvedSource)?.target; - if (!target) { - const parent = seeds.find((seed) => { - const relative = path.relative(path.dirname(seed.source), resolvedSource); - return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); - }); - target = path.join(parent ? path.dirname(parent.target) : contractSourceRoot, path.basename(source)); - } - const existingSource = copyTargets.get(path.resolve(target)); - if (existingSource && existingSource !== resolvedSource) { + 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(path.resolve(target), resolvedSource); + copyTargets.set(target, source); } for (const [target, source] of copyTargets) { 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/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/test/modules/facetConfig/module.test.ts b/cli/test/modules/facetConfig/module.test.ts index 8e815f7d..a51820bb 100644 --- a/cli/test/modules/facetConfig/module.test.ts +++ b/cli/test/modules/facetConfig/module.test.ts @@ -382,6 +382,58 @@ describe("FacetConfigModule", () => { 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(); 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"), + ); + }); +}); From c508a23223fabda0ba3225293fa30a172d5e2413 Mon Sep 17 00:00:00 2001 From: Max N Date: Wed, 14 Oct 2026 20:13:34 -0400 Subject: [PATCH 12/14] add fuzzy find of local contract name --- .changeset/fuzzy-local-facet-reference.md | 5 + .changeset/remove-path-reference.md | 5 + cli/src/comander.ts | 4 +- cli/src/modules/facetConfig/facetResolver.ts | 198 +++++++++++++++++- cli/src/modules/facetConfig/manifest.ts | 4 +- cli/src/modules/facetConfig/module.ts | 52 +++-- .../modules/facetConfig/facetResolver.test.ts | 115 +++++++++- cli/test/modules/facetConfig/manifest.test.ts | 17 ++ cli/test/modules/facetConfig/module.test.ts | 46 ++++ 9 files changed, 425 insertions(+), 21 deletions(-) create mode 100644 .changeset/fuzzy-local-facet-reference.md create mode 100644 .changeset/remove-path-reference.md 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/cli/src/comander.ts b/cli/src/comander.ts index d269f0a6..b71634d7 100644 --- a/cli/src/comander.ts +++ b/cli/src/comander.ts @@ -36,7 +36,7 @@ export function buildProgram(): Command { .command("install") .alias("i") .description("Register facets in compose.json without deploying them") - .argument("[facets...]", "Catalog facet names or local path:Contract references") + .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") @@ -47,7 +47,7 @@ export function buildProgram(): Command { .command("remove") .alias("rm") .description("Unregister facets from compose.json without changing a deployment") - .argument("[facets...]", "Facet names to unregister") + .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") diff --git a/cli/src/modules/facetConfig/facetResolver.ts b/cli/src/modules/facetConfig/facetResolver.ts index 3a4f9894..e7ee6aee 100644 --- a/cli/src/modules/facetConfig/facetResolver.ts +++ b/cli/src/modules/facetConfig/facetResolver.ts @@ -13,12 +13,46 @@ import { 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; @@ -72,7 +106,8 @@ export function listAvailableCatalogFacetNames( return listCatalogFacetNames(catalog).filter((name) => !registered.has(name)); } -function contractReferenceName(contract: string): string | null { +/** 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; } @@ -130,6 +165,7 @@ 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); @@ -140,10 +176,15 @@ export async function resolveFacetInstallReference( } const contractName = contractReferenceName(reference); - if (!contractName) { - throw new Error(`Unknown catalog facet '${reference}'. Use a catalog name or path.sol:ContractName.`); + if (contractName) { + return resolveLocalReference(reference, contractName, projectRoot); } - 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) { @@ -152,6 +193,8 @@ export async function resolveFacetInstallReference( 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.`); } @@ -212,6 +255,153 @@ async function resolveLocalReference( }; } +/** + * 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, diff --git a/cli/src/modules/facetConfig/manifest.ts b/cli/src/modules/facetConfig/manifest.ts index 2bf559e0..baa55d12 100644 --- a/cli/src/modules/facetConfig/manifest.ts +++ b/cli/src/modules/facetConfig/manifest.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pathExists } from "../../utils/files"; +import { contractReferenceName } from "./facetResolver"; import type { FacetBatch, FacetBatchOperation, @@ -75,7 +76,8 @@ export function validateFacetBatch(value: unknown, expectedOperation?: FacetBatc if (typeof facet !== "string" || !facet.trim()) { throw new Error(`Remove manifest for '${diamondName}' must contain facet names as strings.`); } - const name = facet.trim(); + // 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; }); diff --git a/cli/src/modules/facetConfig/module.ts b/cli/src/modules/facetConfig/module.ts index 3767a150..4e62b8ed 100644 --- a/cli/src/modules/facetConfig/module.ts +++ b/cli/src/modules/facetConfig/module.ts @@ -21,10 +21,12 @@ import { serializeFacetBatch, } from "./manifest"; import { + contractReferenceName, copyCatalogFacetSources, listAvailableCatalogFacetNames, listCatalogFacetNames, resolveFacetInstallReference, + type FacetReferenceResolutionOptions, } from "./facetResolver"; import { loadComposeProject, writeComposeProject } from "./projectConfig"; import type { LoadedComposeProject } from "./projectConfig"; @@ -46,6 +48,21 @@ function moduleState(ctx: ComposeContext, key: string): ModuleState | unde 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; @@ -53,9 +70,8 @@ function frameworkDependency(framework: string): DependencyKey { } function catalogManifestReference(value: string): FacetInstallReference { - const match = value.match(/:([A-Za-z_$][A-Za-z0-9_$]*)$/); - if (!match) return { name: value }; - return { name: match[1], source: "local", contract: value }; + const name = contractReferenceName(value); + return name ? { name, source: "local", contract: value } : { name: value }; } function sameRegistration(left: unknown, right: unknown): boolean { @@ -150,15 +166,17 @@ async function selectRemoveFacetNames( diamondName: string, configuredFacets: Record, ): Promise { - return selectFacetNames(ctx, { + const names = await selectFacetNames(ctx, { promptMessage: `Select facets to unregister from ${diamondName}:`, withoutArgumentsMessage: "Provide facet names to remove, or use --manifest.", choices: async () => { - const names = Object.keys(configuredFacets); - if (names.length === 0) throw new Error(`Diamond '${diamondName}' has no registered facets.`); - return names; + 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( @@ -208,15 +226,19 @@ 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); - normalized.push(reference.includes(":") + 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: resolved.registration.contract, + contract: localContract, } : { name: resolved.name }); } @@ -326,13 +348,14 @@ async function applyInstallBatch( 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)); + resolved.push(await resolveFacetInstallReference(reference, catalog, project.root, resolution)); } const copied = await copyCatalogFacetSources(resolved, project.root, contractSourceRoot); @@ -426,7 +449,12 @@ export const FacetConfigModule = { 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); + const normalized = await normalizeInstallManifestReferences( + references, + catalog, + project.root, + facetReferenceOptions(), + ); return createManifest(ctx, project, operation, selectedDiamond as string, normalized); } if (ctx.param.manifest) { diff --git a/cli/test/modules/facetConfig/facetResolver.test.ts b/cli/test/modules/facetConfig/facetResolver.test.ts index d3a1e712..f2ffeebb 100644 --- a/cli/test/modules/facetConfig/facetResolver.test.ts +++ b/cli/test/modules/facetConfig/facetResolver.test.ts @@ -1,6 +1,32 @@ -import { describe, expect, it } from "vitest"; +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 { listAvailableCatalogFacetNames } from "../../../src/modules/facetConfig/facetResolver"; +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", () => { @@ -33,3 +59,88 @@ describe("facet install choices", () => { ]); }); }); + +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 index 62872efa..7d8f5ea0 100644 --- a/cli/test/modules/facetConfig/manifest.test.ts +++ b/cli/test/modules/facetConfig/manifest.test.ts @@ -78,4 +78,21 @@ describe("facet batch manifests", () => { 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 index a51820bb..742389f3 100644 --- a/cli/test/modules/facetConfig/module.test.ts +++ b/cli/test/modules/facetConfig/module.test.ts @@ -135,6 +135,35 @@ describe("FacetConfigModule", () => { 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(); @@ -185,6 +214,23 @@ describe("FacetConfigModule", () => { 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(); From 40da1f41e7dbe976df1e7868a1a0da689b884478 Mon Sep 17 00:00:00 2001 From: Max N Date: Wed, 14 Oct 2026 20:14:09 -0400 Subject: [PATCH 13/14] refresh readme for new cli scope --- cli/README.md | 71 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/cli/README.md b/cli/README.md index e58159af..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,20 +18,20 @@ 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-name...] [options] +compose remove [facet-ref...] [options] compose catalog compose info -compose validate +compose validate [options] compose build compose rpc [options] compose inspect
[options] -compose --version | -v +compose help [command] +compose --version | -V compose --help | -h ``` - -`compose install` also has the short alias `compose i`. `compose remove` also has the alias `compose rm`. +``` ### `compose init` @@ -39,18 +39,20 @@ Scaffold a new Compose diamond project. **Options:** - `[project-name]`: directory name for the new project -- `--framework `: target framework +- `--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 +- `--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 with sensible defaults +- `--yes`: non-interactive mode; requires `--base` - `--install-deps` / `--no-install-deps`: install or skip framework dependencies -When `--yes` is not provided, `compose init` will prompt for any values you omit. +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` @@ -76,6 +78,9 @@ 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 @@ -90,7 +95,7 @@ compose install --manifest add-fees - `--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. Package facets require their package source to be available in the project. +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` @@ -103,7 +108,7 @@ compose rm --manifest remove-legacy compose remove --create-manifest remove-legacy --diamond my-token ``` -The options are the same as `compose install`. In an interactive terminal, direct removals ask for confirmation. `--manifest` accepts a file path or a name under `compose/facet-batches/`. +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 @@ -146,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. @@ -184,7 +215,7 @@ compose init my-token \ --toolbox ethers \ --base erc-20 \ --ownership owner-two-step \ - --extensions ERC20BurnFacet,ERC20MintFacet \ + --extensions ERC20BurnFacet,ERC20PermitFacet \ --libraries ERC165Facet \ --yes From 140bf8835fcb25d5384db95f926e122977fc699a Mon Sep 17 00:00:00 2001 From: Max N Date: Wed, 14 Oct 2026 20:14:33 -0400 Subject: [PATCH 14/14] chore: bump compose version --- README.md | 2 +- website/docs/getting-started/installation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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: