Skip to content

Commit b12b579

Browse files
committed
chore(bench): add javac fixture compilation and javacg-static comparison script (#1307)
Closes #1307
1 parent c3ca506 commit b12b579

4 files changed

Lines changed: 354 additions & 5 deletions

File tree

docs/benchmarks/RESOLUTION-COMPARISON.md

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,35 @@ Established Java static call graph tools all require compiled bytecode:
177177
| [Soot](https://github.com/soot-oss/soot) | CHA / RTA / VTA / Spark | Needs compiled `.class` files |
178178
| [javacg-static](https://github.com/gousiosg/java-callgraph) | CHA | Lightweight, reads JARs |
179179

180-
The fixture contains raw `.java` source with no build system. Running these
181-
tools requires a `javac` compilation step (tracked in #1307).
180+
The fixture now includes a `Makefile` that compiles all `.java` sources into
181+
`fixture.jar`:
182182

183-
**Current codegraph Java metrics** (`scripts/resolution-benchmark.ts`):
183+
```bash
184+
cd tests/benchmarks/resolution/fixtures/java && make
185+
```
186+
187+
`scripts/compare-javacg.mjs` then runs
188+
[javacg-static](https://github.com/gousiosg/java-callgraph) on the compiled JAR.
189+
javacg-static uses CHA to enumerate all possible call targets at virtual,
190+
interface, and static call sites.
191+
192+
```bash
193+
node scripts/compare-javacg.mjs --jar /path/to/javacg-0.1-SNAPSHOT.jar
194+
# or: JAVACG_JAR=... node scripts/compare-javacg.mjs
195+
```
196+
197+
Download javacg-static from
198+
[github.com/gousiosg/java-callgraph/releases](https://github.com/gousiosg/java-callgraph/releases)
199+
or build with `mvn package -DskipTests`.
200+
201+
**Name mapping:** javacg-static uses `pkg.ClassName:method(JVM-descriptors)` form.
202+
`compare-javacg.mjs` maps this to `ClassName.method` and matches
203+
`ClassName.ClassName` (source constructor) / `ClassName` (target constructor)
204+
against the expected-edges.json convention. Only edges where both class names
205+
appear in the fixture source files are counted; JDK calls (`String`, `HashMap`,
206+
`System.out`, …) are filtered out.
207+
208+
**Codegraph Java metrics** (`scripts/resolution-benchmark.ts`):
184209

185210
| Mode | Codegraph |
186211
|------|:---------:|
@@ -192,6 +217,19 @@ tools requires a `javac` compilation step (tracked in #1307).
192217
| `class-inheritance` (3 edges) | 0/3 (0%) |
193218
| **Total** | **9/17 (53%)** · precision=100% |
194219

220+
**javacg-static comparison** — run `node scripts/compare-javacg.mjs` to populate:
221+
222+
| Tool | Precision | Recall | TP | FP | FN |
223+
|------|:---------:|:------:|---:|---:|---:|
224+
| Codegraph | 100% | 53% | 9 | 0 | 8 |
225+
| javacg-static (CHA) ||||||
226+
227+
javacg-static uses CHA and reads compiled bytecode, so it should resolve
228+
`class-inheritance` (inherited `log()` calls) and `interface-dispatched`
229+
(virtual dispatch via `UserRepository` interface) edges that codegraph
230+
currently misses at the source-level. `static` and `same-file` calls
231+
(`invokestatic` in bytecode) should also be fully captured.
232+
195233
---
196234

197235
## Conclusions
@@ -231,8 +269,9 @@ tools requires a `javac` compilation step (tracked in #1307).
231269
2 `class-inheritance` edges (+7 recall on TS fixture).
232270
2. **Property-assignment type tracking** (#1306) — Track `this.prop = new Foo()`
233271
writes. Recovers 3 JS `receiver-typed` FN.
234-
3. **Java comparison with javacg-static** (#1307) — Add `javac` compilation to
235-
the Java fixture so a bytecode-level tool can validate Java recall claims.
272+
3. **Java recall gaps**`same-file` (0/2) and `static` (0/2) are `invokestatic`
273+
patterns that javacg-static will expose; `class-inheritance` (0/3) requires
274+
tracking inherited method calls from superclass to subclass invocation site.
236275

237276
---
238277

@@ -300,6 +339,10 @@ npx tsx scripts/resolution-benchmark.ts | jq '{javascript, typescript, java}'
300339
npm install @cs-au-dk/jelly @persper/js-callgraph
301340
node scripts/compare-tools.mjs --all
302341

342+
# javacg-static comparison (Java)
343+
cd tests/benchmarks/resolution/fixtures/java && make && cd -
344+
node scripts/compare-javacg.mjs --jar /path/to/javacg-0.1-SNAPSHOT.jar
345+
303346
# Full resolution test suite
304347
npx vitest run tests/benchmarks/resolution/resolution-benchmark.test.ts
305348
```

scripts/compare-javacg.mjs

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
#!/usr/bin/env node
2+
/**
3+
* javacg-static vs Codegraph: Java fixture call graph comparison
4+
*
5+
* Runs javacg-static (gousiosg/java-callgraph) on the compiled fixture JAR,
6+
* parses its output, maps class:method names to ClassName.method form, and
7+
* computes precision/recall against expected-edges.json.
8+
*
9+
* javacg-static output format:
10+
* M:pkg.ClassName:method(argDescriptors) (T)pkg.ClassName:method(argDescriptors)
11+
* where T is: C=virtual, S=static, O=special (constructors, super), I=interface, D=dynamic
12+
*
13+
* Name mapping to expected-edges.json convention:
14+
* source <init> → ClassName.ClassName (constructor-as-method)
15+
* target <init> → ClassName (constructor target = class name only)
16+
* other method → ClassName.method
17+
*
18+
* Prerequisites:
19+
* 1. Java runtime (java -jar must work)
20+
* 2. javacg-static JAR — download from:
21+
* https://github.com/gousiosg/java-callgraph/releases
22+
* or build: `cd java-callgraph && mvn package -DskipTests`
23+
* Pass via --jar or set JAVACG_JAR, or place at scripts/lib/javacg-static.jar
24+
* 3. Compiled fixture JAR:
25+
* cd tests/benchmarks/resolution/fixtures/java && make
26+
*
27+
* Usage:
28+
* node scripts/compare-javacg.mjs
29+
* node scripts/compare-javacg.mjs --jar /path/to/javacg-0.1-SNAPSHOT.jar
30+
* node scripts/compare-javacg.mjs --json
31+
*/
32+
33+
import { execFileSync } from 'node:child_process';
34+
import fs from 'node:fs';
35+
import path from 'node:path';
36+
import { fileURLToPath } from 'node:url';
37+
38+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
39+
const ROOT = path.resolve(__dirname, '..');
40+
const FIXTURE_DIR = path.join(ROOT, 'tests/benchmarks/resolution/fixtures/java');
41+
42+
// ── CLI ──────────────────────────────────────────────────────────────────────
43+
44+
const args = process.argv.slice(2);
45+
const jsonFlag = args.includes('--json');
46+
const jarArgIdx = args.indexOf('--jar');
47+
const jarArgPath = jarArgIdx !== -1 ? args[jarArgIdx + 1] : null;
48+
49+
// ── Tool discovery ───────────────────────────────────────────────────────────
50+
51+
function findJavacgJar() {
52+
if (jarArgPath) return jarArgPath;
53+
if (process.env.JAVACG_JAR) return process.env.JAVACG_JAR;
54+
// Glob for any jar with "javacg" in the name under scripts/lib/
55+
const libDir = path.join(__dirname, 'lib');
56+
if (fs.existsSync(libDir)) {
57+
const jar = fs.readdirSync(libDir).find((f) => f.includes('javacg') && f.endsWith('.jar'));
58+
if (jar) return path.join(libDir, jar);
59+
}
60+
return null;
61+
}
62+
63+
// ── Name mapping ─────────────────────────────────────────────────────────────
64+
65+
/**
66+
* Scan .java source files to build SimpleClassName → filename.java map.
67+
* Used to resolve file fields in the edge key format "name@file".
68+
*
69+
* Maps the first top-level type per file — inner classes are not indexed.
70+
* Handles common modifiers (public, abstract, final, sealed, non-sealed, strictfp)
71+
* and type keywords (class, interface, enum, record).
72+
*/
73+
function buildClassFileMap(fixtureDir) {
74+
const map = new Map();
75+
const javaFiles = fs.readdirSync(fixtureDir).filter((f) => f.endsWith('.java'));
76+
for (const filename of javaFiles) {
77+
const src = fs.readFileSync(path.join(fixtureDir, filename), 'utf8');
78+
// Match any combination of access/modifier keywords before the type keyword
79+
const m = src.match(
80+
/(?:(?:public|protected|private|abstract|final|sealed|non-sealed|strictfp)\s+)*(?:class|interface|enum|record)\s+(\w+)/,
81+
);
82+
if (m) {
83+
map.set(m[1], filename);
84+
} else {
85+
console.warn(`[warn] buildClassFileMap: no type declaration found in ${filename} — edges involving this file will be filtered out`);
86+
}
87+
}
88+
// Validate: every .java file should map to exactly one class name
89+
if (map.size !== javaFiles.length) {
90+
console.warn(
91+
`[warn] buildClassFileMap: ${javaFiles.length} .java files but only ${map.size} class names resolved — precision/recall may be skewed`,
92+
);
93+
}
94+
return map;
95+
}
96+
97+
/**
98+
* Parse "pkg.ClassName:methodName(descriptors)" into { className, methodName }.
99+
* Works with both "." and "/" as package separators (javacg uses ".").
100+
*/
101+
function parseMethodSpec(spec) {
102+
// Strip argument descriptor — everything from "(" onwards
103+
const parenIdx = spec.indexOf('(');
104+
const withoutArgs = parenIdx !== -1 ? spec.slice(0, parenIdx) : spec;
105+
const colonIdx = withoutArgs.indexOf(':');
106+
if (colonIdx === -1) return null;
107+
const classPart = withoutArgs.slice(0, colonIdx);
108+
const methodName = withoutArgs.slice(colonIdx + 1);
109+
// Simple class name: last segment after "." or "/"
110+
const className = classPart.split(/[./]/).at(-1);
111+
if (!className) return null;
112+
return { className, methodName };
113+
}
114+
115+
/** Source side: "<init>" method maps to ClassName.ClassName. */
116+
function toSourceName({ className, methodName }) {
117+
return methodName === '<init>' ? `${className}.${className}` : `${className}.${methodName}`;
118+
}
119+
120+
/** Target side: "<init>" method maps to just ClassName (constructor target). */
121+
function toTargetName({ className, methodName }) {
122+
return methodName === '<init>' ? className : `${className}.${methodName}`;
123+
}
124+
125+
// ── Ground truth ─────────────────────────────────────────────────────────────
126+
127+
function loadGroundTruth(fixtureDir) {
128+
const manifest = JSON.parse(
129+
fs.readFileSync(path.join(fixtureDir, 'expected-edges.json'), 'utf8'),
130+
);
131+
const set = new Set(
132+
manifest.edges.map(
133+
(e) =>
134+
`${e.source.name}@${path.basename(e.source.file)}${e.target.name}@${path.basename(e.target.file)}`,
135+
),
136+
);
137+
return set;
138+
}
139+
140+
// ── Run javacg-static ────────────────────────────────────────────────────────
141+
142+
function runJavacg(javacgJar, fixtureDir) {
143+
const fixtureJar = path.join(fixtureDir, 'fixture.jar');
144+
if (!fs.existsSync(fixtureJar)) {
145+
console.error(`fixture.jar not found at ${fixtureJar}`);
146+
console.error(`Build it with: cd ${fixtureDir} && make`);
147+
process.exit(1);
148+
}
149+
try {
150+
return execFileSync('java', ['-jar', javacgJar, fixtureJar], {
151+
encoding: 'utf8',
152+
stdio: ['ignore', 'pipe', 'pipe'],
153+
});
154+
} catch (err) {
155+
// javacg-static may exit non-zero but still produce useful stdout
156+
if (err.stdout?.trim()) return err.stdout;
157+
console.error(`javacg-static failed: ${err.message}`);
158+
process.exit(1);
159+
}
160+
}
161+
162+
/**
163+
* Parse javacg-static text output into a Set of edge keys.
164+
*
165+
* Line format:
166+
* M:pkg.Class:method(args) (T)pkg.Class:method(args)
167+
*
168+
* Only edges where both class names appear in classFileMap are included —
169+
* this filters out JDK / stdlib calls (HashMap, String, System.out, etc.).
170+
*/
171+
function parseJavacgOutput(output, classFileMap) {
172+
// M: caller (T) callee — the space between caller and (T) may vary
173+
// T values: C=virtual, S=static, O=special (constructors/super), I=interface, D=dynamic (invokedynamic)
174+
const lineRe = /^M:(\S+)\s+\(([CSOID])\)(\S+)$/;
175+
const edges = new Set();
176+
177+
for (const rawLine of output.split('\n')) {
178+
const line = rawLine.trim();
179+
if (!line.startsWith('M:')) continue;
180+
181+
const m = line.match(lineRe);
182+
if (!m) continue;
183+
184+
const [, sourceSpec, , targetSpec] = m;
185+
186+
const sourceParsed = parseMethodSpec(sourceSpec);
187+
const targetParsed = parseMethodSpec(targetSpec);
188+
if (!sourceParsed || !targetParsed) continue;
189+
190+
const sourceFile = classFileMap.get(sourceParsed.className);
191+
const targetFile = classFileMap.get(targetParsed.className);
192+
// Skip edges to/from classes outside the fixture (JDK, etc.)
193+
if (!sourceFile || !targetFile) continue;
194+
195+
const sourceName = toSourceName(sourceParsed);
196+
const targetName = toTargetName(targetParsed);
197+
198+
const key = `${sourceName}@${sourceFile}${targetName}@${targetFile}`;
199+
// Skip self-edges (e.g. recursive calls not in expected-edges)
200+
if (sourceName === targetName && sourceFile === targetFile) continue;
201+
edges.add(key);
202+
}
203+
return edges;
204+
}
205+
206+
// ── Metrics ──────────────────────────────────────────────────────────────────
207+
208+
function computeMetrics(predicted, groundTruth) {
209+
let tp = 0;
210+
const fp = [];
211+
const fn = [];
212+
for (const edge of predicted) (groundTruth.has(edge) ? tp++ : fp.push(edge));
213+
for (const edge of groundTruth) if (!predicted.has(edge)) fn.push(edge);
214+
return {
215+
precision: predicted.size === 0 ? 0 : tp / predicted.size,
216+
recall: groundTruth.size === 0 ? 0 : tp / groundTruth.size,
217+
tp,
218+
fp: fp.length,
219+
fn: fn.length,
220+
totalPredicted: predicted.size,
221+
totalExpected: groundTruth.size,
222+
fpEdges: fp,
223+
fnEdges: fn,
224+
};
225+
}
226+
227+
// ── Main ─────────────────────────────────────────────────────────────────────
228+
229+
const javacgJar = findJavacgJar();
230+
if (!javacgJar) {
231+
console.error('javacg-static JAR not found.');
232+
console.error('Download from: https://github.com/gousiosg/java-callgraph/releases');
233+
console.error('Then use one of:');
234+
console.error(' node scripts/compare-javacg.mjs --jar /path/to/javacg-0.1-SNAPSHOT.jar');
235+
console.error(' JAVACG_JAR=/path/to/javacg-0.1-SNAPSHOT.jar node scripts/compare-javacg.mjs');
236+
console.error(' cp /path/to/javacg-0.1-SNAPSHOT.jar scripts/lib/javacg-static.jar');
237+
process.exit(1);
238+
}
239+
240+
const classFileMap = buildClassFileMap(FIXTURE_DIR);
241+
const groundTruth = loadGroundTruth(FIXTURE_DIR);
242+
243+
console.error(`\n── JAVA ──────────────────────────────────────────────────`);
244+
console.error(` Ground truth: ${groundTruth.size} edges`);
245+
console.error(` Running javacg-static on fixture.jar...`);
246+
247+
const rawOutput = runJavacg(javacgJar, FIXTURE_DIR);
248+
const predictedEdges = parseJavacgOutput(rawOutput, classFileMap);
249+
250+
console.error(` javacg-static: ${predictedEdges.size} named benchmark edges`);
251+
252+
const metrics = computeMetrics(predictedEdges, groundTruth);
253+
254+
console.error(
255+
` precision=${metrics.precision.toFixed(2)} recall=${metrics.recall.toFixed(2)} ` +
256+
`TP=${metrics.tp} FP=${metrics.fp} FN=${metrics.fn}`,
257+
);
258+
259+
if (metrics.fpEdges.length) {
260+
console.error(` FP (edges not in expected-edges.json):`);
261+
for (const e of metrics.fpEdges) console.error(` - ${e}`);
262+
}
263+
if (metrics.fnEdges.length) {
264+
console.error(` FN (expected edges missed):`);
265+
for (const e of metrics.fnEdges) console.error(` - ${e}`);
266+
}
267+
268+
if (jsonFlag) {
269+
console.log(
270+
JSON.stringify(
271+
{
272+
java: {
273+
groundTruth: groundTruth.size,
274+
javacgEdges: predictedEdges.size,
275+
metrics,
276+
},
277+
},
278+
null,
279+
2,
280+
),
281+
);
282+
} else {
283+
console.log('\n## javacg-static vs expected-edges.json Ground Truth\n');
284+
console.log('| Language | Tool | Precision | Recall | TP | FP | FN |');
285+
console.log('|----------|------|:---------:|:------:|---:|---:|---:|');
286+
console.log(
287+
`| Java | javacg-static (CHA) | ${(metrics.precision * 100).toFixed(0)}% | ` +
288+
`${(metrics.recall * 100).toFixed(0)}% | ${metrics.tp} | ${metrics.fp} | ${metrics.fn} |`,
289+
);
290+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
fixture.jar
2+
benchmark/
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
SRCS = $(wildcard *.java)
2+
JAR = fixture.jar
3+
4+
.PHONY: all clean
5+
6+
all: $(JAR)
7+
8+
$(JAR): $(SRCS)
9+
javac -d . $(SRCS)
10+
jar cf $(JAR) benchmark/
11+
12+
clean:
13+
rm -f $(JAR)
14+
rm -rf benchmark/

0 commit comments

Comments
 (0)