Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-beers-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perfect-abstractions/compose-cli": minor
---

Add compiler AST support for Foundry and Hardhat, introduce Virtual Storage Layout compatibility checks, and add the standalone `compose validate` pipeline with traceable selector and storage diagnostics.
52 changes: 49 additions & 3 deletions cli/src/adapters/foundryAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import fs from "node:fs/promises";
import path from "node:path";
import { ComposeContext } from "../context/types";
import { ConfigOptions, IFrameworkAdapter } from "./interface/IFrameworkAdapter";
import {
ConfigOptions,
IFrameworkAdapter,
SolidityAstSource,
} from "./interface/IFrameworkAdapter";
import { writeFileIfMissing } from "../utils/files";
import { runCommand } from "../utils/exec";
import { resolveCatalogSourceForRead } from "../utils/soliditySources";
import {
composePackageSubpath,
isComposePackagePath,
} from "../utils/soliditySources";
import { CLI_ROOT } from "../utils/cliRoot";
import { isSourceUnitAst, listJsonFiles, uniqueAstSources } from "../utils/solidityAst";

function ensureTomlSectionSettings(
content: string,
Expand Down Expand Up @@ -59,7 +67,45 @@ const adapter: IFrameworkAdapter = {
},

async resolveSoliditySourcePath(ctx: ComposeContext, sourcePath: string): Promise<string> {
return resolveCatalogSourceForRead(sourcePath);
if (path.isAbsolute(sourcePath)) return sourcePath;

const root = String(ctx.param.projectRoot ?? "");
if (isComposePackagePath(sourcePath)) {
return path.join(root, "lib", "Compose", "src", composePackageSubpath(sourcePath));
}

return path.resolve(root, sourcePath);
},

async compileAst(ctx: ComposeContext, sourcePaths: string[]): Promise<SolidityAstSource[]> {
const root = String(ctx.param.projectRoot ?? "");
const buildPaths = sourcePaths.map((sourcePath) => {
const relativePath = path.relative(root, sourcePath);
return relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)
? relativePath.replace(/\\/g, "/")
: sourcePath;
});
await runCommand("forge", ["build", ...buildPaths, "--ast", "--force"], { cwd: root });

const sources: SolidityAstSource[] = [];
const artifactPaths = await listJsonFiles(path.join(root, "out"));

for (const artifactPath of artifactPaths) {
const artifact = JSON.parse(await fs.readFile(artifactPath, "utf8")) as {
ast?: unknown;
};
if (!isSourceUnitAst(artifact.ast)) continue;

const sourceName = artifact.ast.absolutePath ?? path.basename(path.dirname(artifactPath));
sources.push({ sourceName, ast: artifact.ast });
}

const uniqueSources = uniqueAstSources(sources);
if (uniqueSources.length === 0) {
throw new Error("Foundry compilation did not produce any Solidity AST source units.");
}

return uniqueSources;
},

