250 lines
		
	
	
		
			7.1 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			250 lines
		
	
	
		
			7.1 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| #!/usr/bin/env node
 | |
| 'use strict';
 | |
| 
 | |
| const fs = require('fs');
 | |
| const path = require('path');
 | |
| const { performance } = require('perf_hooks');
 | |
| 
 | |
| function globToRegExp(pattern) {
 | |
|   let working = pattern
 | |
|     .replace(/\*\*/g, ':::DOUBLE_WILDCARD:::')
 | |
|     .replace(/\*/g, ':::SINGLE_WILDCARD:::');
 | |
|   working = working.replace(/([.+^${}()|[\]\\])/g, '\\$1');
 | |
|   working = working
 | |
|     .replace(/:::DOUBLE_WILDCARD:::\//g, '(?:.*/)?')
 | |
|     .replace(/:::DOUBLE_WILDCARD:::/g, '.*')
 | |
|     .replace(/:::SINGLE_WILDCARD:::/g, '[^/]*');
 | |
|   return new RegExp(`^${working}$`);
 | |
| }
 | |
| 
 | |
| function walkFiles(root, matcher) {
 | |
|   const out = [];
 | |
|   const stack = [root];
 | |
|   while (stack.length) {
 | |
|     const current = stack.pop();
 | |
|     const stat = fs.statSync(current, { throwIfNoEntry: true });
 | |
|     if (stat.isDirectory()) {
 | |
|       const entries = fs.readdirSync(current);
 | |
|       for (const entry of entries) {
 | |
|         stack.push(path.join(current, entry));
 | |
|       }
 | |
|     } else if (stat.isFile()) {
 | |
|       const relativePath = path.relative(root, current).replace(/\\/g, '/');
 | |
|       if (matcher.test(relativePath)) {
 | |
|         out.push(current);
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   return out;
 | |
| }
 | |
| 
 | |
| function parseArgs(argv) {
 | |
|   const args = {
 | |
|     config: path.join(__dirname, 'config.json'),
 | |
|     iterations: undefined,
 | |
|     thresholdMs: undefined,
 | |
|     out: undefined,
 | |
|     repoRoot: path.join(__dirname, '..', '..'),
 | |
|   };
 | |
| 
 | |
|   for (let i = 2; i < argv.length; i++) {
 | |
|     const current = argv[i];
 | |
|     switch (current) {
 | |
|       case '--config':
 | |
|         args.config = argv[++i];
 | |
|         break;
 | |
|       case '--iterations':
 | |
|         args.iterations = Number(argv[++i]);
 | |
|         break;
 | |
|       case '--threshold-ms':
 | |
|         args.thresholdMs = Number(argv[++i]);
 | |
|         break;
 | |
|       case '--out':
 | |
|         args.out = argv[++i];
 | |
|         break;
 | |
|       case '--repo-root':
 | |
|       case '--samples':
 | |
|         args.repoRoot = argv[++i];
 | |
|         break;
 | |
|       default:
 | |
|         throw new Error(`Unknown argument: ${current}`);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return args;
 | |
| }
 | |
| 
 | |
| function loadConfig(configPath) {
 | |
|   const json = fs.readFileSync(configPath, 'utf8');
 | |
|   const cfg = JSON.parse(json);
 | |
|   if (!Array.isArray(cfg.scenarios) || cfg.scenarios.length === 0) {
 | |
|     throw new Error('config.scenarios must be a non-empty array');
 | |
|   }
 | |
|   return cfg;
 | |
| }
 | |
| 
 | |
| function ensureWithinRepo(repoRoot, target) {
 | |
|   const relative = path.relative(repoRoot, target);
 | |
|   if (relative === '' || relative === '.') {
 | |
|     return true;
 | |
|   }
 | |
|   return !relative.startsWith('..') && !path.isAbsolute(relative);
 | |
| }
 | |
| 
 | |
| function parseNodePackage(contents) {
 | |
|   const parsed = JSON.parse(contents);
 | |
|   if (!parsed.name || !parsed.version) {
 | |
|     throw new Error('package.json missing name/version');
 | |
|   }
 | |
|   return { name: parsed.name, version: parsed.version };
 | |
| }
 | |
| 
 | |
| function parsePythonMetadata(contents) {
 | |
|   let name;
 | |
|   let version;
 | |
|   for (const line of contents.split(/\r?\n/)) {
 | |
|     if (!name && line.startsWith('Name:')) {
 | |
|       name = line.slice(5).trim();
 | |
|     } else if (!version && line.startsWith('Version:')) {
 | |
|       version = line.slice(8).trim();
 | |
|     }
 | |
|     if (name && version) {
 | |
|       break;
 | |
|     }
 | |
|   }
 | |
|   if (!name || !version) {
 | |
|     throw new Error('METADATA missing Name/Version headers');
 | |
|   }
 | |
|   return { name, version };
 | |
| }
 | |
| 
 | |
| function formatRow(row) {
 | |
|   const cols = [
 | |
|     row.id.padEnd(28),
 | |
|     row.sampleCount.toString().padStart(5),
 | |
|     row.meanMs.toFixed(2).padStart(9),
 | |
|     row.p95Ms.toFixed(2).padStart(9),
 | |
|     row.maxMs.toFixed(2).padStart(9),
 | |
|   ];
 | |
|   return cols.join(' | ');
 | |
| }
 | |
| 
 | |
| function percentile(sortedDurations, percentile) {
 | |
|   if (sortedDurations.length === 0) {
 | |
|     return 0;
 | |
|   }
 | |
|   const rank = (percentile / 100) * (sortedDurations.length - 1);
 | |
|   const lower = Math.floor(rank);
 | |
|   const upper = Math.ceil(rank);
 | |
|   const weight = rank - lower;
 | |
|   if (upper >= sortedDurations.length) {
 | |
|     return sortedDurations[lower];
 | |
|   }
 | |
|   return sortedDurations[lower] + weight * (sortedDurations[upper] - sortedDurations[lower]);
 | |
| }
 | |
| 
 | |
| function main() {
 | |
|   const args = parseArgs(process.argv);
 | |
|   const cfg = loadConfig(args.config);
 | |
|   const iterations = args.iterations ?? cfg.iterations ?? 5;
 | |
|   const thresholdMs = args.thresholdMs ?? cfg.thresholdMs ?? 5000;
 | |
| 
 | |
|   const results = [];
 | |
|   const failures = [];
 | |
| 
 | |
|   for (const scenario of cfg.scenarios) {
 | |
|     const scenarioRoot = path.resolve(args.repoRoot, scenario.root);
 | |
|     if (!ensureWithinRepo(args.repoRoot, scenarioRoot)) {
 | |
|       throw new Error(`Scenario root ${scenario.root} escapes repo root ${args.repoRoot}`);
 | |
|     }
 | |
|     if (!fs.existsSync(scenarioRoot)) {
 | |
|       throw new Error(`Scenario root ${scenarioRoot} does not exist`);
 | |
|     }
 | |
| 
 | |
|     const matcher = globToRegExp(scenario.matcher.replace(/\\/g, '/'));
 | |
|     const durations = [];
 | |
|     let sampleCount = 0;
 | |
| 
 | |
|     for (let attempt = 0; attempt < iterations; attempt++) {
 | |
|       const start = performance.now();
 | |
|       const files = walkFiles(scenarioRoot, matcher);
 | |
|       if (files.length === 0) {
 | |
|         throw new Error(`Scenario ${scenario.id} matched no files`);
 | |
|       }
 | |
| 
 | |
|       for (const filePath of files) {
 | |
|         const contents = fs.readFileSync(filePath, 'utf8');
 | |
|         if (scenario.parser === 'node') {
 | |
|           parseNodePackage(contents);
 | |
|         } else if (scenario.parser === 'python') {
 | |
|           parsePythonMetadata(contents);
 | |
|         } else {
 | |
|           throw new Error(`Unknown parser ${scenario.parser} for scenario ${scenario.id}`);
 | |
|         }
 | |
|       }
 | |
|       const end = performance.now();
 | |
|       durations.push(end - start);
 | |
|       sampleCount = files.length;
 | |
|     }
 | |
| 
 | |
|     durations.sort((a, b) => a - b);
 | |
|     const mean = durations.reduce((acc, value) => acc + value, 0) / durations.length;
 | |
|     const p95 = percentile(durations, 95);
 | |
|     const max = durations[durations.length - 1];
 | |
| 
 | |
|     if (max > thresholdMs) {
 | |
|       failures.push(`${scenario.id} exceeded threshold: ${(max).toFixed(2)} ms > ${thresholdMs} ms`);
 | |
|     }
 | |
| 
 | |
|     results.push({
 | |
|       id: scenario.id,
 | |
|       label: scenario.label,
 | |
|       sampleCount,
 | |
|       meanMs: mean,
 | |
|       p95Ms: p95,
 | |
|       maxMs: max,
 | |
|       iterations,
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   console.log('Scenario                     | Count |   Mean(ms) |    P95(ms) |     Max(ms)');
 | |
|   console.log('---------------------------- | ----- | --------- | --------- | ----------');
 | |
|   for (const row of results) {
 | |
|     console.log(formatRow(row));
 | |
|   }
 | |
| 
 | |
|   if (args.out) {
 | |
|     const header = 'scenario,iterations,sample_count,mean_ms,p95_ms,max_ms\n';
 | |
|     const csvRows = results
 | |
|       .map((row) =>
 | |
|         [
 | |
|           row.id,
 | |
|           row.iterations,
 | |
|           row.sampleCount,
 | |
|           row.meanMs.toFixed(4),
 | |
|           row.p95Ms.toFixed(4),
 | |
|           row.maxMs.toFixed(4),
 | |
|         ].join(',')
 | |
|       )
 | |
|       .join('\n');
 | |
|     fs.writeFileSync(args.out, header + csvRows + '\n', 'utf8');
 | |
|   }
 | |
| 
 | |
|   if (failures.length > 0) {
 | |
|     console.error('\nPerformance threshold exceeded:');
 | |
|     for (const failure of failures) {
 | |
|       console.error(` - ${failure}`);
 | |
|     }
 | |
|     process.exitCode = 1;
 | |
|   }
 | |
| }
 | |
| 
 | |
| if (require.main === module) {
 | |
|   try {
 | |
|     main();
 | |
|   } catch (err) {
 | |
|     console.error(err instanceof Error ? err.message : err);
 | |
|     process.exit(1);
 | |
|   }
 | |
| }
 |