recon-generate 0.0.37 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.js CHANGED
@@ -100,10 +100,19 @@ async function ensureBuild(foundryRoot, forceBuild) {
100
100
  await runCmd(cmd, foundryRoot);
101
101
  }
102
102
  /**
103
- * Load the latest build-info JSON file from .recon/out/build-info.
103
+ * Load the latest build-info cluster from .recon/out/build-info.
104
+ *
105
+ * A single `forge build` run may emit multiple build-info files (one per compiler
106
+ * config). Files are clustered by mtime proximity and their outputs are merged:
107
+ * output.contracts + output.sources + input.sources. First-encountered (newest)
108
+ * file wins on key collisions.
104
109
  */
105
110
  function loadBuildInfoRaw(foundryRoot) {
111
+ var _a;
106
112
  const buildInfoDir = path.join(foundryRoot, '.recon', 'out', 'build-info');
113
+ // Reuse the shared helper synchronously via readdirSync/statSync so we don't
114
+ // change this function's signature. findLatestBuildInfoCluster is async; the
115
+ // logic is simple enough to inline here for the sync path.
107
116
  let files = [];
108
117
  try {
109
118
  const entries = fs.readdirSync(buildInfoDir);
@@ -119,28 +128,66 @@ function loadBuildInfoRaw(foundryRoot) {
119
128
  if (files.length === 0) {
120
129
  throw new Error(`No build-info JSON files found in ${buildInfoDir}. Run: forge build --build-info`);
121
130
  }
122
- const latestFile = files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0];
123
- console.log(`[recon-generate] Using build-info: ${latestFile.name}`);
124
- const stat = fs.statSync(latestFile.fullPath);
125
- console.log(`[recon-generate] Reading build-info (${(stat.size / 1024 / 1024).toFixed(1)} MB)...`);
126
- const rawContent = fs.readFileSync(latestFile.fullPath, 'utf-8');
127
- console.log(`[recon-generate] Parsing JSON...`);
128
- const parsed = JSON.parse(rawContent);
129
- const { output, compilerVersion: detectedVersion } = normalizeCompilerOutput(parsed);
130
- const compilerVersion = detectedVersion || '0.8.0';
131
- return { raw: parsed, output, compilerVersion };
131
+ // Cluster by mtime proximity (10s window) see utils.findLatestBuildInfoCluster.
132
+ const CLUSTER_WINDOW_MS = 10000;
133
+ const sorted = files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
134
+ const anchorTime = sorted[0].mtime.getTime();
135
+ const cluster = sorted.filter((f) => anchorTime - f.mtime.getTime() <= CLUSTER_WINDOW_MS);
136
+ if (cluster.length > 1) {
137
+ console.log(`[recon-generate] Detected ${cluster.length} build-info files from latest run; merging.`);
138
+ }
139
+ const mergedOutput = { contracts: {}, sources: {} };
140
+ const mergedInputSources = {};
141
+ let compilerVersion = null;
142
+ for (const file of cluster) {
143
+ console.log(`[recon-generate] Using build-info: ${file.name}`);
144
+ const stat = fs.statSync(file.fullPath);
145
+ console.log(`[recon-generate] Reading build-info (${(stat.size / 1024 / 1024).toFixed(1)} MB)...`);
146
+ const rawContent = fs.readFileSync(file.fullPath, 'utf-8');
147
+ console.log(`[recon-generate] Parsing JSON...`);
148
+ const parsed = JSON.parse(rawContent);
149
+ const { output, compilerVersion: detectedVersion } = normalizeCompilerOutput(parsed);
150
+ // First file (newest) wins on per-key collisions.
151
+ if (output === null || output === void 0 ? void 0 : output.contracts) {
152
+ for (const [filePath, contracts] of Object.entries(output.contracts)) {
153
+ mergedOutput.contracts[filePath] = {
154
+ ...contracts,
155
+ ...(mergedOutput.contracts[filePath] || {}),
156
+ };
157
+ }
158
+ }
159
+ if (output === null || output === void 0 ? void 0 : output.sources) {
160
+ for (const [filePath, src] of Object.entries(output.sources)) {
161
+ if (!mergedOutput.sources[filePath]) {
162
+ mergedOutput.sources[filePath] = src;
163
+ }
164
+ }
165
+ }
166
+ if ((_a = parsed.input) === null || _a === void 0 ? void 0 : _a.sources) {
167
+ for (const [filePath, entry] of Object.entries(parsed.input.sources)) {
168
+ if (!mergedInputSources[filePath]) {
169
+ mergedInputSources[filePath] = entry;
170
+ }
171
+ }
172
+ }
173
+ if (!compilerVersion && detectedVersion) {
174
+ compilerVersion = detectedVersion;
175
+ }
176
+ }
177
+ return {
178
+ output: mergedOutput,
179
+ inputSources: mergedInputSources,
180
+ compilerVersion: compilerVersion || '0.8.0',
181
+ };
132
182
  }
133
183
  /**
134
184
  * Extract source contents from build-info input.sources
135
185
  */
136
- function extractSourceContents(raw) {
137
- var _a;
186
+ function extractSourceContents(inputSources) {
138
187
  const sourceContents = new Map();
139
- if ((_a = raw.input) === null || _a === void 0 ? void 0 : _a.sources) {
140
- for (const [filePath, entry] of Object.entries(raw.input.sources)) {
141
- if (entry === null || entry === void 0 ? void 0 : entry.content) {
142
- sourceContents.set(filePath, entry.content);
143
- }
188
+ for (const [filePath, entry] of Object.entries(inputSources)) {
189
+ if (entry === null || entry === void 0 ? void 0 : entry.content) {
190
+ sourceContents.set(filePath, entry.content);
144
191
  }
145
192
  }
146
193
  return sourceContents;
@@ -148,9 +195,9 @@ function extractSourceContents(raw) {
148
195
  const runDb = async (foundryRoot, options = {}) => {
149
196
  // Build into .recon/out (skipping test/script), reuse if already present
150
197
  await ensureBuild(foundryRoot, !!options.forceBuild);
151
- // Load latest build-info from .recon/out
152
- const { raw, output, compilerVersion } = loadBuildInfoRaw(foundryRoot);
153
- const sourceContents = extractSourceContents(raw);
198
+ // Load latest build-info cluster from .recon/out
199
+ const { output, inputSources, compilerVersion } = loadBuildInfoRaw(foundryRoot);
200
+ const sourceContents = extractSourceContents(inputSources);
154
201
  console.log(`[recon-generate] Compiler version: ${compilerVersion}`);
155
202
  if (sourceContents.size > 0) {
156
203
  console.log(`[recon-generate] Found ${sourceContents.size} source content entries`);
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ const coverage_1 = require("./coverage");
43
43
  const pathsGenerator_1 = require("./pathsGenerator");
44
44
  const info_1 = require("./info");
45
45
  const utils_1 = require("./utils");
46
+ const fs = __importStar(require("fs"));
46
47
  const link_1 = require("./link");
47
48
  const link2_1 = require("./link2");
48
49
  const sourcemap_1 = require("./sourcemap");
@@ -133,7 +134,8 @@ async function main() {
133
134
  .option('--list', 'List available contracts/functions (after filters) and exit')
134
135
  .option('--force', 'Replace existing generated suite output (under --output). Does not rebuild .recon/out.')
135
136
  .option('--force-build', 'Delete .recon/out to force a fresh forge build before generating')
136
- .option('--foundry-config <path>', 'Path to foundry.toml (defaults to ./foundry.toml)');
137
+ .option('--foundry-config <path>', 'Path to foundry.toml (defaults to ./foundry.toml)')
138
+ .option('--scaffold <path>', 'Path to contracts-to-scaffold.json (Scout V2 output)');
137
139
  program
138
140
  .command('coverage')
139
141
  .description('Generate recon-coverage.json from a Crytic tester contract without scaffolding tests')
@@ -354,6 +356,29 @@ async function main() {
354
356
  const foundryConfigPath = (0, utils_1.getFoundryConfigPath)(workspaceRoot, opts.foundryConfig);
355
357
  const foundryRoot = path.dirname(foundryConfigPath);
356
358
  const forgeConfig = await (0, utils_1.getFoundryConfig)(foundryRoot);
359
+ // Scout V2: load contracts-to-scaffold.json and merge into include/mock filters
360
+ if (opts.scaffold) {
361
+ const scaffoldPath = path.isAbsolute(opts.scaffold)
362
+ ? opts.scaffold
363
+ : path.resolve(workspaceRoot, opts.scaffold);
364
+ try {
365
+ const scaffoldData = JSON.parse(fs.readFileSync(scaffoldPath, 'utf-8'));
366
+ const sourceContracts = scaffoldData.source_contracts || [];
367
+ const mockedContracts = scaffoldData.mocked_contracts || [];
368
+ if (sourceContracts.length) {
369
+ const existing = opts.include || '';
370
+ opts.include = [existing, sourceContracts.join(',')].filter(Boolean).join(',');
371
+ }
372
+ if (mockedContracts.length) {
373
+ const existing = opts.mock || '';
374
+ opts.mock = [existing, mockedContracts.join(',')].filter(Boolean).join(',');
375
+ }
376
+ console.log(`Scout V2: loaded ${sourceContracts.length} source + ${mockedContracts.length} mock contracts from ${opts.scaffold}`);
377
+ }
378
+ catch (err) {
379
+ console.error(`Scout V2: failed to load scaffold file ${opts.scaffold}: ${err.message}`);
380
+ }
381
+ }
357
382
  const includeFilter = parseFilter(opts.include);
358
383
  const excludeFilter = parseFilter(opts.exclude);
359
384
  const adminFilter = parseFilter(opts.admin);
package/dist/link2.js CHANGED
@@ -121,40 +121,34 @@ const runForgeBuildAndGetLibraries = async (cwd, verbose = false) => {
121
121
  resolve();
122
122
  });
123
123
  });
124
- // Find the most recent build-info JSON file
124
+ // Find the latest build-info cluster — a single `forge build` run can emit
125
+ // multiple files (one per compiler config), so we stream libraries from all
126
+ // of them and union the results.
125
127
  const foundryConfig = await (0, utils_1.getFoundryConfig)(cwd);
126
128
  const buildInfoDir = path.join(cwd, foundryConfig.out, 'build-info');
127
129
  if (verbose) {
128
130
  console.log(`[VERBOSE] Looking for build-info files in: ${buildInfoDir}`);
129
131
  }
130
- let files;
132
+ let cluster;
131
133
  try {
132
- files = await fs.readdir(buildInfoDir);
133
- }
134
- catch {
135
- throw new Error(`Build info directory not found: ${buildInfoDir}. Make sure forge build --build-info completed successfully.`);
136
- }
137
- const jsonFiles = files.filter(f => f.endsWith('.json'));
138
- if (jsonFiles.length === 0) {
139
- throw new Error(`No build-info JSON files found in ${buildInfoDir}`);
140
- }
141
- // Get the most recent file by modification time
142
- let mostRecentFile = jsonFiles[0];
143
- let mostRecentTime = 0;
144
- for (const file of jsonFiles) {
145
- const filePath = path.join(buildInfoDir, file);
146
- const stats = await fs.stat(filePath);
147
- if (stats.mtimeMs > mostRecentTime) {
148
- mostRecentTime = stats.mtimeMs;
149
- mostRecentFile = file;
150
- }
134
+ cluster = await (0, utils_1.findLatestBuildInfoCluster)(buildInfoDir);
151
135
  }
152
- const buildInfoPath = path.join(buildInfoDir, mostRecentFile);
153
- if (verbose) {
154
- console.log(`[VERBOSE] Streaming build-info from: ${buildInfoPath}`);
136
+ catch (e) {
137
+ throw new Error(`${e.message} Make sure forge build --build-info completed successfully.`);
138
+ }
139
+ if (verbose && cluster.length > 1) {
140
+ console.log(`[VERBOSE] Detected ${cluster.length} build-info files from latest run; streaming each.`);
141
+ }
142
+ const allLibraries = new Set();
143
+ for (const file of cluster) {
144
+ if (verbose) {
145
+ console.log(`[VERBOSE] Streaming build-info from: ${file.path}`);
146
+ }
147
+ const libs = await streamLibrariesFromBuildInfo(file.path, verbose);
148
+ for (const lib of libs)
149
+ allLibraries.add(lib);
155
150
  }
156
- // Stream-parse instead of loading entire file into memory
157
- return streamLibrariesFromBuildInfo(buildInfoPath, verbose);
151
+ return Array.from(allLibraries);
158
152
  };
159
153
  const formatEchidnaYaml = (config, libraries) => {
160
154
  const cfg = { ...config };
package/dist/sourcemap.js CHANGED
@@ -70,26 +70,63 @@ function findFoundryProject(startDir = process.cwd()) {
70
70
  }
71
71
  return null;
72
72
  }
73
- function findBuildInfo(projectDir, outDir = 'out') {
73
+ async function findBuildInfoCluster(projectDir, outDir = 'out') {
74
74
  const buildInfoDir = path.join(projectDir, outDir, 'build-info');
75
75
  if (!fs.existsSync(buildInfoDir))
76
76
  return null;
77
- const files = fs.readdirSync(buildInfoDir).filter(f => f.endsWith('.json'));
78
- if (files.length === 0)
77
+ try {
78
+ return await (0, utils_1.findLatestBuildInfoCluster)(buildInfoDir);
79
+ }
80
+ catch {
79
81
  return null;
80
- // Return the most recent build-info file
81
- const sorted = files.sort((a, b) => {
82
- const statA = fs.statSync(path.join(buildInfoDir, a));
83
- const statB = fs.statSync(path.join(buildInfoDir, b));
84
- return statB.mtimeMs - statA.mtimeMs;
85
- });
86
- return path.join(buildInfoDir, sorted[0]);
82
+ }
87
83
  }
88
84
  function loadBuildInfo(buildInfoPath) {
89
85
  const content = fs.readFileSync(buildInfoPath, 'utf-8');
90
86
  const buildInfo = JSON.parse(content);
91
87
  return buildInfo.output || buildInfo;
92
88
  }
89
+ /**
90
+ * Load and merge build-info output from every file in the cluster.
91
+ * First-encountered (newest) file wins on per-key collisions in sources/contracts.
92
+ */
93
+ function loadAndMergeBuildInfoCluster(cluster) {
94
+ const mergedSources = {};
95
+ const mergedContracts = {};
96
+ const sourceListSet = new Set();
97
+ const paths = [];
98
+ for (const file of cluster) {
99
+ const output = loadBuildInfo(file.path);
100
+ paths.push(file.path);
101
+ if (output === null || output === void 0 ? void 0 : output.sources) {
102
+ for (const [filePath, src] of Object.entries(output.sources)) {
103
+ if (!mergedSources[filePath]) {
104
+ mergedSources[filePath] = src;
105
+ }
106
+ }
107
+ }
108
+ if (output === null || output === void 0 ? void 0 : output.contracts) {
109
+ for (const [filePath, contracts] of Object.entries(output.contracts)) {
110
+ mergedContracts[filePath] = {
111
+ ...contracts,
112
+ ...(mergedContracts[filePath] || {}),
113
+ };
114
+ }
115
+ }
116
+ if (Array.isArray(output === null || output === void 0 ? void 0 : output.sourceList)) {
117
+ for (const s of output.sourceList)
118
+ sourceListSet.add(s);
119
+ }
120
+ }
121
+ return {
122
+ astData: {
123
+ sources: mergedSources,
124
+ contracts: mergedContracts,
125
+ sourceList: Array.from(sourceListSet),
126
+ },
127
+ paths,
128
+ };
129
+ }
93
130
  function getAllSourceContracts(sourceUnits, astData) {
94
131
  const results = [];
95
132
  const sources = astData.sources || {};
@@ -158,14 +195,19 @@ async function runSourcemap(foundryRoot, options = {}) {
158
195
  console.log(`📁 Project: ${projectDir}`);
159
196
  console.log(`📂 Output: ${outputDir}`);
160
197
  const startTime = Date.now();
161
- // Find build info
162
- const buildInfoPath = findBuildInfo(projectDir, forgeOutDir);
163
- if (!buildInfoPath) {
198
+ // Find build-info cluster (handles multi-config runs emitting >1 file).
199
+ const cluster = await findBuildInfoCluster(projectDir, forgeOutDir);
200
+ if (!cluster || cluster.length === 0) {
164
201
  throw new Error(`No build-info found in ${projectDir}/${forgeOutDir}/build-info/. Run "forge build" first to generate build artifacts.`);
165
202
  }
166
- console.log(`📂 Found build-info: ${path.basename(buildInfoPath)}`);
167
- // Load AST
168
- const astData = loadBuildInfo(buildInfoPath);
203
+ if (cluster.length > 1) {
204
+ console.log(`📂 Found ${cluster.length} build-info files (latest run): ${cluster.map(f => f.name).join(', ')}`);
205
+ }
206
+ else {
207
+ console.log(`📂 Found build-info: ${cluster[0].name}`);
208
+ }
209
+ // Load and merge AST + contracts across the cluster.
210
+ const { astData, paths: buildInfoPaths } = loadAndMergeBuildInfoCluster(cluster);
169
211
  const reader = new solc_typed_ast_1.ASTReader();
170
212
  const skipPatterns = ['forge-std/', 'lib/forge-std/'];
171
213
  const filteredSources = {};
@@ -228,7 +270,15 @@ async function runSourcemap(foundryRoot, options = {}) {
228
270
  console.log(`🗺️ Step 2/3: Generating Branch Mappings...`);
229
271
  console.log(`${'─'.repeat(60)}`);
230
272
  const { loadBuildInfoSourceMaps, generateBranchMapping } = await Promise.resolve().then(() => __importStar(require('./cfg/sourcemap')));
231
- const sourceMaps = loadBuildInfoSourceMaps(buildInfoPath);
273
+ // Merge source maps from every cluster file. First-encountered (newest) wins.
274
+ const sourceMaps = new Map();
275
+ for (const p of buildInfoPaths) {
276
+ const perFileMaps = loadBuildInfoSourceMaps(p);
277
+ for (const [name, data] of perFileMaps) {
278
+ if (!sourceMaps.has(name))
279
+ sourceMaps.set(name, data);
280
+ }
281
+ }
232
282
  const contractsToProcess = Array.from(sourceMaps.keys()).filter(name => {
233
283
  const mapData = sourceMaps.get(name);
234
284
  return mapData && mapData.sourceFile.startsWith('src/');
package/dist/utils.d.ts CHANGED
@@ -65,15 +65,38 @@ export declare const parseBuildOutput: (buildOutput: any, skipPatterns?: string[
65
65
  * @returns Object containing sourceUnits and raw buildOutput
66
66
  */
67
67
  export declare const loadBuildInfoFromFile: (buildInfoPath: string, skipPatterns?: string[]) => Promise<BuildInfoResult>;
68
+ /**
69
+ * A single build-info file on disk.
70
+ */
71
+ export interface BuildInfoFile {
72
+ name: string;
73
+ path: string;
74
+ mtime: Date;
75
+ }
76
+ /**
77
+ * Find the cluster of build-info files produced by the latest `forge build` run.
78
+ *
79
+ * Foundry emits one build-info per unique compiler config (e.g. mixed solc
80
+ * versions), so a single run can produce multiple files. We anchor on the
81
+ * newest file's mtime and include any file within `clusterWindowMs`. Older
82
+ * files (from prior runs) are excluded. Returns newest-first ordering.
83
+ */
84
+ export declare const findLatestBuildInfoCluster: (buildInfoDir: string, clusterWindowMs?: number) => Promise<BuildInfoFile[]>;
68
85
  /**
69
86
  * Load the latest build info from a Foundry project.
70
87
  *
88
+ * A single `forge build` run may emit multiple build-info files (one per compiler
89
+ * config — e.g. mixed solc versions). We cluster files by mtime proximity: the
90
+ * newest file anchors a window, and any file with an mtime within `clusterWindowMs`
91
+ * is considered part of the same run. Older files (from prior runs) are ignored.
92
+ *
71
93
  * @param foundryRoot - The root directory of the Foundry project
72
94
  * @param outputDir - The output directory relative to foundryRoot (default: 'out')
73
95
  * @param skipPatterns - Optional array of patterns to skip (e.g., ['test/', 'script/'])
74
- * @returns Object containing sourceUnits and raw buildOutput
96
+ * @param clusterWindowMs - Time window (ms) for grouping build-infos from the same run (default: 10000)
97
+ * @returns Object containing merged sourceUnits and raw buildOutput
75
98
  */
76
- export declare const loadLatestBuildInfo: (foundryRoot: string, outputDir?: string, skipPatterns?: string[]) => Promise<BuildInfoResult>;
99
+ export declare const loadLatestBuildInfo: (foundryRoot: string, outputDir?: string, skipPatterns?: string[], clusterWindowMs?: number) => Promise<BuildInfoResult>;
77
100
  /**
78
101
  * Recursively walks parameter internalType fields and extracts referenced type names.
79
102
  * For dotted types like "struct ISpoke.DynamicReserveConfig" → extracts "ISpoke"
package/dist/utils.js CHANGED
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.loadLatestBuildInfo = exports.loadBuildInfoFromFile = exports.parseBuildOutput = exports.signatureEquals = exports.getFunctionName = exports.getIndex = exports.getSource = exports.getLines = exports.FOUNDRY_CONFIG_DEFAULTS = void 0;
39
+ exports.loadLatestBuildInfo = exports.findLatestBuildInfoCluster = exports.loadBuildInfoFromFile = exports.parseBuildOutput = exports.signatureEquals = exports.getFunctionName = exports.getIndex = exports.getSource = exports.getLines = exports.FOUNDRY_CONFIG_DEFAULTS = void 0;
40
40
  exports.fileExists = fileExists;
41
41
  exports.getFoundryConfigPath = getFoundryConfigPath;
42
42
  exports.findOutputDirectory = findOutputDirectory;
@@ -527,16 +527,14 @@ const loadBuildInfoFromFile = async (buildInfoPath, skipPatterns = []) => {
527
527
  };
528
528
  exports.loadBuildInfoFromFile = loadBuildInfoFromFile;
529
529
  /**
530
- * Load the latest build info from a Foundry project.
530
+ * Find the cluster of build-info files produced by the latest `forge build` run.
531
531
  *
532
- * @param foundryRoot - The root directory of the Foundry project
533
- * @param outputDir - The output directory relative to foundryRoot (default: 'out')
534
- * @param skipPatterns - Optional array of patterns to skip (e.g., ['test/', 'script/'])
535
- * @returns Object containing sourceUnits and raw buildOutput
532
+ * Foundry emits one build-info per unique compiler config (e.g. mixed solc
533
+ * versions), so a single run can produce multiple files. We anchor on the
534
+ * newest file's mtime and include any file within `clusterWindowMs`. Older
535
+ * files (from prior runs) are excluded. Returns newest-first ordering.
536
536
  */
537
- const loadLatestBuildInfo = async (foundryRoot, outputDir = 'out', skipPatterns = []) => {
538
- const outDir = path.join(foundryRoot, outputDir);
539
- const buildInfoDir = path.join(outDir, 'build-info');
537
+ const findLatestBuildInfoCluster = async (buildInfoDir, clusterWindowMs = 10000) => {
540
538
  let files = [];
541
539
  try {
542
540
  const entries = await fs.readdir(buildInfoDir);
@@ -553,8 +551,74 @@ const loadLatestBuildInfo = async (foundryRoot, outputDir = 'out', skipPatterns
553
551
  if (files.length === 0) {
554
552
  throw new Error(`No build-info JSON files found in ${buildInfoDir}.`);
555
553
  }
556
- const latestFile = files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0].path;
557
- return (0, exports.loadBuildInfoFromFile)(latestFile, skipPatterns);
554
+ const sorted = files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
555
+ const anchorTime = sorted[0].mtime.getTime();
556
+ return sorted.filter((f) => anchorTime - f.mtime.getTime() <= clusterWindowMs);
557
+ };
558
+ exports.findLatestBuildInfoCluster = findLatestBuildInfoCluster;
559
+ /**
560
+ * Merge two build outputs by shallow-combining their `contracts` and `sources` maps.
561
+ * Keys present in `base` are preserved (base wins on collision).
562
+ */
563
+ const mergeBuildOutputs = (base, next) => {
564
+ const merged = { ...base };
565
+ if (next === null || next === void 0 ? void 0 : next.contracts) {
566
+ merged.contracts = { ...((base === null || base === void 0 ? void 0 : base.contracts) || {}) };
567
+ for (const [filePath, contracts] of Object.entries(next.contracts)) {
568
+ merged.contracts[filePath] = {
569
+ ...contracts,
570
+ ...(merged.contracts[filePath] || {}),
571
+ };
572
+ }
573
+ }
574
+ if (next === null || next === void 0 ? void 0 : next.sources) {
575
+ merged.sources = { ...((base === null || base === void 0 ? void 0 : base.sources) || {}) };
576
+ for (const [filePath, src] of Object.entries(next.sources)) {
577
+ if (!merged.sources[filePath]) {
578
+ merged.sources[filePath] = src;
579
+ }
580
+ }
581
+ }
582
+ return merged;
583
+ };
584
+ /**
585
+ * Load the latest build info from a Foundry project.
586
+ *
587
+ * A single `forge build` run may emit multiple build-info files (one per compiler
588
+ * config — e.g. mixed solc versions). We cluster files by mtime proximity: the
589
+ * newest file anchors a window, and any file with an mtime within `clusterWindowMs`
590
+ * is considered part of the same run. Older files (from prior runs) are ignored.
591
+ *
592
+ * @param foundryRoot - The root directory of the Foundry project
593
+ * @param outputDir - The output directory relative to foundryRoot (default: 'out')
594
+ * @param skipPatterns - Optional array of patterns to skip (e.g., ['test/', 'script/'])
595
+ * @param clusterWindowMs - Time window (ms) for grouping build-infos from the same run (default: 10000)
596
+ * @returns Object containing merged sourceUnits and raw buildOutput
597
+ */
598
+ const loadLatestBuildInfo = async (foundryRoot, outputDir = 'out', skipPatterns = [], clusterWindowMs = 10000) => {
599
+ const outDir = path.join(foundryRoot, outputDir);
600
+ const buildInfoDir = path.join(outDir, 'build-info');
601
+ const cluster = await (0, exports.findLatestBuildInfoCluster)(buildInfoDir, clusterWindowMs);
602
+ if (cluster.length === 1) {
603
+ return (0, exports.loadBuildInfoFromFile)(cluster[0].path, skipPatterns);
604
+ }
605
+ // Parse each build-info independently (each has its own AST ID space; feeding
606
+ // merged sources into a single ASTReader would collide on duplicate IDs).
607
+ // Then concat sourceUnits (deduped by absolutePath) and merge buildOutput maps.
608
+ const seenSourcePaths = new Set();
609
+ const mergedSourceUnits = [];
610
+ let mergedBuildOutput = {};
611
+ for (const f of cluster) {
612
+ const { sourceUnits, buildOutput } = await (0, exports.loadBuildInfoFromFile)(f.path, skipPatterns);
613
+ for (const unit of sourceUnits) {
614
+ if (!seenSourcePaths.has(unit.absolutePath)) {
615
+ seenSourcePaths.add(unit.absolutePath);
616
+ mergedSourceUnits.push(unit);
617
+ }
618
+ }
619
+ mergedBuildOutput = mergeBuildOutputs(mergedBuildOutput, buildOutput);
620
+ }
621
+ return { sourceUnits: mergedSourceUnits, buildOutput: mergedBuildOutput };
558
622
  };
559
623
  exports.loadLatestBuildInfo = loadLatestBuildInfo;
560
624
  // --- Extra import resolution utilities ---
package/dist/z3Solver.js CHANGED
@@ -125,18 +125,17 @@ const getExpressionName = (expr) => {
125
125
  };
126
126
  const expressionToZ3 = (ctx, expr, varMap, bitSize, signed) => {
127
127
  if (expr instanceof solc_typed_ast_1.Literal) {
128
- let value = expr.value || '0';
129
- // Handle hex values
130
- if (value.startsWith('0x')) {
131
- value = BigInt(value).toString();
132
- }
133
- // Handle negative values for signed types
128
+ // Only numeric literals can be solved as BitVecs. Bool/string/hexString
129
+ // literals have no BitVec form — skip them instead of letting BigInt throw.
130
+ if (expr.kind !== 'number')
131
+ return null;
132
+ const value = (expr.value || '').trim();
133
+ if (!/^-?(0x[0-9a-fA-F]+|[0-9]+)$/.test(value))
134
+ return null;
134
135
  if (value.startsWith('-')) {
135
136
  const absVal = BigInt(value.slice(1));
136
- // Two's complement
137
137
  const maxVal = BigInt(1) << BigInt(bitSize);
138
- const signedVal = maxVal - absVal;
139
- return ctx.BitVec.val(signedVal, bitSize);
138
+ return ctx.BitVec.val(maxVal - absVal, bitSize);
140
139
  }
141
140
  return ctx.BitVec.val(BigInt(value), bitSize);
142
141
  }
@@ -252,19 +251,26 @@ const createZ3Constraint = (ctx, expr, varMap, bitSize, signed) => {
252
251
  }
253
252
  };
254
253
  /**
255
- * Parse Z3 value string to BigInt
256
- * Z3 returns hex values in format like #xFFFF... or decimal strings
254
+ * Parse Z3 value string to BigInt.
255
+ * Z3 returns hex values in format like #xFFFF... or decimal strings.
256
+ * Returns null for unparseable values (e.g., uninterpreted-function expressions
257
+ * like `keccak256` or `unknown_length` that Z3 couldn't reduce to a literal).
257
258
  */
258
259
  const parseZ3Value = (valueStr) => {
259
- // Handle Z3 hex format: #xFFFF... -> 0xFFFF...
260
260
  if (valueStr.startsWith('#x')) {
261
- return BigInt('0x' + valueStr.slice(2));
261
+ const hex = valueStr.slice(2);
262
+ if (!/^[0-9a-fA-F]+$/.test(hex))
263
+ return null;
264
+ return BigInt('0x' + hex);
262
265
  }
263
- // Handle Z3 binary format: #b0101... -> 0b0101...
264
266
  if (valueStr.startsWith('#b')) {
265
- return BigInt('0b' + valueStr.slice(2));
267
+ const bin = valueStr.slice(2);
268
+ if (!/^[01]+$/.test(bin))
269
+ return null;
270
+ return BigInt('0b' + bin);
266
271
  }
267
- // Handle regular decimal or hex
272
+ if (!/^-?(0x[0-9a-fA-F]+|[0-9]+)$/.test(valueStr))
273
+ return null;
268
274
  return BigInt(valueStr);
269
275
  };
270
276
  const solveConstraint = async (ctx, constraint, varMap, bitSize, signed) => {
@@ -279,6 +285,9 @@ const solveConstraint = async (ctx, constraint, varMap, bitSize, signed) => {
279
285
  const value = model.eval(z3Var);
280
286
  const valueStr = value.toString();
281
287
  let numValue = parseZ3Value(valueStr);
288
+ // Skip uninterpreted/non-numeric model values (keccak256(...), etc.)
289
+ if (numValue === null)
290
+ continue;
282
291
  // Convert to signed if needed
283
292
  if (signed && numValue >= BigInt(1) << BigInt(bitSize - 1)) {
284
293
  numValue = numValue - (BigInt(1) << BigInt(bitSize));
@@ -295,11 +304,19 @@ const setZ3Silent = (silent) => {
295
304
  exports.setZ3Silent = setZ3Silent;
296
305
  const findZ3Solutions = async (fnDef) => {
297
306
  const solutions = [];
307
+ let ctx;
298
308
  try {
299
- const { ctx } = await initZ3();
300
- // Find all comparison operations
301
- const binOps = fnDef.getChildrenByType(solc_typed_ast_1.BinaryOperation);
302
- for (const binOp of binOps) {
309
+ ({ ctx } = await initZ3());
310
+ }
311
+ catch (e) {
312
+ if (!silentMode) {
313
+ console.warn(`[z3] Warning: init failed: ${e}`);
314
+ }
315
+ return solutions;
316
+ }
317
+ const binOps = fnDef.getChildrenByType(solc_typed_ast_1.BinaryOperation);
318
+ for (const binOp of binOps) {
319
+ try {
303
320
  // Only handle equality operator
304
321
  if (binOp.operator !== '==') {
305
322
  continue;
@@ -363,11 +380,11 @@ const findZ3Solutions = async (fnDef) => {
363
380
  });
364
381
  }
365
382
  }
366
- }
367
- catch (e) {
368
- // Z3 errors are non-fatal
369
- if (!silentMode) {
370
- console.warn(`[z3] Warning: ${e}`);
383
+ catch (e) {
384
+ // Per-constraint errors are non-fatal — skip this binOp and continue.
385
+ if (!silentMode) {
386
+ console.warn(`[z3] Warning: ${e}`);
387
+ }
371
388
  }
372
389
  }
373
390
  return solutions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recon-generate",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
4
4
  "description": "CLI to scaffold Recon fuzzing suite inside Foundry projects",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -26,15 +26,15 @@
26
26
  "commander": "^14.0.3",
27
27
  "ethers": "^6.16.0",
28
28
  "handlebars": "^4.7.8",
29
- "solc-typed-ast": "^20.0.2",
29
+ "solc-typed-ast": "^20.0.5",
30
30
  "src-location": "^1.1.0",
31
31
  "stream-json": "^1.9.1",
32
- "better-sqlite3": "^12.6.2",
33
- "yaml": "^2.8.2",
34
- "z3-solver": "^4.15.4"
32
+ "better-sqlite3": "^12.9.0",
33
+ "yaml": "^2.8.3",
34
+ "z3-solver": "^4.16.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@types/node": "^25.2.0",
37
+ "@types/node": "^25.6.0",
38
38
  "@types/stream-json": "^1.7.8",
39
39
  "typescript": "^5.8.3"
40
40
  }