recon-generate 0.0.9 → 0.0.11
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/generator.d.ts +2 -0
- package/dist/generator.js +71 -0
- package/dist/index.js +55 -1
- package/dist/pathsGenerator.d.ts +5 -1
- package/dist/pathsGenerator.js +62 -11
- package/dist/processor.js +175 -9
- package/dist/templates/setup.js +4 -3
- package/dist/templates/wake/before_after.d.ts +1 -0
- package/dist/templates/wake/before_after.js +33 -0
- package/dist/templates/wake/conftest.d.ts +1 -0
- package/dist/templates/wake/conftest.js +18 -0
- package/dist/templates/wake/crytic_tester.d.ts +1 -0
- package/dist/templates/wake/crytic_tester.js +20 -0
- package/dist/templates/wake/flows.d.ts +1 -0
- package/dist/templates/wake/flows.js +18 -0
- package/dist/templates/wake/fuzz-test.d.ts +1 -0
- package/dist/templates/wake/fuzz-test.js +62 -0
- package/dist/templates/wake/handlebars_helpers.d.ts +2 -0
- package/dist/templates/wake/handlebars_helpers.js +84 -0
- package/dist/templates/wake/helpers/actor_manager.d.ts +1 -0
- package/dist/templates/wake/helpers/actor_manager.js +44 -0
- package/dist/templates/wake/helpers/asset_manager.d.ts +1 -0
- package/dist/templates/wake/helpers/asset_manager.js +44 -0
- package/dist/templates/wake/helpers/utils.d.ts +1 -0
- package/dist/templates/wake/helpers/utils.js +11 -0
- package/dist/templates/wake/helpers.d.ts +2 -0
- package/dist/templates/wake/helpers.js +81 -0
- package/dist/templates/wake/index.d.ts +10 -0
- package/dist/templates/wake/index.js +26 -0
- package/dist/templates/wake/invariants.d.ts +1 -0
- package/dist/templates/wake/invariants.js +21 -0
- package/dist/templates/wake/properties.d.ts +1 -0
- package/dist/templates/wake/properties.js +19 -0
- package/dist/templates/wake/setup.d.ts +1 -0
- package/dist/templates/wake/setup.js +48 -0
- package/dist/templates/wake/target_functions.d.ts +1 -0
- package/dist/templates/wake/target_functions.js +24 -0
- package/dist/templates/wake/targets/contract_targets.d.ts +1 -0
- package/dist/templates/wake/targets/contract_targets.js +23 -0
- package/dist/templates/wake/targets/managers_targets.d.ts +1 -0
- package/dist/templates/wake/targets/managers_targets.js +26 -0
- package/dist/templates/wake/test_fuzz.d.ts +1 -0
- package/dist/templates/wake/test_fuzz.js +27 -0
- package/dist/wakeGenerator.d.ts +32 -0
- package/dist/wakeGenerator.js +447 -0
- package/package.json +1 -1
package/dist/generator.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export declare class ReconGenerator {
|
|
|
24
24
|
private generatedMocks;
|
|
25
25
|
private allowedMockNames;
|
|
26
26
|
private contractKindCache;
|
|
27
|
+
private abstractContractCache;
|
|
27
28
|
constructor(foundryRoot: string, options: GeneratorOptions);
|
|
28
29
|
private logDebug;
|
|
29
30
|
private outDir;
|
|
@@ -37,6 +38,7 @@ export declare class ReconGenerator {
|
|
|
37
38
|
private updateRemappings;
|
|
38
39
|
private updateGitignore;
|
|
39
40
|
private getContractKind;
|
|
41
|
+
private isAbstractContract;
|
|
40
42
|
private findSourceContracts;
|
|
41
43
|
private matchesSignatureOrName;
|
|
42
44
|
private paramType;
|
package/dist/generator.js
CHANGED
|
@@ -54,6 +54,7 @@ class ReconGenerator {
|
|
|
54
54
|
this.generatedMocks = new Set();
|
|
55
55
|
this.allowedMockNames = new Set();
|
|
56
56
|
this.contractKindCache = new Map();
|
|
57
|
+
this.abstractContractCache = new Map();
|
|
57
58
|
}
|
|
58
59
|
logDebug(message, obj) {
|
|
59
60
|
if (!this.options.debug)
|
|
@@ -196,6 +197,53 @@ class ReconGenerator {
|
|
|
196
197
|
this.contractKindCache.set(absPath, fileCache);
|
|
197
198
|
return kind;
|
|
198
199
|
}
|
|
200
|
+
async isAbstractContract(sourcePath, contractName) {
|
|
201
|
+
const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(this.foundryRoot, sourcePath);
|
|
202
|
+
let fileCache = this.abstractContractCache.get(absPath);
|
|
203
|
+
if (fileCache && fileCache.has(contractName)) {
|
|
204
|
+
return fileCache.get(contractName);
|
|
205
|
+
}
|
|
206
|
+
let content;
|
|
207
|
+
try {
|
|
208
|
+
content = await fs.readFile(absPath, 'utf8');
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
this.logDebug('Failed to read source when determining abstract contract', { sourcePath, error: String(e) });
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
let ast;
|
|
215
|
+
try {
|
|
216
|
+
ast = parser_1.default.parse(content, { tolerant: true });
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
this.logDebug('Failed to parse source when determining abstract contract', { sourcePath, error: String(e) });
|
|
220
|
+
ast = null;
|
|
221
|
+
}
|
|
222
|
+
fileCache = fileCache !== null && fileCache !== void 0 ? fileCache : new Map();
|
|
223
|
+
let isAbstract = false;
|
|
224
|
+
const children = ast === null || ast === void 0 ? void 0 : ast.children;
|
|
225
|
+
if (Array.isArray(children)) {
|
|
226
|
+
for (const node of children) {
|
|
227
|
+
if ((node === null || node === void 0 ? void 0 : node.type) === 'ContractDefinition' && node.name === contractName) {
|
|
228
|
+
if (node.abstract === true) {
|
|
229
|
+
isAbstract = true;
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!isAbstract) {
|
|
236
|
+
// Fallback: text search for `abstract contract <Name>` to cover parser misses
|
|
237
|
+
const stripped = content.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
|
238
|
+
const pattern = new RegExp(`\\babstract\\s+contract\\s+${contractName}\\b`);
|
|
239
|
+
if (pattern.test(stripped)) {
|
|
240
|
+
isAbstract = true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
fileCache.set(contractName, isAbstract);
|
|
244
|
+
this.abstractContractCache.set(absPath, fileCache);
|
|
245
|
+
return isAbstract;
|
|
246
|
+
}
|
|
199
247
|
async findSourceContracts() {
|
|
200
248
|
var _a, _b;
|
|
201
249
|
const contracts = [];
|
|
@@ -226,11 +274,34 @@ class ReconGenerator {
|
|
|
226
274
|
const lowerName = String(contractName).toLowerCase();
|
|
227
275
|
const isGeneratedMock = this.generatedMocks.has(String(contractName));
|
|
228
276
|
const contractKind = await this.getContractKind(sourcePath, String(contractName));
|
|
277
|
+
const isAbstract = await this.isAbstractContract(sourcePath, String(contractName));
|
|
278
|
+
const absSourcePath = path.isAbsolute(sourcePath)
|
|
279
|
+
? sourcePath
|
|
280
|
+
: path.join(this.foundryRoot, sourcePath);
|
|
281
|
+
const relSourcePath = path.relative(this.foundryRoot, absSourcePath).replace(/\\/g, '/').toLowerCase();
|
|
282
|
+
const skipPrefixes = [
|
|
283
|
+
'test/',
|
|
284
|
+
'tests/',
|
|
285
|
+
'script/',
|
|
286
|
+
'scripts/',
|
|
287
|
+
'contracts/test/',
|
|
288
|
+
'contracts/tests/',
|
|
289
|
+
'lib/',
|
|
290
|
+
];
|
|
229
291
|
// Check if contract is explicitly included or mocked - if so, don't filter by kind
|
|
230
292
|
const isExplicitlyIncluded = this.options.include &&
|
|
231
293
|
(this.options.include.contractOnly.has(String(contractName)) ||
|
|
232
294
|
this.options.include.functions.has(String(contractName)));
|
|
233
295
|
const isExplicitlyMocked = (_b = this.options.mocks) === null || _b === void 0 ? void 0 : _b.has(String(contractName));
|
|
296
|
+
const inSkipDir = skipPrefixes.some(prefix => relSourcePath.startsWith(prefix));
|
|
297
|
+
if (!isGeneratedMock && inSkipDir && !isExplicitlyIncluded && !isExplicitlyMocked) {
|
|
298
|
+
this.logDebug('Skipping artifact from non-source directory', { contractName, sourcePath });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (isAbstract && !isExplicitlyIncluded && !isExplicitlyMocked) {
|
|
302
|
+
this.logDebug('Skipping abstract contract', { contractName, sourcePath });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
234
305
|
if ((contractKind === 'interface' || contractKind === 'library') && !isExplicitlyIncluded && !isExplicitlyMocked) {
|
|
235
306
|
this.logDebug('Skipping non-contract artifact', { contractName, sourcePath, contractKind });
|
|
236
307
|
continue;
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ const commander_1 = require("commander");
|
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
39
|
const case_1 = require("case");
|
|
40
40
|
const generator_1 = require("./generator");
|
|
41
|
+
const wakeGenerator_1 = require("./wakeGenerator");
|
|
41
42
|
const coverage_1 = require("./coverage");
|
|
42
43
|
const pathsGenerator_1 = require("./pathsGenerator");
|
|
43
44
|
const utils_1 = require("./utils");
|
|
@@ -146,6 +147,8 @@ async function main() {
|
|
|
146
147
|
.option('--crytic-name <name>', 'Name of the Crytic tester contract to compile', 'CryticTester')
|
|
147
148
|
.option('--name <suite>', 'Suite name; affects paths filename (recon-<name>-paths.json)')
|
|
148
149
|
.option('--foundry-config <path>', 'Path to foundry.toml (defaults to ./foundry.toml)')
|
|
150
|
+
.option('--dry-run', 'Do not enumerate paths; only report which functions/external calls would be processed')
|
|
151
|
+
.option('--max-paths <n>', 'Cap path enumeration per function to avoid OOM (default: 5000)')
|
|
149
152
|
.action(async (opts, cmd) => {
|
|
150
153
|
var _a;
|
|
151
154
|
const workspaceRoot = process.cwd();
|
|
@@ -154,7 +157,11 @@ async function main() {
|
|
|
154
157
|
const parentOpts = ((_a = cmd.parent) === null || _a === void 0 ? void 0 : _a.opts()) || {};
|
|
155
158
|
const suiteRaw = opts.name || parentOpts.name ? String(opts.name || parentOpts.name).trim() : '';
|
|
156
159
|
const suiteSnake = suiteRaw ? (0, case_1.snake)(suiteRaw) : '';
|
|
157
|
-
|
|
160
|
+
const maxPaths = opts.maxPaths ? parseInt(String(opts.maxPaths), 10) : 5000;
|
|
161
|
+
await (0, pathsGenerator_1.runPaths)(foundryRoot, opts.cryticName || 'CryticTester', suiteSnake, {
|
|
162
|
+
dryRun: !!opts.dryRun,
|
|
163
|
+
maxPaths: Number.isFinite(maxPaths) && maxPaths > 0 ? maxPaths : undefined,
|
|
164
|
+
});
|
|
158
165
|
});
|
|
159
166
|
program
|
|
160
167
|
.command('link')
|
|
@@ -185,6 +192,53 @@ async function main() {
|
|
|
185
192
|
: path.join(foundryRoot, medusaConfigOpt);
|
|
186
193
|
await (0, link_1.runLink)(foundryRoot, echidnaConfigPath, medusaConfigPath, !!opts.verbose);
|
|
187
194
|
});
|
|
195
|
+
program
|
|
196
|
+
.command('wake')
|
|
197
|
+
.description('Generate Python fuzzing suite using Wake framework')
|
|
198
|
+
.option('--include <spec>', 'Include filter: e.g. "A,B:{foo(uint256)|bar(address)},C"')
|
|
199
|
+
.option('--exclude <spec>', 'Exclude filter: e.g. "A:{foo(uint256)},D" or just "B"')
|
|
200
|
+
.option('--admin <spec>', 'Mark functions as admin: e.g. "A:{foo(uint256),bar()},B:{baz(address)}"')
|
|
201
|
+
.option('--name <suite>', 'Suite name; affects folder (tests/recon-name)')
|
|
202
|
+
.option('-o, --output <path>', 'Custom output folder for tests')
|
|
203
|
+
.option('--debug', 'Print debug info about filters and selection')
|
|
204
|
+
.option('--list', 'List available contracts/functions (after filters) and exit')
|
|
205
|
+
.option('--force', 'Replace existing generated suite output')
|
|
206
|
+
.option('--force-build', 'Delete .recon/out to force a fresh forge build before generating')
|
|
207
|
+
.option('--foundry-config <path>', 'Path to foundry.toml (defaults to ./foundry.toml)')
|
|
208
|
+
.action(async (opts, cmd) => {
|
|
209
|
+
var _a;
|
|
210
|
+
const workspaceRoot = process.cwd();
|
|
211
|
+
const foundryConfig = (0, utils_1.getFoundryConfigPath)(workspaceRoot, opts.foundryConfig);
|
|
212
|
+
const foundryRoot = path.dirname(foundryConfig);
|
|
213
|
+
const parentOpts = ((_a = cmd.parent) === null || _a === void 0 ? void 0 : _a.opts()) || {};
|
|
214
|
+
const includeFilter = parseFilter(opts.include);
|
|
215
|
+
const excludeFilter = parseFilter(opts.exclude);
|
|
216
|
+
const adminFilter = parseFilter(opts.admin);
|
|
217
|
+
const suiteRaw = opts.name || parentOpts.name ? String(opts.name || parentOpts.name).trim() : '';
|
|
218
|
+
const suiteSnake = suiteRaw ? (0, case_1.snake)(suiteRaw) : '';
|
|
219
|
+
const suitePascal = suiteRaw ? (0, case_1.pascal)(suiteRaw) : '';
|
|
220
|
+
const suiteFolderName = suiteSnake ? `recon_${suiteSnake}` : 'recon';
|
|
221
|
+
const baseOut = opts.output ? String(opts.output) : 'tests';
|
|
222
|
+
const baseOutPath = path.isAbsolute(baseOut) ? baseOut : path.resolve(foundryRoot, baseOut);
|
|
223
|
+
const suiteDir = path.resolve(baseOutPath, suiteFolderName);
|
|
224
|
+
const generator = new wakeGenerator_1.WakeGenerator(foundryRoot, {
|
|
225
|
+
suiteDir,
|
|
226
|
+
foundryConfigPath: foundryConfig,
|
|
227
|
+
include: includeFilter,
|
|
228
|
+
exclude: excludeFilter,
|
|
229
|
+
admin: adminFilter ? adminFilter.functions : undefined,
|
|
230
|
+
debug: !!opts.debug || !!parentOpts.debug,
|
|
231
|
+
force: Boolean(opts.force || parentOpts.force),
|
|
232
|
+
forceBuild: !!opts.forceBuild || !!parentOpts.forceBuild,
|
|
233
|
+
suiteNameSnake: suiteSnake,
|
|
234
|
+
suiteNamePascal: suitePascal,
|
|
235
|
+
});
|
|
236
|
+
if (opts.list) {
|
|
237
|
+
await generator.listAvailable();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
await generator.run();
|
|
241
|
+
});
|
|
188
242
|
program
|
|
189
243
|
.action(async (opts) => {
|
|
190
244
|
const workspaceRoot = process.cwd();
|
package/dist/pathsGenerator.d.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* Generates minimal paths needed for 100% branch coverage.
|
|
5
5
|
* Output is optimized for LLM consumption.
|
|
6
6
|
*/
|
|
7
|
+
export interface RunPathsOptions {
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
maxPaths?: number;
|
|
10
|
+
}
|
|
7
11
|
/** Simple output: function name -> array of path condition strings */
|
|
8
12
|
export type PathOutput = Record<string, string[]>;
|
|
9
|
-
export declare const runPaths: (foundryRoot: string, cryticName: string, suiteNameSnake?: string) => Promise<void>;
|
|
13
|
+
export declare const runPaths: (foundryRoot: string, cryticName: string, suiteNameSnake?: string, options?: RunPathsOptions) => Promise<void>;
|
package/dist/pathsGenerator.js
CHANGED
|
@@ -103,7 +103,7 @@ const loadLatestSourceUnits = async (foundryRoot) => {
|
|
|
103
103
|
return reader.read(filteredAstData);
|
|
104
104
|
};
|
|
105
105
|
// ==================== Main Entry ====================
|
|
106
|
-
const runPaths = async (foundryRoot, cryticName, suiteNameSnake) => {
|
|
106
|
+
const runPaths = async (foundryRoot, cryticName, suiteNameSnake, options = {}) => {
|
|
107
107
|
const buildCmd = `forge build --contracts ${cryticName} --build-info --out .recon/out`.replace(/\s+/g, ' ').trim();
|
|
108
108
|
await runCmd(buildCmd, foundryRoot);
|
|
109
109
|
const sourceUnits = await loadLatestSourceUnits(foundryRoot);
|
|
@@ -123,6 +123,18 @@ const runPaths = async (foundryRoot, cryticName, suiteNameSnake) => {
|
|
|
123
123
|
// Process each function
|
|
124
124
|
const output = {};
|
|
125
125
|
let totalPaths = 0;
|
|
126
|
+
if (options.dryRun) {
|
|
127
|
+
console.log(`[dry-run] Crytic contract: ${cryticName}`);
|
|
128
|
+
console.log(`[dry-run] Target functions (${targetFunctions.length}):`);
|
|
129
|
+
for (const func of targetFunctions) {
|
|
130
|
+
const externalCall = findExternalCall(func);
|
|
131
|
+
const extLabel = externalCall ? `${externalCall.contract}.${externalCall.function}` : 'none';
|
|
132
|
+
console.log(` - ${func.name} -> external call: ${extLabel}`);
|
|
133
|
+
}
|
|
134
|
+
console.log('[dry-run] Skipping path enumeration.');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const maxPaths = options.maxPaths && options.maxPaths > 0 ? options.maxPaths : undefined;
|
|
126
138
|
for (const func of targetFunctions) {
|
|
127
139
|
// Find external call
|
|
128
140
|
const externalCall = findExternalCall(func);
|
|
@@ -145,8 +157,8 @@ const runPaths = async (foundryRoot, cryticName, suiteNameSnake) => {
|
|
|
145
157
|
const callTree = (0, call_tree_builder_1.buildCallTree)(extFunc, [], undefined, undefined, context);
|
|
146
158
|
// Build param mapping
|
|
147
159
|
const paramMapping = buildParamMapping(func, externalCall);
|
|
148
|
-
// Enumerate paths
|
|
149
|
-
const enumerator = new PathEnumerator(sourceUnits, paramMapping);
|
|
160
|
+
// Enumerate paths with optional cap
|
|
161
|
+
const enumerator = new PathEnumerator(sourceUnits, paramMapping, maxPaths);
|
|
150
162
|
const paths = enumerator.enumerate(callTree);
|
|
151
163
|
if (paths.length === 0)
|
|
152
164
|
continue;
|
|
@@ -157,11 +169,14 @@ const runPaths = async (foundryRoot, cryticName, suiteNameSnake) => {
|
|
|
157
169
|
continue;
|
|
158
170
|
output[func.name] = simplified;
|
|
159
171
|
totalPaths += simplified.length;
|
|
172
|
+
if (enumerator.wasTruncated()) {
|
|
173
|
+
console.warn(`[recon-generate] Warning: Path enumeration for ${func.name} reached the max limit${maxPaths ? ` (${maxPaths})` : ''} and was truncated.`);
|
|
174
|
+
}
|
|
160
175
|
}
|
|
161
176
|
const pathsName = suiteNameSnake ? `recon-${suiteNameSnake}-paths.json` : 'recon-paths.json';
|
|
162
177
|
const pathsFilePath = path.join(foundryRoot, pathsName);
|
|
163
178
|
await fs.writeFile(pathsFilePath, JSON.stringify(output, null, 2));
|
|
164
|
-
console.log(`[recon-generate] Wrote paths file to ${pathsFilePath}`);
|
|
179
|
+
console.log(`[recon-generate] Wrote paths file to ${pathsFilePath} (total paths: ${totalPaths})`);
|
|
165
180
|
};
|
|
166
181
|
exports.runPaths = runPaths;
|
|
167
182
|
// ==================== Source Helpers ====================
|
|
@@ -224,20 +239,26 @@ function buildParamMapping(func, extCall) {
|
|
|
224
239
|
}
|
|
225
240
|
// ==================== Path Enumeration ====================
|
|
226
241
|
class PathEnumerator {
|
|
227
|
-
constructor(sourceUnits, paramMap) {
|
|
242
|
+
constructor(sourceUnits, paramMap, maxPaths) {
|
|
228
243
|
this.pathIdCounter = 0;
|
|
229
244
|
this.branchPoints = 0;
|
|
245
|
+
this.truncated = false;
|
|
230
246
|
this.sourceUnits = sourceUnits;
|
|
231
247
|
this.paramMap = new Map(paramMap);
|
|
248
|
+
this.maxPaths = maxPaths;
|
|
232
249
|
}
|
|
233
250
|
enumerate(callTree) {
|
|
234
251
|
this.pathIdCounter = 0;
|
|
235
252
|
this.branchPoints = 0;
|
|
253
|
+
this.truncated = false;
|
|
236
254
|
const paths = this.processCallTree(callTree);
|
|
237
255
|
return paths
|
|
238
256
|
.filter(p => p.result === 'success')
|
|
239
257
|
.map(p => ({ id: p.id, conditions: p.conditions, requires: p.requires, result: p.result }));
|
|
240
258
|
}
|
|
259
|
+
wasTruncated() {
|
|
260
|
+
return this.truncated;
|
|
261
|
+
}
|
|
241
262
|
processCallTree(node) {
|
|
242
263
|
const def = node.definition;
|
|
243
264
|
// Process children first (leaf to root)
|
|
@@ -252,8 +273,13 @@ class PathEnumerator {
|
|
|
252
273
|
this.paramMap = savedMap;
|
|
253
274
|
}
|
|
254
275
|
// Process function body
|
|
276
|
+
const firstId = ++this.pathIdCounter;
|
|
277
|
+
if (this.maxPaths && firstId > this.maxPaths) {
|
|
278
|
+
this.truncated = true;
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
255
281
|
let activePaths = [{
|
|
256
|
-
id:
|
|
282
|
+
id: firstId,
|
|
257
283
|
conditions: [],
|
|
258
284
|
requires: [],
|
|
259
285
|
terminated: false
|
|
@@ -336,8 +362,13 @@ class PathEnumerator {
|
|
|
336
362
|
for (const clause of stmt.vClauses) {
|
|
337
363
|
if (clause.errorName === '') {
|
|
338
364
|
// Success clause (try block) - external call succeeds
|
|
365
|
+
const nextId = ++this.pathIdCounter;
|
|
366
|
+
if (this.maxPaths && nextId > this.maxPaths) {
|
|
367
|
+
this.truncated = true;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
339
370
|
const successPath = {
|
|
340
|
-
id:
|
|
371
|
+
id: nextId,
|
|
341
372
|
conditions: [...path.conditions, {
|
|
342
373
|
original: callExpr,
|
|
343
374
|
resolved: callExpr,
|
|
@@ -351,8 +382,13 @@ class PathEnumerator {
|
|
|
351
382
|
}
|
|
352
383
|
else {
|
|
353
384
|
// Catch clause - external call fails
|
|
385
|
+
const nextCatchId = ++this.pathIdCounter;
|
|
386
|
+
if (this.maxPaths && nextCatchId > this.maxPaths) {
|
|
387
|
+
this.truncated = true;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
354
390
|
const catchPath = {
|
|
355
|
-
id:
|
|
391
|
+
id: nextCatchId,
|
|
356
392
|
conditions: [...path.conditions, {
|
|
357
393
|
original: callExpr,
|
|
358
394
|
resolved: callExpr,
|
|
@@ -411,8 +447,13 @@ class PathEnumerator {
|
|
|
411
447
|
continue;
|
|
412
448
|
}
|
|
413
449
|
// True branch
|
|
450
|
+
const nextIdTrue = ++this.pathIdCounter;
|
|
451
|
+
if (this.maxPaths && nextIdTrue > this.maxPaths) {
|
|
452
|
+
this.truncated = true;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
414
455
|
const truePath = {
|
|
415
|
-
id:
|
|
456
|
+
id: nextIdTrue,
|
|
416
457
|
conditions: [...path.conditions, { original: condOrig, resolved: condResolved, mustBeTrue: true }],
|
|
417
458
|
requires: [...path.requires],
|
|
418
459
|
terminated: false
|
|
@@ -427,8 +468,13 @@ class PathEnumerator {
|
|
|
427
468
|
results.push(truePath);
|
|
428
469
|
}
|
|
429
470
|
// False branch
|
|
471
|
+
const nextIdFalse = ++this.pathIdCounter;
|
|
472
|
+
if (this.maxPaths && nextIdFalse > this.maxPaths) {
|
|
473
|
+
this.truncated = true;
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
430
476
|
const falsePath = {
|
|
431
|
-
id:
|
|
477
|
+
id: nextIdFalse,
|
|
432
478
|
conditions: [...path.conditions, { original: condOrig, resolved: condResolved, mustBeTrue: false }],
|
|
433
479
|
requires: [...path.requires],
|
|
434
480
|
terminated: false
|
|
@@ -483,8 +529,13 @@ class PathEnumerator {
|
|
|
483
529
|
for (const cp of cPaths) {
|
|
484
530
|
if (cp.result === 'revert')
|
|
485
531
|
continue;
|
|
532
|
+
const nextId = ++this.pathIdCounter;
|
|
533
|
+
if (this.maxPaths && nextId > this.maxPaths) {
|
|
534
|
+
this.truncated = true;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
486
537
|
merged.push({
|
|
487
|
-
id:
|
|
538
|
+
id: nextId,
|
|
488
539
|
conditions: [...p.conditions, ...cp.conditions],
|
|
489
540
|
requires: [...p.requires, ...cp.requires],
|
|
490
541
|
terminated: false
|
package/dist/processor.js
CHANGED
|
@@ -107,21 +107,28 @@ const matchesSignature = (sig, allowed) => {
|
|
|
107
107
|
return true;
|
|
108
108
|
return false;
|
|
109
109
|
};
|
|
110
|
-
const processFunction = (fnDef, includeDeps = false) => {
|
|
110
|
+
const processFunction = (fnDef, includeDeps = false, visited = new Set()) => {
|
|
111
111
|
const result = [];
|
|
112
|
+
if (visited.has(fnDef.id)) {
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
visited.add(fnDef.id);
|
|
112
116
|
fnDef.walk((n) => {
|
|
113
117
|
if ('vReferencedDeclaration' in n &&
|
|
114
118
|
n.vReferencedDeclaration &&
|
|
115
119
|
n.vReferencedDeclaration !== fnDef) {
|
|
116
120
|
if (n.vReferencedDeclaration instanceof $.FunctionDefinition ||
|
|
117
121
|
n.vReferencedDeclaration instanceof $.ModifierDefinition) {
|
|
118
|
-
|
|
122
|
+
if (visited.has(n.vReferencedDeclaration.id)) {
|
|
123
|
+
return; // break potential recursion cycles
|
|
124
|
+
}
|
|
119
125
|
if (result.some((x) => x.ast === n.vReferencedDeclaration)) {
|
|
120
126
|
return;
|
|
121
127
|
}
|
|
128
|
+
const nextVisited = new Set(visited);
|
|
122
129
|
result.push({
|
|
123
130
|
ast: n.vReferencedDeclaration,
|
|
124
|
-
children: processFunction(n.vReferencedDeclaration, includeDeps),
|
|
131
|
+
children: processFunction(n.vReferencedDeclaration, includeDeps, nextVisited),
|
|
125
132
|
callType: n instanceof $.FunctionCall ? (0, utils_1.getCallType)(n) : types_1.CallType.Internal,
|
|
126
133
|
});
|
|
127
134
|
}
|
|
@@ -165,14 +172,130 @@ const skipPatterns = [
|
|
|
165
172
|
'lib/chimera/',
|
|
166
173
|
'lib/setup-helpers/',
|
|
167
174
|
];
|
|
175
|
+
/**
|
|
176
|
+
* Normalizes a path by stripping common prefixes like /recon/ that some servers add.
|
|
177
|
+
* This ensures consistent path handling across different environments.
|
|
178
|
+
*/
|
|
179
|
+
const normalizePath = (relPath) => {
|
|
180
|
+
// Strip leading /recon/ or recon/ prefix if present
|
|
181
|
+
let normalized = relPath.replace(/^\/?(recon)\//i, '');
|
|
182
|
+
// Ensure no leading slash for relative paths
|
|
183
|
+
normalized = normalized.replace(/^\/+/, '');
|
|
184
|
+
return normalized;
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Checks if a line should be excluded from coverage.
|
|
188
|
+
* Lines that Echidna cannot report on:
|
|
189
|
+
* - Empty lines or whitespace-only lines
|
|
190
|
+
* - Comment-only lines (// ...) but NOT lines with code before comment
|
|
191
|
+
* - Lines with only "}"
|
|
192
|
+
* - Lines with only "});"
|
|
193
|
+
* - Lines with only "} else {"
|
|
194
|
+
*/
|
|
195
|
+
const isUncoverableLine = (line) => {
|
|
196
|
+
const trimmed = line.trim();
|
|
197
|
+
// Empty or whitespace-only
|
|
198
|
+
if (trimmed === '')
|
|
199
|
+
return true;
|
|
200
|
+
// Comment-only line (starts with //)
|
|
201
|
+
if (/^\/\//.test(trimmed))
|
|
202
|
+
return true;
|
|
203
|
+
// Block comment only line
|
|
204
|
+
if (/^\/\*.*\*\/$/.test(trimmed))
|
|
205
|
+
return true;
|
|
206
|
+
if (/^\*/.test(trimmed))
|
|
207
|
+
return true; // middle of block comment
|
|
208
|
+
if (/^\/\*/.test(trimmed) && !/\*\//.test(trimmed))
|
|
209
|
+
return true; // start of multi-line block comment
|
|
210
|
+
// Lines with only closing braces/brackets
|
|
211
|
+
if (trimmed === '}')
|
|
212
|
+
return true;
|
|
213
|
+
if (trimmed === '});')
|
|
214
|
+
return true;
|
|
215
|
+
if (trimmed === ')')
|
|
216
|
+
return true;
|
|
217
|
+
if (trimmed === ');')
|
|
218
|
+
return true;
|
|
219
|
+
if (trimmed === '_;')
|
|
220
|
+
return true;
|
|
221
|
+
if (trimmed === '} else {')
|
|
222
|
+
return true;
|
|
223
|
+
if (trimmed === '} else if')
|
|
224
|
+
return true;
|
|
225
|
+
if (/^\}\s*else\s*\{$/.test(trimmed))
|
|
226
|
+
return true;
|
|
227
|
+
if (/^\}\s*else\s+if\s*\(/.test(trimmed))
|
|
228
|
+
return true;
|
|
229
|
+
// Unchecked blocks
|
|
230
|
+
if (trimmed === 'unchecked {')
|
|
231
|
+
return true;
|
|
232
|
+
if (/^unchecked\s*\{$/.test(trimmed))
|
|
233
|
+
return true;
|
|
234
|
+
return false;
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Filters a range of lines, removing uncoverable lines and splitting into valid sub-ranges.
|
|
238
|
+
* @param lines Array of all lines in the file (0-indexed)
|
|
239
|
+
* @param startLine 1-based start line
|
|
240
|
+
* @param endLine 1-based end line
|
|
241
|
+
* @returns Array of range strings (e.g., ["5", "7-9", "12"])
|
|
242
|
+
*/
|
|
243
|
+
const filterLineRange = (lines, startLine, endLine) => {
|
|
244
|
+
var _a;
|
|
245
|
+
const result = [];
|
|
246
|
+
let rangeStart = null;
|
|
247
|
+
for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
|
|
248
|
+
const lineIndex = lineNum - 1; // Convert to 0-based index
|
|
249
|
+
const line = (_a = lines[lineIndex]) !== null && _a !== void 0 ? _a : '';
|
|
250
|
+
if (isUncoverableLine(line)) {
|
|
251
|
+
// End current range if we have one
|
|
252
|
+
if (rangeStart !== null) {
|
|
253
|
+
const rangeEnd = lineNum - 1;
|
|
254
|
+
result.push(rangeStart === rangeEnd ? `${rangeStart}` : `${rangeStart}-${rangeEnd}`);
|
|
255
|
+
rangeStart = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// Start new range or continue existing one
|
|
260
|
+
if (rangeStart === null) {
|
|
261
|
+
rangeStart = lineNum;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Close any remaining range
|
|
266
|
+
if (rangeStart !== null) {
|
|
267
|
+
result.push(rangeStart === endLine ? `${rangeStart}` : `${rangeStart}-${endLine}`);
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
};
|
|
168
271
|
async function buildCoverageMap(asts, foundryRoot, contractFunctions) {
|
|
272
|
+
var _a;
|
|
169
273
|
const coverage = new Map();
|
|
170
274
|
const fileCache = new Map();
|
|
275
|
+
const fileLinesCache = new Map();
|
|
171
276
|
let nodesVisited = 0;
|
|
172
277
|
let nodesAdded = 0;
|
|
173
278
|
const shouldSkipPath = (relPath) => {
|
|
174
279
|
return skipPatterns.some((p) => relPath.includes(p));
|
|
175
280
|
};
|
|
281
|
+
const getFileLines = async (absPath) => {
|
|
282
|
+
let lines = fileLinesCache.get(absPath);
|
|
283
|
+
if (!lines) {
|
|
284
|
+
let content = fileCache.get(absPath);
|
|
285
|
+
if (!content) {
|
|
286
|
+
try {
|
|
287
|
+
content = await fs.readFile(absPath, 'utf8');
|
|
288
|
+
fileCache.set(absPath, content);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
lines = content.split('\n');
|
|
295
|
+
fileLinesCache.set(absPath, lines);
|
|
296
|
+
}
|
|
297
|
+
return lines;
|
|
298
|
+
};
|
|
176
299
|
const addNodeRange = async (node) => {
|
|
177
300
|
var _a;
|
|
178
301
|
nodesVisited++;
|
|
@@ -239,14 +362,57 @@ async function buildCoverageMap(asts, foundryRoot, contractFunctions) {
|
|
|
239
362
|
}
|
|
240
363
|
const normalized = {};
|
|
241
364
|
for (const [relPath, ranges] of coverage.entries()) {
|
|
242
|
-
|
|
365
|
+
// Normalize the path to handle /recon/ prefix
|
|
366
|
+
const normalizedRelPath = normalizePath(relPath);
|
|
367
|
+
if (shouldSkipPath(normalizedRelPath)) {
|
|
243
368
|
continue; // skip test/script/lib files at the final emission step only
|
|
244
369
|
}
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
370
|
+
const absPath = path.isAbsolute(relPath)
|
|
371
|
+
? relPath
|
|
372
|
+
: path.join(foundryRoot, relPath);
|
|
373
|
+
const lines = await getFileLines(absPath);
|
|
374
|
+
// Expand all ranges, filter uncoverable lines, and collect valid line numbers
|
|
375
|
+
const validLines = new Set();
|
|
376
|
+
for (const range of ranges) {
|
|
377
|
+
const parts = range.split('-');
|
|
378
|
+
const start = parseInt(parts[0], 10);
|
|
379
|
+
const end = parts.length > 1 ? parseInt(parts[1], 10) : start;
|
|
380
|
+
for (let lineNum = start; lineNum <= end; lineNum++) {
|
|
381
|
+
const lineIndex = lineNum - 1;
|
|
382
|
+
const line = (_a = lines[lineIndex]) !== null && _a !== void 0 ? _a : '';
|
|
383
|
+
if (!isUncoverableLine(line)) {
|
|
384
|
+
validLines.add(lineNum);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Convert valid line numbers back to ranges
|
|
389
|
+
const sortedLines = Array.from(validLines).sort((a, b) => a - b);
|
|
390
|
+
const filteredRanges = [];
|
|
391
|
+
let rangeStart = null;
|
|
392
|
+
let rangeEnd = null;
|
|
393
|
+
for (const lineNum of sortedLines) {
|
|
394
|
+
if (rangeStart === null) {
|
|
395
|
+
rangeStart = lineNum;
|
|
396
|
+
rangeEnd = lineNum;
|
|
397
|
+
}
|
|
398
|
+
else if (lineNum === rangeEnd + 1) {
|
|
399
|
+
rangeEnd = lineNum;
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
// Emit previous range
|
|
403
|
+
filteredRanges.push(rangeStart === rangeEnd ? `${rangeStart}` : `${rangeStart}-${rangeEnd}`);
|
|
404
|
+
rangeStart = lineNum;
|
|
405
|
+
rangeEnd = lineNum;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Emit final range
|
|
409
|
+
if (rangeStart !== null) {
|
|
410
|
+
filteredRanges.push(rangeStart === rangeEnd ? `${rangeStart}` : `${rangeStart}-${rangeEnd}`);
|
|
411
|
+
}
|
|
412
|
+
if (filteredRanges.length > 0) {
|
|
413
|
+
// Use normalized path as key for consistent output
|
|
414
|
+
normalized[normalizedRelPath] = filteredRanges;
|
|
415
|
+
}
|
|
250
416
|
}
|
|
251
417
|
return normalized;
|
|
252
418
|
}
|
package/dist/templates/setup.js
CHANGED
|
@@ -27,11 +27,12 @@ import "{{this.path}}";
|
|
|
27
27
|
{{/each}}
|
|
28
28
|
|
|
29
29
|
abstract contract Setup is BaseSetup, ActorManager, AssetManager, Utils {
|
|
30
|
-
|
|
30
|
+
{{#each contracts}}
|
|
31
31
|
{{this.name}} {{camel this.name}};
|
|
32
|
-
{{#if this.isDynamic}}
|
|
32
|
+
{{#if this.isDynamic}}
|
|
33
|
+
address[] {{camel this.name}}_s;
|
|
33
34
|
{{/if}}
|
|
34
|
-
|
|
35
|
+
{{/each}}
|
|
35
36
|
|
|
36
37
|
/// === Setup === ///
|
|
37
38
|
/// This contains all calls to be performed in the tester constructor, both for Echidna and Foundry
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const beforeAfterTemplate: HandlebarsTemplateDelegate<any>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.beforeAfterTemplate = void 0;
|
|
7
|
+
const handlebars_1 = __importDefault(require("handlebars"));
|
|
8
|
+
exports.beforeAfterTemplate = handlebars_1.default.compile(`
|
|
9
|
+
from wake.testing import *
|
|
10
|
+
from wake.testing.fuzzing import *
|
|
11
|
+
from .setup import Setup
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Vars:
|
|
16
|
+
# Example ghost variables for tracking state
|
|
17
|
+
# total_assets: int = 0
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
class BeforeAfter(Setup):
|
|
21
|
+
_before: Vars
|
|
22
|
+
_after: Vars
|
|
23
|
+
|
|
24
|
+
def pre_flow(self, flow: Callable) -> None:
|
|
25
|
+
# Called before each flow
|
|
26
|
+
# self._before = Vars(total_assets=self.complexVault.totalAssets())
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def post_flow(self, flow: Callable) -> None:
|
|
30
|
+
# Called after each flow
|
|
31
|
+
# self._after = Vars(total_assets=self.complexVault.totalAssets())
|
|
32
|
+
pass
|
|
33
|
+
`, { noEscape: true });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const conftestTemplate: HandlebarsTemplateDelegate<any>;
|