async initProject(ctx: ComposeContext): Promise<void> {
Expand Down
83 changes: 80 additions & 3 deletions cli/src/adapters/hardhatAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
import fs from "node:fs/promises";
import path from "node:path";
import { ComposeContext } from "../context/types";
import { ConfigOptions, IFrameworkAdapter } from "./interface/IFrameworkAdapter";
import {
ConfigOptions,
IFrameworkAdapter,
SolidityAstSource,
} from "./interface/IFrameworkAdapter";
import { writeFileIfMissing } from "../utils/files";
import { runCommand } from "../utils/exec";
import { isComposePackagePath, resolveCatalogSourceForRead } from "../utils/soliditySources";
import {
composePackageSubpath,
isComposePackagePath,
} from "../utils/soliditySources";
import { ScaffoldingModule } from "../modules/scaffolding/module";
import { CLI_ROOT } from "../utils/cliRoot";
import { isSourceUnitAst, listJsonFiles, uniqueAstSources } from "../utils/solidityAst";

/** Converts a Hardhat compiler source name into its readable filesystem path. */
export function resolveHardhatAstSourcePath(projectRoot: string, sourceName: string): string {
const segments = sourceName.replace(/\\/g, "/").split("/");

if (segments[0] === "project") {
return path.resolve(projectRoot, ...segments.slice(1));
}

if (segments[0] === "npm") {
const packageNameIndex = segments[1]?.startsWith("@") ? 2 : 1;
const versionedPackageName = segments[packageNameIndex] ?? "";
const versionSeparator = versionedPackageName.lastIndexOf("@");
if (versionSeparator > 0) {
segments[packageNameIndex] = versionedPackageName.slice(0, versionSeparator);
}
return path.resolve(projectRoot, "node_modules", ...segments.slice(1));
}

return path.isAbsolute(sourceName)
? path.normalize(sourceName)
: path.resolve(projectRoot, sourceName);
}

/** Framework adapter for Hardhat-based Diamond projects. */
const adapter: IFrameworkAdapter = {
Expand All @@ -31,7 +62,53 @@ const adapter: IFrameworkAdapter = {
},

async resolveSoliditySourcePath(ctx: ComposeContext, sourcePath: string): Promise<string> {
return resolveCatalogSourceForRead(sourcePath);
if (path.isAbsolute(sourcePath)) return sourcePath;

const root = String(ctx.param.projectRoot ?? "");
if (isComposePackagePath(sourcePath)) {
return path.join(
root,
"node_modules",
"@perfect-abstractions",
"compose",
composePackageSubpath(sourcePath),
);
}

return path.resolve(root, sourcePath);
},

async compileAst(ctx: ComposeContext, sourcePaths: string[]): Promise<SolidityAstSource[]> {
const root = String(ctx.param.projectRoot ?? "");
await Promise.all(sourcePaths.map((sourcePath) => fs.access(sourcePath)));
await runCommand("npx", ["hardhat", "compile", "--force"], { cwd: root });

const sources: SolidityAstSource[] = [];
const buildInfoPaths = await listJsonFiles(path.join(root, "artifacts", "build-info"));

for (const buildInfoPath of buildInfoPaths) {
const buildInfo = JSON.parse(await fs.readFile(buildInfoPath, "utf8")) as {
output?: { sources?: Record<string, { ast?: unknown }> };
sources?: Record<string, { ast?: unknown }>;
};
const compilerSources = buildInfo.output?.sources ?? buildInfo.sources ?? {};

for (const [sourceName, compilerSource] of Object.entries(compilerSources)) {
if (isSourceUnitAst(compilerSource.ast)) {
sources.push({
sourceName: resolveHardhatAstSourcePath(root, sourceName),
ast: compilerSource.ast,
});
}
}
}

const uniqueSources = uniqueAstSources(sources);
if (uniqueSources.length === 0) {
throw new Error("Hardhat compilation did not produce any Solidity AST source units.");
}

return uniqueSources;
},

async initProject(ctx: ComposeContext): Promise<void> {
Expand Down
19 changes: 19 additions & 0 deletions cli/src/adapters/interface/IFrameworkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ export type ConfigOptions = {
installDeps: boolean;
};

/** A Solidity source unit AST returned by a framework compiler. */
export type SolidityAstSource = {
sourceName: string;
ast: SoliditySourceUnitAst;
};

/** Minimal stable shape shared by Solidity compact AST source units. */
export type SoliditySourceUnitAst = {
id: number;
nodeType: "SourceUnit";
src: string;
absolutePath?: string;
nodes?: unknown[];
[key: string]: unknown;
};

/** Interface for framework-specific project scaffolding adapters. */
export interface IFrameworkAdapter {
/** Resolve the framework's Solidity source root inside the generated project. */
Expand All @@ -24,6 +40,9 @@ export interface IFrameworkAdapter {
/** Compile the project using the framework's build tool. */
compile(projectRoot: string): Promise<void>;

/** Compile explicit Solidity entrypoints and return every unique source unit AST. */
compileAst(ctx: ComposeContext, sourcePaths: string[]): Promise<SolidityAstSource[]>;

/**
* Resolve a catalog Solidity path to a readable local file.
*
Expand Down
8 changes: 7 additions & 1 deletion cli/src/comander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export function buildProgram(): Command {

program
.command("validate")
.description("Validate your project (Coming Soon...)")
.description("Validate selectors and virtual storage layouts")
.option("--project-root <dir>", "Compose project directory")

program
.command("info")
Expand Down Expand Up @@ -83,6 +84,11 @@ export function parseArgs(argv: string[]): { command: string; flags: Record<stri
const opts = commandInstance.opts();
const flags: Record<string, unknown> = { ...opts };

if (flags.out !== undefined) {
flags.outDir = flags.out;
delete flags.out;
}

// Map the first positional argument to projectName if --name wasn't passed
const positionalArgs = commandInstance.args.filter(
(arg): arg is string => typeof arg === "string" && arg !== command,
Expand Down
5 changes: 4 additions & 1 deletion cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ async function main(): Promise<void> {
ctx.param = { ...ctx.param, ...flags };

try {
await EntryPipeline.execute(ctx);
const result = await EntryPipeline.execute(ctx);
if (!result.status.success) {
process.exitCode = 1;
}
} catch (error) {
exitWithError(error);
}
Expand Down
3 changes: 2 additions & 1 deletion cli/src/modules/pipelineBuilder/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { ValidatePipeline } from "../../pipelines/validatePipeline";

/**
* Pipeline builder module that routes CLI commands to their corresponding pipelines.
Expand Down Expand Up @@ -39,7 +40,7 @@ export const PipelineBuilderModule = {
result: { command: ctx.param.command },
error: null,
};
return ctx;
return ValidatePipeline.execute(ctx);
case "info":
ctx.state.commandSelected = {
success: true,
Expand Down
7 changes: 1 addition & 6 deletions cli/src/modules/scaffolding/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,16 +184,11 @@ export const ScaffoldingModule = {
const scaffoldMapState = ctx.state.scaffoldMap as ModuleState<{ entries: ScaffoldMapEntry[] }> | undefined;
const entries = scaffoldMapState?.result?.entries ?? [];

const facetScanState = ctx.state.facetScan as ModuleState<{ facets: FacetScanResult[] }> | undefined;
const facetScanResults = facetScanState?.result?.facets ?? [];

const facets: Record<string, { source: string; contract: string; package?: string }> = {};
for (const entry of entries) {
const relativePath = toPosixPath(path.relative(root, entry.targetPath));
if (entry.origin === "package") {
const scanResult = facetScanResults.find((f) => f.facetName === entry.facetName);
const sourcePath = scanResult?.path ?? entry.contractName;
const packageName = parsePackageName(sourcePath);
const packageName = parsePackageName(entry.targetPath);
facets[entry.facetName] = {
source: "package",
contract: entry.contractName,
Expand Down
21 changes: 21 additions & 0 deletions cli/src/modules/validation/astIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Matches a resolved source file with the source-unit name emitted by solc. */
export function matchesAstSource(
compilerSourceName: string,
resolvedSourcePath: string,
): boolean {
let sourceName = normalizePath(compilerSourceName);
let sourcePath = normalizePath(resolvedSourcePath);

if (/^[a-zA-Z]:\//.test(sourcePath)) {
sourceName = sourceName.toLowerCase();
sourcePath = sourcePath.toLowerCase();
}

if (sourceName === sourcePath) return true;
const relativeSourceName = sourceName.replace(/^\.\//, "").replace(/^\//, "");
return sourcePath.endsWith(`/${relativeSourceName}`);
}

function normalizePath(value: string): string {
return value.replace(/\\/g, "/").replace(/\/+$/, "");
}
Loading
Loading