effect-analyzer 0.1.0

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/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "effect-analyzer",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Static analysis for Effect-TS code. Analyze Effect code to extract structure, calculate complexity, and generate visualizations.",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "sideEffects": false,
10
+ "bin": {
11
+ "effect-analyze": "./dist/cli.js"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js",
17
+ "require": "./dist/index.cjs"
18
+ },
19
+ "./effect-workflow": {
20
+ "types": "./dist/effect-workflow.d.ts",
21
+ "import": "./dist/effect-workflow.js",
22
+ "require": "./dist/effect-workflow.cjs"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "scripts",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "lsp": "node dist/lsp/server.js",
33
+ "type-check": "tsc --noEmit",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest watch",
36
+ "lint": "eslint src",
37
+ "lint:fix": "eslint src --fix",
38
+ "clean": "rm -rf dist",
39
+ "quality": "pnpm type-check && pnpm test && pnpm lint",
40
+ "effect:fetch": "tsx scripts/fetch-effect-repo.ts"
41
+ },
42
+ "keywords": [
43
+ "effect", "effect-ts", "static-analysis", "ts-morph",
44
+ "typescript", "visualization", "mermaid", "functional-programming"
45
+ ],
46
+ "author": "Jag Reehal <jag@jagreehal.com>",
47
+ "license": "MIT",
48
+ "peerDependencies": {
49
+ "effect": ">=3.0.0",
50
+ "@effect/platform": ">=0.70.0"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "effect": { "optional": false },
54
+ "@effect/platform": { "optional": true }
55
+ },
56
+ "dependencies": {
57
+ "effect": "^3.21.0",
58
+ "ts-morph": "^27.0.2"
59
+ },
60
+ "optionalDependencies": {
61
+ "vscode-languageserver": "^9.0.1",
62
+ "vscode-languageserver-textdocument": "^1.0.12"
63
+ },
64
+ "devDependencies": {
65
+ "@effect/eslint-plugin": "^0.3.2",
66
+ "@eslint/js": "^10.0.1",
67
+ "@total-typescript/ts-reset": "^0.6.1",
68
+ "@types/node": "^25.5.0",
69
+ "eslint": "^10.1.0",
70
+ "ts-morph": "^27.0.2",
71
+ "tsup": "^8.5.1",
72
+ "typescript": "^5.9.3",
73
+ "typescript-eslint": "^8.57.1",
74
+ "vitest": "^4.1.0"
75
+ }
76
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Clone public Effect repos into .analysis-output/, run effect-analyze (colocated
3
+ * .effect-analysis.md), and optionally run coverage audit and append a one-line
4
+ * summary to .analysis-output/NOTES.md.
5
+ *
6
+ * Usage: pnpm build && pnpm run analyze:public-repos [--refresh]
7
+ *
8
+ * --refresh Re-clone repos that already exist (default: skip existing clones).
9
+ *
10
+ * After running, review generated .effect-analysis.md files under .analysis-output
11
+ * and update .analysis-output/NOTES.md with README-worthy examples (repo, file, program, why).
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, rmSync } from 'fs';
15
+ import { join, resolve } from 'path';
16
+ import { execFileSync, spawnSync } from 'child_process';
17
+
18
+ const ROOT = resolve(process.cwd());
19
+ const ANALYSIS_DIR = join(ROOT, '.analysis-output');
20
+ const CLI_PATH = join(ROOT, 'dist/cli.js');
21
+
22
+ const REPOS: { name: string; url: string }[] = [
23
+ { name: 'typed', url: 'https://github.com/TylorS/typed.git' },
24
+ { name: 'effect-http', url: 'https://github.com/sukovanej/effect-http.git' },
25
+ { name: 'distilled', url: 'https://github.com/alchemy-run/distilled.git' },
26
+ { name: 'sync-engine-web', url: 'https://github.com/typeonce-dev/sync-engine-web.git' },
27
+ { name: 'effect-aws', url: 'https://github.com/floydspace/effect-aws.git' },
28
+ { name: 'effect-nextjs', url: 'https://github.com/mcrovero/effect-nextjs.git' },
29
+ { name: 'sqlfx', url: 'https://github.com/tim-smart/sqlfx.git' },
30
+ // EffectReact templates: add exact repo URL when known (e.g. Effect-TS/effect-react or template repo)
31
+ ];
32
+
33
+ const NOTES_TEMPLATE = `# Public repo analysis – README candidates
34
+
35
+ One-line audit summaries are appended below by the script. Add README-worthy examples here after reviewing generated \`.effect-analysis.md\` files.
36
+
37
+ ## Audit summaries (auto-appended)
38
+
39
+ `;
40
+
41
+ function ensureAnalysisDir(): void {
42
+ mkdirSync(ANALYSIS_DIR, { recursive: true });
43
+ }
44
+
45
+ function ensureNotesFile(): void {
46
+ const notesPath = join(ANALYSIS_DIR, 'NOTES.md');
47
+ if (!existsSync(notesPath)) {
48
+ writeFileSync(notesPath, NOTES_TEMPLATE, 'utf-8');
49
+ }
50
+ }
51
+
52
+ function clone(repo: { name: string; url: string }, refresh: boolean): void {
53
+ const dest = join(ANALYSIS_DIR, repo.name);
54
+ if (existsSync(dest)) {
55
+ if (refresh) {
56
+ rmSync(dest, { recursive: true, force: true });
57
+ } else {
58
+ console.log(` [skip clone] ${repo.name} (already exists; use --refresh to re-clone)`);
59
+ return;
60
+ }
61
+ }
62
+ console.log(` cloning ${repo.name}...`);
63
+ execFileSync('git', ['clone', '--depth', '1', repo.url, dest], {
64
+ stdio: 'inherit',
65
+ cwd: ROOT,
66
+ });
67
+ }
68
+
69
+ function runAnalyzer(repoName: string): boolean {
70
+ const targetPath = join(ANALYSIS_DIR, repoName);
71
+ const result = spawnSync('node', [CLI_PATH, targetPath], {
72
+ stdio: 'inherit',
73
+ cwd: ROOT,
74
+ shell: false,
75
+ });
76
+ return result.status === 0;
77
+ }
78
+
79
+ function runCoverageAudit(repoName: string): { discovered: number; analyzed: number; failed: number } | null {
80
+ const targetPath = join(ANALYSIS_DIR, repoName);
81
+ const auditPath = join(ANALYSIS_DIR, `audit-${repoName}.json`);
82
+ const result = spawnSync(
83
+ 'node',
84
+ [CLI_PATH, targetPath, '--coverage-audit', '--no-colocate', '-o', auditPath],
85
+ { cwd: ROOT, shell: false, encoding: 'utf-8' },
86
+ );
87
+ if (result.status !== 0) return null;
88
+ try {
89
+ const json = JSON.parse(readFileSync(auditPath, 'utf-8'));
90
+ return {
91
+ discovered: json.discovered ?? 0,
92
+ analyzed: json.analyzed ?? 0,
93
+ failed: json.failed ?? 0,
94
+ };
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function appendAuditLine(repoName: string, audit: { discovered: number; analyzed: number; failed: number }): void {
101
+ const notesPath = join(ANALYSIS_DIR, 'NOTES.md');
102
+ const line = `- **${repoName}**: discovered ${audit.discovered}, analyzed ${audit.analyzed}, failed ${audit.failed}\n`;
103
+ appendFileSync(notesPath, line, 'utf-8');
104
+ }
105
+
106
+ function main(): void {
107
+ if (!existsSync(CLI_PATH)) {
108
+ console.error('Run pnpm build first (dist/cli.js not found).');
109
+ process.exit(1);
110
+ }
111
+
112
+ const refresh = process.argv.includes('--refresh');
113
+ ensureAnalysisDir();
114
+ ensureNotesFile();
115
+
116
+ console.log('Repos: ' + REPOS.map((r) => r.name).join(', '));
117
+ for (const repo of REPOS) {
118
+ console.log(`\n--- ${repo.name} ---`);
119
+ clone(repo, refresh);
120
+ const targetPath = join(ANALYSIS_DIR, repo.name);
121
+ if (!existsSync(targetPath)) continue;
122
+ runAnalyzer(repo.name);
123
+ const audit = runCoverageAudit(repo.name);
124
+ if (audit) appendAuditLine(repo.name, audit);
125
+ }
126
+
127
+ console.log('\nDone. Review .analysis-output/**/*.effect-analysis.md and update .analysis-output/NOTES.md with README candidates.');
128
+ }
129
+
130
+ main();
@@ -0,0 +1,213 @@
1
+ import { Effect } from 'effect';
2
+ import { join, resolve } from 'path';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { runCoverageAudit } from '../src/project-analyzer';
5
+
6
+ const DEFAULT_EFFECT_REPO_PATH = resolve('.cache/effect-repo');
7
+
8
+ function getArgValue(flag: string): string | undefined {
9
+ const idx = process.argv.indexOf(flag);
10
+ if (idx === -1) return undefined;
11
+ const next = process.argv[idx + 1];
12
+ if (!next || next.startsWith('--')) return undefined;
13
+ return next;
14
+ }
15
+
16
+ function parseNumber(
17
+ value: string | undefined,
18
+ fallback: number,
19
+ ): number {
20
+ if (value === undefined) return fallback;
21
+ const n = Number(value);
22
+ return Number.isFinite(n) ? n : fallback;
23
+ }
24
+
25
+ function parseIntNumber(
26
+ value: string | undefined,
27
+ fallback: number,
28
+ ): number {
29
+ if (value === undefined) return fallback;
30
+ const n = Number.parseInt(value, 10);
31
+ return Number.isFinite(n) ? n : fallback;
32
+ }
33
+
34
+ type BudgetConfig = {
35
+ dirPath: string;
36
+ tsconfig: string | undefined;
37
+ knownEffectInternalsRoot: string | undefined;
38
+ maxFailed: number;
39
+ maxUnknownNodeRate: number;
40
+ maxSuspiciousZeros: number;
41
+ baselineFile: string | undefined;
42
+ };
43
+
44
+ type BudgetBaseline = {
45
+ maxFailed?: number;
46
+ maxUnknownNodeRate?: number;
47
+ maxSuspiciousZeros?: number;
48
+ metrics?: {
49
+ failed?: number;
50
+ unknownNodeRate?: number;
51
+ suspiciousZeros?: number;
52
+ };
53
+ };
54
+
55
+ function readBaseline(pathValue: string | undefined): BudgetBaseline | undefined {
56
+ if (!pathValue) return undefined;
57
+ if (!existsSync(pathValue)) return undefined;
58
+ try {
59
+ const raw = readFileSync(pathValue, 'utf-8');
60
+ return JSON.parse(raw) as BudgetBaseline;
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ function getConfig(): BudgetConfig {
67
+ const effectRepoPath = resolve(
68
+ process.env.EFFECT_REPO_PATH ?? DEFAULT_EFFECT_REPO_PATH,
69
+ );
70
+ const defaultDir = join(effectRepoPath, 'packages');
71
+ const defaultInternalsRoot = join(effectRepoPath, 'packages', 'effect', 'src', 'internal');
72
+ const defaultBaselineFile = resolve('scripts/baselines/effect-audit-baseline.json');
73
+ const baselineFile =
74
+ getArgValue('--baseline-file') ??
75
+ process.env.AUDIT_BASELINE_FILE ??
76
+ (existsSync(defaultBaselineFile) ? defaultBaselineFile : undefined);
77
+ const baseline = readBaseline(baselineFile);
78
+
79
+ const dirPath = resolve(
80
+ getArgValue('--dir') ??
81
+ process.env.EFFECT_AUDIT_DIR ??
82
+ defaultDir,
83
+ );
84
+ const tsconfig = getArgValue('--tsconfig') ?? process.env.EFFECT_AUDIT_TSCONFIG;
85
+ const knownEffectInternalsRoot =
86
+ getArgValue('--known-effect-internals-root') ??
87
+ process.env.KNOWN_EFFECT_INTERNALS_ROOT ??
88
+ defaultInternalsRoot;
89
+
90
+ // Defaults are set to current observed baselines; tighten over time.
91
+ const maxFailed = parseIntNumber(
92
+ getArgValue('--max-failed') ?? process.env.AUDIT_MAX_FAILED,
93
+ baseline?.maxFailed ?? baseline?.metrics?.failed ?? 0,
94
+ );
95
+ const maxUnknownNodeRate = parseNumber(
96
+ getArgValue('--max-unknown-node-rate') ?? process.env.AUDIT_MAX_UNKNOWN_NODE_RATE,
97
+ baseline?.maxUnknownNodeRate ?? baseline?.metrics?.unknownNodeRate ?? 0.1173,
98
+ );
99
+ const maxSuspiciousZeros = parseIntNumber(
100
+ getArgValue('--max-suspicious-zeros') ?? process.env.AUDIT_MAX_SUSPICIOUS_ZEROS,
101
+ baseline?.maxSuspiciousZeros ?? baseline?.metrics?.suspiciousZeros ?? 999_999,
102
+ );
103
+
104
+ return {
105
+ dirPath,
106
+ tsconfig,
107
+ knownEffectInternalsRoot,
108
+ maxFailed,
109
+ maxUnknownNodeRate,
110
+ maxSuspiciousZeros,
111
+ baselineFile,
112
+ };
113
+ }
114
+
115
+ async function main(): Promise<void> {
116
+ const config = getConfig();
117
+ const startedAt = Date.now();
118
+ if (!existsSync(config.dirPath)) {
119
+ console.error(
120
+ JSON.stringify(
121
+ {
122
+ status: 'fail',
123
+ message:
124
+ `Audit dir not found: ${config.dirPath}. ` +
125
+ 'Set EFFECT_AUDIT_DIR / EFFECT_REPO_PATH, or run `pnpm effect:fetch` to clone Effect locally.',
126
+ },
127
+ null,
128
+ 2,
129
+ ),
130
+ );
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+
135
+ const audit = await Effect.runPromise(
136
+ runCoverageAudit(config.dirPath, {
137
+ tsconfig: config.tsconfig,
138
+ knownEffectInternalsRoot: config.knownEffectInternalsRoot,
139
+ }),
140
+ );
141
+
142
+ const suspiciousCount = audit.zeroProgramCategoryCounts.suspicious;
143
+ const violations: string[] = [];
144
+
145
+ if (audit.failed > config.maxFailed) {
146
+ violations.push(
147
+ `failed files ${audit.failed} exceeds max ${config.maxFailed}`,
148
+ );
149
+ }
150
+ if (audit.unknownNodeRate > config.maxUnknownNodeRate) {
151
+ violations.push(
152
+ `unknownNodeRate ${(audit.unknownNodeRate * 100).toFixed(2)}% exceeds max ${(config.maxUnknownNodeRate * 100).toFixed(2)}%`,
153
+ );
154
+ }
155
+ if (suspiciousCount > config.maxSuspiciousZeros) {
156
+ violations.push(
157
+ `suspicious zeros ${suspiciousCount} exceeds max ${config.maxSuspiciousZeros}`,
158
+ );
159
+ }
160
+
161
+ const summary = {
162
+ dirPath: config.dirPath,
163
+ budgets: {
164
+ maxFailed: config.maxFailed,
165
+ maxUnknownNodeRate: config.maxUnknownNodeRate,
166
+ maxSuspiciousZeros: config.maxSuspiciousZeros,
167
+ baselineFile: config.baselineFile,
168
+ },
169
+ metrics: {
170
+ discovered: audit.discovered,
171
+ analyzed: audit.analyzed,
172
+ zeroPrograms: audit.zeroPrograms,
173
+ failed: audit.failed,
174
+ coverage: audit.percentage,
175
+ analyzableCoverage: audit.analyzableCoverage,
176
+ unknownNodeRate: audit.unknownNodeRate,
177
+ suspiciousZeros: suspiciousCount,
178
+ zeroProgramCategoryCounts: audit.zeroProgramCategoryCounts,
179
+ durationMs: audit.durationMs,
180
+ wallClockMs: Date.now() - startedAt,
181
+ },
182
+ };
183
+
184
+ if (violations.length > 0) {
185
+ console.error(
186
+ JSON.stringify(
187
+ {
188
+ status: 'fail',
189
+ violations,
190
+ summary,
191
+ suspiciousZeroSample: audit.suspiciousZeros.slice(0, 50),
192
+ },
193
+ null,
194
+ 2,
195
+ ),
196
+ );
197
+ process.exitCode = 1;
198
+ return;
199
+ }
200
+
201
+ console.log(
202
+ JSON.stringify(
203
+ {
204
+ status: 'pass',
205
+ summary,
206
+ },
207
+ null,
208
+ 2,
209
+ ),
210
+ );
211
+ }
212
+
213
+ void main();
@@ -0,0 +1,111 @@
1
+ import { Effect } from 'effect';
2
+ import { join, resolve } from 'path';
3
+ import { analyze } from '../src/analyze';
4
+
5
+ const DEFAULT_EFFECT_REPO_PATH = '/Users/jagreehal/dev/js/awaitly/__temp/effect';
6
+
7
+ function getEffectRepoPath(): string {
8
+ return resolve(process.env.EFFECT_REPO_PATH ?? DEFAULT_EFFECT_REPO_PATH);
9
+ }
10
+
11
+ function getArgValue(flag: string): string | undefined {
12
+ const idx = process.argv.indexOf(flag);
13
+ if (idx === -1) return undefined;
14
+ const next = process.argv[idx + 1];
15
+ if (!next || next.startsWith('--')) return undefined;
16
+ return next;
17
+ }
18
+
19
+ function getModules(): string[] {
20
+ const raw = getArgValue('--modules');
21
+ if (!raw) return ['Effect.ts', 'Layer.ts', 'Schedule.ts', 'Stream.ts', 'Chunk.ts'];
22
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
23
+ }
24
+
25
+ async function auditModule(
26
+ filePath: string,
27
+ knownEffectInternalsRoot: string,
28
+ ): Promise<unknown> {
29
+ const base = await Effect.runPromise(
30
+ analyze(filePath, { knownEffectInternalsRoot }).all().pipe(Effect.either),
31
+ );
32
+ if (base._tag === 'Left') {
33
+ return { file: filePath, error: base.left.code };
34
+ }
35
+
36
+ const rows = base.right;
37
+ const low = rows.filter((r) => r.root.discoveryConfidence === 'low');
38
+ const medium = rows.filter((r) => r.root.discoveryConfidence === 'medium');
39
+ const high = rows.filter((r) => r.root.discoveryConfidence === 'high');
40
+
41
+ const publicHigh = await Effect.runPromise(
42
+ analyze(filePath, {
43
+ knownEffectInternalsRoot,
44
+ onlyExportedPrograms: true,
45
+ minDiscoveryConfidence: 'high',
46
+ }).all().pipe(Effect.either),
47
+ );
48
+
49
+ const exportedAll = await Effect.runPromise(
50
+ analyze(filePath, {
51
+ knownEffectInternalsRoot,
52
+ onlyExportedPrograms: true,
53
+ minDiscoveryConfidence: 'low',
54
+ }).all().pipe(Effect.either),
55
+ );
56
+
57
+ const exportedLowNames =
58
+ exportedAll._tag === 'Left'
59
+ ? []
60
+ : exportedAll.right
61
+ .filter((r) => r.root.discoveryConfidence === 'low')
62
+ .map((r) => r.root.programName);
63
+
64
+ return {
65
+ file: filePath,
66
+ total: rows.length,
67
+ confidence: {
68
+ high: high.length,
69
+ medium: medium.length,
70
+ low: low.length,
71
+ },
72
+ lowNames: low.map((r) => r.root.programName),
73
+ exportedLowNames,
74
+ launchReadyPublicSurface: exportedLowNames.length === 0,
75
+ publicHigh:
76
+ publicHigh._tag === 'Left'
77
+ ? { error: publicHigh.left.code }
78
+ : {
79
+ count: publicHigh.right.length,
80
+ sample: publicHigh.right.slice(0, 20).map((r) => r.root.programName),
81
+ },
82
+ };
83
+ }
84
+
85
+ async function main(): Promise<void> {
86
+ const effectRepoPath = getEffectRepoPath();
87
+ const srcRoot = join(effectRepoPath, 'packages', 'effect', 'src');
88
+ const internalsRoot = join(srcRoot, 'internal');
89
+ const modules = getModules();
90
+
91
+ const reports = [];
92
+ for (const mod of modules) {
93
+ reports.push(await auditModule(join(srcRoot, mod), internalsRoot));
94
+ }
95
+
96
+ console.log(
97
+ JSON.stringify(
98
+ {
99
+ effectRepoPath,
100
+ srcRoot,
101
+ internalsRoot,
102
+ modules,
103
+ reports,
104
+ },
105
+ null,
106
+ 2,
107
+ ),
108
+ );
109
+ }
110
+
111
+ void main();
@@ -0,0 +1,14 @@
1
+ {
2
+ "notes": "Observed on 2026-02-25 against /Users/jagreehal/dev/js/awaitly/__temp/effect/packages with KNOWN_EFFECT_INTERNALS_ROOT set. Tighten budgets over time.",
3
+ "maxFailed": 0,
4
+ "maxUnknownNodeRate": 0.1463,
5
+ "maxSuspiciousZeros": 63,
6
+ "metrics": {
7
+ "discovered": 1756,
8
+ "analyzed": 1043,
9
+ "zeroPrograms": 713,
10
+ "failed": 0,
11
+ "unknownNodeRate": 0.14623301179761178,
12
+ "suspiciousZeros": 63
13
+ }
14
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Benchmark script — runs coverage audit across repos and stores JSON snapshots.
4
+ * Usage: npx tsx scripts/benchmark.ts [repo1] [repo2] ...
5
+ */
6
+ import { Effect } from 'effect';
7
+ import { runCoverageAudit } from '../src/project-analyzer';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
9
+ import { join, basename, resolve, dirname } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ const BENCHMARK_DIR = resolve(__dirname, '..', 'benchmarks');
16
+
17
+ interface BenchmarkResult {
18
+ repo: string;
19
+ timestamp: string;
20
+ discovered: number;
21
+ analyzed: number;
22
+ zeroPrograms: number;
23
+ failed: number;
24
+ percentage: number;
25
+ analyzableCoverage: number;
26
+ unknownNodeRate: number;
27
+ suspiciousZerosCount: number;
28
+ durationMs: number;
29
+ }
30
+
31
+ async function benchmarkRepo(repoPath: string): Promise<BenchmarkResult> {
32
+ const start = Date.now();
33
+ const tsconfig = existsSync(join(repoPath, 'tsconfig.json'))
34
+ ? join(repoPath, 'tsconfig.json')
35
+ : undefined;
36
+
37
+ const audit = await Effect.runPromise(
38
+ runCoverageAudit(repoPath, { tsconfig }),
39
+ );
40
+
41
+ return {
42
+ repo: basename(repoPath),
43
+ timestamp: new Date().toISOString(),
44
+ discovered: audit.discovered,
45
+ analyzed: audit.analyzed,
46
+ zeroPrograms: audit.zeroPrograms,
47
+ failed: audit.failed,
48
+ percentage: Math.round(audit.percentage * 100) / 100,
49
+ analyzableCoverage: Math.round(audit.analyzableCoverage * 100) / 100,
50
+ unknownNodeRate: Math.round(audit.unknownNodeRate * 10000) / 10000,
51
+ suspiciousZerosCount: audit.suspiciousZeros.length,
52
+ durationMs: Date.now() - start,
53
+ };
54
+ }
55
+
56
+ async function main() {
57
+ const repos = process.argv.slice(2);
58
+ if (repos.length === 0) {
59
+ console.log('Usage: npx tsx scripts/benchmark.ts <repo-path> [repo-path2] ...');
60
+ process.exit(1);
61
+ }
62
+
63
+ if (!existsSync(BENCHMARK_DIR)) mkdirSync(BENCHMARK_DIR, { recursive: true });
64
+
65
+ const results: BenchmarkResult[] = [];
66
+ for (const repo of repos) {
67
+ const repoResolved = resolve(repo);
68
+ if (!existsSync(repoResolved)) {
69
+ console.log(`Skipping ${repo} — not found`);
70
+ continue;
71
+ }
72
+ console.log(`Benchmarking ${repoResolved}...`);
73
+ const result = await benchmarkRepo(repoResolved);
74
+ results.push(result);
75
+ console.log(` discovered=${result.discovered} analyzed=${result.analyzed} zero=${result.zeroPrograms} failed=${result.failed}`);
76
+ console.log(` coverage=${result.percentage}% analyzable=${result.analyzableCoverage}% unknownRate=${result.unknownNodeRate}`);
77
+ console.log(` duration=${result.durationMs}ms`);
78
+ }
79
+
80
+ // Save snapshot
81
+ const snapshotPath = join(BENCHMARK_DIR, `benchmark-${new Date().toISOString().slice(0, 10)}.json`);
82
+ writeFileSync(snapshotPath, JSON.stringify(results, null, 2));
83
+ console.log(`\nSnapshot saved to ${snapshotPath}`);
84
+
85
+ // Compare with previous baseline if exists
86
+ const baselinePath = join(BENCHMARK_DIR, 'baseline.json');
87
+ if (existsSync(baselinePath)) {
88
+ const baseline: BenchmarkResult[] = JSON.parse(readFileSync(baselinePath, 'utf-8'));
89
+ console.log('\nDeltas vs baseline:');
90
+ for (const result of results) {
91
+ const base = baseline.find((b) => b.repo === result.repo);
92
+ if (!base) {
93
+ console.log(` ${result.repo}: NEW (no baseline)`);
94
+ continue;
95
+ }
96
+ const delta = (field: keyof BenchmarkResult) => {
97
+ const curr = result[field] as number;
98
+ const prev = base[field] as number;
99
+ const diff = curr - prev;
100
+ return diff >= 0 ? `+${diff}` : `${diff}`;
101
+ };
102
+ console.log(` ${result.repo}: analyzed ${delta('analyzed')} | unknown ${delta('unknownNodeRate')} | coverage ${delta('percentage')}%`);
103
+ }
104
+ }
105
+ }
106
+
107
+ main().catch(console.error);