From 3dfff96e0a54edee18bfcd9c44743921535556c8 Mon Sep 17 00:00:00 2001 From: Vagabond Date: Thu, 8 Oct 2026 00:31:36 +0700 Subject: [PATCH] feat(cli): add compose selectors command --- .changeset/compose-selectors.md | 5 ++ cli/src/comander.ts | 8 ++- cli/src/modules/inspect/module.ts | 26 +------- cli/src/modules/inspect/selectorLoader.ts | 24 +++++++ cli/src/modules/pipelineBuilder/module.ts | 8 +++ cli/src/modules/selectors/module.ts | 15 +++++ cli/src/modules/selectors/output.ts | 23 +++++++ cli/src/pipelines/selectorsPipeline.ts | 22 +++++++ cli/test/comander.test.ts | 16 +++++ .../pipelineBuilder/pipelineBuilder.test.ts | 16 +++++ cli/test/modules/selectors/output.test.ts | 29 ++++++++ .../selectorsPipeline.test.ts | 66 +++++++++++++++++++ 12 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 .changeset/compose-selectors.md create mode 100644 cli/src/modules/inspect/selectorLoader.ts create mode 100644 cli/src/modules/selectors/module.ts create mode 100644 cli/src/modules/selectors/output.ts create mode 100644 cli/src/pipelines/selectorsPipeline.ts create mode 100644 cli/test/modules/selectors/output.test.ts create mode 100644 cli/test/pipelines/selectorsPipeline/selectorsPipeline.test.ts diff --git a/.changeset/compose-selectors.md b/.changeset/compose-selectors.md new file mode 100644 index 00000000..83444612 --- /dev/null +++ b/.changeset/compose-selectors.md @@ -0,0 +1,5 @@ +--- +"@perfect-abstractions/compose-cli": patch +--- + +Add `compose selectors` to list and decode a deployed diamond's selectors by facet. diff --git a/cli/src/comander.ts b/cli/src/comander.ts index 899fecd4..cdfcd681 100644 --- a/cli/src/comander.ts +++ b/cli/src/comander.ts @@ -61,6 +61,12 @@ export function buildProgram(): Command { .argument("
", "Diamond contract address") .option("--chain ", "Chain key from compose.json", "local") + program + .command("selectors") + .description("List a deployed diamond's selectors by facet") + .argument("
", "Diamond contract address") + .option("--chain ", "Chain key from compose.json", "local") + return program; } @@ -94,7 +100,7 @@ export function parseArgs(argv: string[]): { command: string; flags: Record typeof arg === "string" && arg !== command, ); if (positionalArgs.length > 0) { - if (command === "inspect" && !flags.address) { + if ((command === "inspect" || command === "selectors") && !flags.address) { flags.address = positionalArgs[0]; } else if (!flags.projectName) { flags.projectName = positionalArgs[0]; diff --git a/cli/src/modules/inspect/module.ts b/cli/src/modules/inspect/module.ts index 962d075f..57040b54 100644 --- a/cli/src/modules/inspect/module.ts +++ b/cli/src/modules/inspect/module.ts @@ -1,14 +1,10 @@ -import path from "node:path"; import { isAddress, type Address } from "viem"; import { ComposeContext } from "../../context/types"; import type { IDiamondAdapter } from "../../adapters/IDiamondAdapter/interface"; import type { ResolvedChainConfig } from "../../utils/chainConfig"; -import { findFileAncestor } from "../../utils/files"; import { RPCAdapterError } from "../../adapters/IRPCAdapter/errors"; import { showInspect } from "./output"; -import { toFacetInfo } from "./facetFormatter"; -import { mergeProjectSignatures } from "./selectorDecoder"; -import type { InspectResult, FacetInfo } from "./types"; +import { loadDiamondSelectors } from "./selectorLoader"; /** Validate the command address before resolving chain dependencies. */ export function inspectAddress(value: unknown): Address { @@ -34,28 +30,12 @@ export const InspectModule = { * @param diamond - Resolved Diamond adapter for the selected chain. * @param chain - Resolved chain identity for the result. * @returns The updated context with inspect result stored in - * `ctx.state.inspect` as {@link ModuleState}\<{@link InspectResult}\>. + * `ctx.state.inspect`. * @throws {RPCAdapterError} If the address is invalid or no contract code is * found. */ async inspect(ctx: ComposeContext, diamond: IDiamondAdapter, chain: ResolvedChainConfig): Promise { - const diamondAddress = inspectAddress(ctx.param.address); - const rawFacets = await diamond.facets(diamondAddress); - - const composePath = await findFileAncestor(process.cwd(), "compose.json"); - const projectRoot = composePath ? path.dirname(composePath) : null; - if (projectRoot) { - await mergeProjectSignatures(projectRoot); - } - - const facets: FacetInfo[] = rawFacets.map(toFacetInfo); - - const result: InspectResult = { - diamond: diamondAddress, - chainKey: chain.chainKey, - chainId: chain.chainId, - facets, - }; + const result = await loadDiamondSelectors(inspectAddress(ctx.param.address), diamond, chain); ctx.state.inspect = { success: true, result, error: null }; showInspect(result); diff --git a/cli/src/modules/inspect/selectorLoader.ts b/cli/src/modules/inspect/selectorLoader.ts new file mode 100644 index 00000000..b9e0b28c --- /dev/null +++ b/cli/src/modules/inspect/selectorLoader.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import type { Address } from "viem"; +import type { IDiamondAdapter } from "../../adapters/IDiamondAdapter/interface"; +import type { ResolvedChainConfig } from "../../utils/chainConfig"; +import { findFileAncestor } from "../../utils/files"; +import { toFacetInfo } from "./facetFormatter"; +import { mergeProjectSignatures } from "./selectorDecoder"; +import type { FacetInfo, InspectResult } from "./types"; + +/** Loads and decodes a diamond's selectors for on-chain read commands. */ +export async function loadDiamondSelectors( + address: Address, + diamond: IDiamondAdapter, + chain: ResolvedChainConfig, +): Promise { + const rawFacets = await diamond.facets(address); + const composePath = await findFileAncestor(process.cwd(), "compose.json"); + if (composePath) { + await mergeProjectSignatures(path.dirname(composePath)); + } + + const facets: FacetInfo[] = rawFacets.map(toFacetInfo); + return { diamond: address, chainKey: chain.chainKey, chainId: chain.chainId, facets }; +} diff --git a/cli/src/modules/pipelineBuilder/module.ts b/cli/src/modules/pipelineBuilder/module.ts index df1788e9..f065bf81 100644 --- a/cli/src/modules/pipelineBuilder/module.ts +++ b/cli/src/modules/pipelineBuilder/module.ts @@ -5,6 +5,7 @@ import { CatalogPipeline } from "../../pipelines/catalogPipeline"; import { BuildPipeline } from "../../pipelines/buildPipeline"; import { RPCPipeline } from "../../pipelines/rpcPipeline"; import { InspectPipeline } from "../../pipelines/inspectPipeline"; +import { SelectorsPipeline } from "../../pipelines/selectorsPipeline"; import { ValidatePipeline } from "../../pipelines/validatePipeline"; /** @@ -76,6 +77,13 @@ export const PipelineBuilderModule = { error: null, }; return InspectPipeline.execute(ctx); + case "selectors": + ctx.state.commandSelected = { + success: true, + result: { command: ctx.param.command, address: ctx.param.address, chain: ctx.param.chain }, + error: null, + }; + return SelectorsPipeline.execute(ctx); default: ctx.state.commandRouting = { success: false, diff --git a/cli/src/modules/selectors/module.ts b/cli/src/modules/selectors/module.ts new file mode 100644 index 00000000..211be623 --- /dev/null +++ b/cli/src/modules/selectors/module.ts @@ -0,0 +1,15 @@ +import type { IDiamondAdapter } from "../../adapters/IDiamondAdapter/interface"; +import type { ComposeContext } from "../../context/types"; +import { inspectAddress } from "../inspect/module"; +import { loadDiamondSelectors } from "../inspect/selectorLoader"; +import type { ResolvedChainConfig } from "../../utils/chainConfig"; +import { showSelectors } from "./output"; + +export const SelectorsModule = { + async list(ctx: ComposeContext, diamond: IDiamondAdapter, chain: ResolvedChainConfig): Promise { + const result = await loadDiamondSelectors(inspectAddress(ctx.param.address), diamond, chain); + ctx.state.selectors = { success: true, result, error: null }; + showSelectors(result); + return ctx; + }, +}; diff --git a/cli/src/modules/selectors/output.ts b/cli/src/modules/selectors/output.ts new file mode 100644 index 00000000..0c0703b0 --- /dev/null +++ b/cli/src/modules/selectors/output.ts @@ -0,0 +1,23 @@ +import { cyan, dim, green, yellow } from "../../utils/terminal"; +import type { InspectResult } from "../inspect/types"; + +/** Prints a selector-first view grouped by facet address. */ +export function showSelectors(result: InspectResult): void { + console.log(`\n${cyan("Diamond Selectors")}\n`); + console.log(` Diamond: ${result.diamond}`); + console.log(` Chain: ${result.chainKey} (${result.chainId})`); + console.log(); + + for (const facet of result.facets) { + console.log(` ${facet.address}`); + if (facet.selectors.length === 0) { + console.log(` ${dim("No selectors")}`); + } + for (const { selector, signature } of facet.selectors) { + const unknown = signature.toLowerCase() === selector.toLowerCase(); + console.log(` ${dim(selector)} ${unknown ? yellow("Unknown signature") : green(signature)}`); + } + console.log(); + } + if (result.facets.length === 0) console.log(` ${dim("No facets found")}\n`); +} diff --git a/cli/src/pipelines/selectorsPipeline.ts b/cli/src/pipelines/selectorsPipeline.ts new file mode 100644 index 00000000..c81b162b --- /dev/null +++ b/cli/src/pipelines/selectorsPipeline.ts @@ -0,0 +1,22 @@ +import { ComposeContext } from "../context/types"; +import { inspectAddress } from "../modules/inspect/module"; +import { SelectorsModule } from "../modules/selectors/module"; +import { DependencyKey } from "../resolver/dependencyKey"; +import { DependencyResolver } from "../resolver/dependencyResolver"; +import { resolveChainConfig } from "../utils/chainConfig"; + +/** Focused on-chain selector listing pipeline. */ +export const SelectorsPipeline = { + async execute(ctx: ComposeContext): Promise { + inspectAddress(ctx.param.address); + const chainKey = typeof ctx.param.chain === "string" ? ctx.param.chain : "local"; + const chain = await resolveChainConfig({ chainKey }); + const dependencies = await DependencyResolver.resolve([{ + key: DependencyKey.Diamond, + params: { chainKey: chain.chainKey }, + }]); + const diamond = dependencies[DependencyKey.Diamond]; + if (!diamond) throw new Error("Diamond dependency was not resolved"); + return SelectorsModule.list(ctx, diamond, chain); + }, +}; diff --git a/cli/test/comander.test.ts b/cli/test/comander.test.ts index 985ab631..facd8af8 100644 --- a/cli/test/comander.test.ts +++ b/cli/test/comander.test.ts @@ -105,3 +105,19 @@ describe("inspect command", () => { }); }); + +describe("selectors command", () => { + it("parses address and chain", () => { + expect(parseArgs(["node", "compose", "selectors", "0x0000000000000000000000000000000000000001", "--chain", "sepolia"])).toEqual({ + command: "selectors", + flags: { address: "0x0000000000000000000000000000000000000001", chain: "sepolia" }, + }); + }); + + it("defaults to local", () => { + expect(parseArgs(["node", "compose", "selectors", "0x0000000000000000000000000000000000000001"])).toEqual({ + command: "selectors", + flags: { address: "0x0000000000000000000000000000000000000001", chain: "local" }, + }); + }); +}); diff --git a/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts b/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts index 5a957103..72ed2dd4 100644 --- a/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts +++ b/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { Context } from "../../../src/context/context"; import { PipelineBuilderModule } from "../../../src/modules/pipelineBuilder/module"; import { ValidatePipeline } from "../../../src/pipelines/validatePipeline"; +import { SelectorsPipeline } from "../../../src/pipelines/selectorsPipeline"; describe("PipelineBuilderModule", () => { it("routes the validate command to ValidatePipeline", async () => { @@ -19,4 +20,19 @@ describe("PipelineBuilderModule", () => { execute.mockRestore(); } }); + + it("routes the selectors command to SelectorsPipeline", async () => { + const ctx = Context.create(); + ctx.param = { command: "selectors", address: "0x0000000000000000000000000000000000000001", chain: "local" }; + const execute = vi.spyOn(SelectorsPipeline, "execute").mockResolvedValue(ctx); + + try { + const result = await PipelineBuilderModule.route(ctx); + expect(result).toBe(ctx); + expect(execute).toHaveBeenCalledWith(ctx); + expect(ctx.state.commandSelected).toMatchObject({ success: true, result: ctx.param }); + } finally { + execute.mockRestore(); + } + }); }); diff --git a/cli/test/modules/selectors/output.test.ts b/cli/test/modules/selectors/output.test.ts new file mode 100644 index 00000000..6b797141 --- /dev/null +++ b/cli/test/modules/selectors/output.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { showSelectors } from "../../../src/modules/selectors/output"; + +describe("showSelectors", () => { + it("groups signatures by facet and marks unknown selectors", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + showSelectors({ + diamond: "0x0000000000000000000000000000000000000001", + chainKey: "local", + chainId: 31337, + facets: [{ + address: "0x0000000000000000000000000000000000000002", + index: 0, + selectors: [ + { selector: "0x7a0ed627", signature: "facets()" }, + { selector: "0xdeadbeef", signature: "0xdeadbeef" }, + ], + }], + }); + const output = log.mock.calls.map(([line]) => line).join("\n"); + expect(output).toContain("facets()"); + expect(output).toContain("Unknown signature"); + expect(output).toContain("0xdeadbeef"); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/cli/test/pipelines/selectorsPipeline/selectorsPipeline.test.ts b/cli/test/pipelines/selectorsPipeline/selectorsPipeline.test.ts new file mode 100644 index 00000000..dd202064 --- /dev/null +++ b/cli/test/pipelines/selectorsPipeline/selectorsPipeline.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { Context } from "../../../src/context/context"; +import { DependencyKey } from "../../../src/resolver/dependencyKey"; + +const mocks = vi.hoisted(() => ({ + resolveChainConfig: vi.fn(), + resolve: vi.fn(), + showSelectors: vi.fn(), +})); + +vi.mock("../../../src/utils/chainConfig", () => ({ resolveChainConfig: mocks.resolveChainConfig })); +vi.mock("../../../src/resolver/dependencyResolver", () => ({ DependencyResolver: { resolve: mocks.resolve } })); +vi.mock("../../../src/modules/selectors/output", () => ({ showSelectors: mocks.showSelectors })); + +import { SelectorsPipeline } from "../../../src/pipelines/selectorsPipeline"; + +const address = "0x0000000000000000000000000000000000000001"; + +describe("SelectorsPipeline", () => { + it("rejects an invalid address before resolving the chain", async () => { + const ctx = Context.create(); + ctx.param = { address: "invalid" }; + await expect(SelectorsPipeline.execute(ctx)).rejects.toMatchObject({ code: "RPC_INVALID_ADDRESS" }); + expect(mocks.resolveChainConfig).not.toHaveBeenCalled(); + }); + + it("propagates an unknown chain error before resolving the adapter", async () => { + mocks.resolveChainConfig.mockRejectedValueOnce(new Error("Unknown chain: missing")); + const ctx = Context.create(); + ctx.param = { address, chain: "missing" }; + + await expect(SelectorsPipeline.execute(ctx)).rejects.toThrow("Unknown chain: missing"); + expect(mocks.resolve).not.toHaveBeenCalled(); + }); + + it("queries facets and decodes selectors", async () => { + const facets = vi.fn().mockResolvedValue([ + { facet: "0x0000000000000000000000000000000000000002", functionSelectors: ["0x7a0ed627", "0xdeadbeef"] }, + ]); + mocks.resolveChainConfig.mockResolvedValue({ chainKey: "sepolia", rpcUrl: "https://rpc.example", chainId: 11155111 }); + mocks.resolve.mockResolvedValue({ [DependencyKey.Diamond]: { facets } }); + + const ctx = Context.create(); + ctx.param = { address, chain: "sepolia" }; + const result = await SelectorsPipeline.execute(ctx); + + expect(mocks.resolve).toHaveBeenCalledWith([{ key: DependencyKey.Diamond, params: { chainKey: "sepolia" } }]); + expect(facets).toHaveBeenCalledWith(address); + expect(result.state.selectors).toMatchObject({ + success: true, + result: { facets: [{ selectors: [ + { selector: "0x7a0ed627", signature: "facets()" }, + { selector: "0xdeadbeef", signature: "0xdeadbeef" }, + ] }] }, + }); + expect(mocks.showSelectors).toHaveBeenCalledOnce(); + }); + + it("propagates RPC failures", async () => { + mocks.resolveChainConfig.mockResolvedValue({ chainKey: "local", rpcUrl: "http://localhost", chainId: 31337 }); + mocks.resolve.mockResolvedValue({ [DependencyKey.Diamond]: { facets: vi.fn().mockRejectedValue(new Error("RPC unavailable")) } }); + const ctx = Context.create(); + ctx.param = { address }; + await expect(SelectorsPipeline.execute(ctx)).rejects.toThrow("RPC unavailable"); + }); +});