perf: benchmark scope resolution and remove redundant allocation (#3711)

* fix(java): bound imported return-type propagation

* fix(java): preserve inherited return types

* perf: benchmark scope-resolution allocation in CI

Measure all supported languages with real parsing and resolution, and
require stable graphs and measured binding budgets in CI.

Keep transitive Java factory calls without re-copying dependency tables,
and build shared re-export closures only when a lookup needs them.

* test: cover scope benchmark CI dependency

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
Gergő Magyar 2026-10-24 22:04:46 +01:00 • committed by GitHub
parent d8a256a03a
commit 36227d93d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1291 additions and 37 deletions

View file

@ -306,11 +306,11 @@ jobs:
# Branch protection still requires these six names from the three-shard
# matrix. Keep them as aggregate gates as the real matrix grows: every native
# shard and the complete execution audit must pass before any gate succeeds.
# shard, the complete execution audit, and the scope measurements must pass.
# These jobs only verify dependency results; tests run in cross-platform above.
protected-platform-checks:
name: ${{ matrix.os }} (platform-sensitive) ${{ matrix.check }}/3
needs: [cross-platform, test-completeness]
needs: [cross-platform, test-completeness, scope-resolution-benchmark]
if: always()
permissions: {}
strategy:
@ -321,17 +321,19 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Require all native shards and the execution audit
- name: Require native shards, execution audit, and scope measurements
shell: bash
env:
NATIVE_RESULT: ${{ needs.cross-platform.result }}
EXECUTION_RESULT: ${{ needs.test-completeness.result }}
SCOPE_BENCH_RESULT: ${{ needs.scope-resolution-benchmark.result }}
run: |
echo "Compatibility gate for the former three-shard check name."
echo "All native shards: $NATIVE_RESULT"
echo "Complete execution audit: $EXECUTION_RESULT"
if [[ "$NATIVE_RESULT" != "success" || "$EXECUTION_RESULT" != "success" ]]; then
echo "::error::Every native shard and the execution audit must succeed."
echo "Scope-resolution measurements: $SCOPE_BENCH_RESULT"
if [[ "$NATIVE_RESULT" != "success" || "$EXECUTION_RESULT" != "success" || "$SCOPE_BENCH_RESULT" != "success" ]]; then
echo "::error::Every native shard, the execution audit, and scope-resolution measurements must succeed."
exit 1
fi
@ -1108,6 +1110,33 @@ jobs:
if-no-files-found: error
retention-days: 5
# Fresh-process measurements have their own runner and time budget so they
# neither contend with nor consume the existing benchmark gate's allowance.
scope-resolution-benchmark:
name: scope resolution time / memory (all languages)
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Measure all languages and check graph and binding baselines
run: node --import tsx bench/scope-resolution/measure.mts --check --output scope-resolution-benchmark.json
working-directory: gitnexus
- name: Upload scope-resolution measurements
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: scope-resolution-benchmark
path: gitnexus/scope-resolution-benchmark.json
if-no-files-found: error
retention-days: 5
# Locked eval suite. setup-uv and uv itself are immutable so CI exercises
# exactly the dependency graph developers run from eval/uv.lock.
eval-tests:

View file

@ -333,11 +333,12 @@ def test_platform_ci_preserves_required_check_names_with_complete_execution_gate
for shard in range(1, total + 1)
}
assert names.isdisjoint(native_names)
assert set(gate["needs"]) == {"cross-platform", "test-completeness"}
assert set(gate["needs"]) == {"cross-platform", "test-completeness", "scope-resolution-benchmark"}
assert gate["if"] == "always()"
assert not gate.get("continue-on-error", False)
assert not jobs["cross-platform"].get("continue-on-error", False)
assert not jobs["test-completeness"].get("continue-on-error", False)
assert not jobs["scope-resolution-benchmark"].get("continue-on-error", False)
assert gate["permissions"] == {}
assert len(gate["steps"]) == 1
step = gate["steps"][0]
@ -347,22 +348,31 @@ def test_platform_ci_preserves_required_check_names_with_complete_execution_gate
assert step["env"] == {
"NATIVE_RESULT": "${{ needs.cross-platform.result }}",
"EXECUTION_RESULT": "${{ needs.test-completeness.result }}",
"SCOPE_BENCH_RESULT": "${{ needs.scope-resolution-benchmark.result }}",
}
@pytest.mark.parametrize("native_result", ["success", "failure", "cancelled", "skipped", ""])
@pytest.mark.parametrize("execution_result", ["success", "failure", "cancelled", "skipped", ""])
def test_required_platform_check_fails_unless_every_dependency_passed(ci_test_jobs, native_result, execution_result):
@pytest.mark.parametrize("scope_bench_result", ["success", "failure", "cancelled", "skipped", ""])
def test_required_platform_check_fails_unless_every_dependency_passed(
ci_test_jobs, native_result, execution_result, scope_bench_result
):
step = ci_test_jobs["protected-platform-checks"]["steps"][0]
result = subprocess.run(
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
env={**os.environ, "NATIVE_RESULT": native_result, "EXECUTION_RESULT": execution_result},
env={
**os.environ,
"NATIVE_RESULT": native_result,
"EXECUTION_RESULT": execution_result,
"SCOPE_BENCH_RESULT": scope_bench_result,
},
capture_output=True,
text=True,
timeout=10,
check=False,
)
expected = 0 if native_result == execution_result == "success" else 1
expected = 0 if native_result == execution_result == scope_bench_result == "success" else 1
assert result.returncode == expected, result.stdout + result.stderr

View file

@ -298,7 +298,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// ── Phase 2: Tarjan SCC → reverse-topological list of SCCs.
const sccs = tarjanSccs(graph);
// ── Phase 2.5: precompute the per-file re-export closure (iterative,
// ── Phase 2.5: prepare the per-file re-export closure (iterative,
// SCC-condensed). Eliminates the recursive crawl that the per-edge
// `tryFinalize` call site used to do; lookups are O(1) afterwards.
// See `buildReexportClosures` for the algorithm.
@ -322,15 +322,20 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
ambiguousByFile.set(filePath, new Set(byName.keys()));
}
const topLevelOnly = hooks.namedImportsBindTopLevelOnly === true;
const reexportClosures = buildReexportClosures(
input.files,
byFilePath,
moduleEdgeIndex,
ambiguousByFile,
topLevelOnly,
hooks.filterWildcardNames,
input.workspaceIndex,
);
// Direct imports and unfiltered wildcards never read these closures. Build
// them once, on demand, so those workspaces do not allocate transitive tables
// (and their provenance paths) that are discarded without a single lookup.
let reexportClosures: ReadonlyMap<string, FileReexportClosure> | undefined;
const getReexportClosures = () =>
(reexportClosures ??= buildReexportClosures(
input.files,
byFilePath,
moduleEdgeIndex,
ambiguousByFile,
topLevelOnly,
hooks.filterWildcardNames,
input.workspaceIndex,
));
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
@ -369,7 +374,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
if (drafts === undefined) continue;
for (const draft of drafts) {
if (draft.finalized !== null) continue;
const finalized = tryFinalize(draft, byFilePath, reexportClosures, topLevelOnly);
const finalized = tryFinalize(draft, byFilePath, getReexportClosures, topLevelOnly);
if (finalized !== null) {
draft.finalized = finalized;
progressed = true;
@ -422,7 +427,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
byFilePath,
hooks,
input.workspaceIndex,
reexportClosures,
getReexportClosures,
);
for (const e of expanded) {
if (
@ -692,7 +697,7 @@ function extractExportedName(parsed: ParsedImport): string {
function tryFinalize(
draft: ImportEdgeDraft,
byFilePath: Map<string, FinalizeFile>,
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
getReexportClosures: () => ReadonlyMap<string, FileReexportClosure>,
topLevelOnly: boolean,
): ImportEdge | null {
const targetFile = draft.targetFile;
@ -754,7 +759,7 @@ function tryFinalize(
// already encodes every name reachable through that file's named and
// wildcard re-exports — including transitively through cyclic SCCs —
// so the lookup is O(1) and never recurses.
const followed = lookupReexportedName(reexportClosures, targetFile, importedName);
const followed = lookupReexportedName(getReexportClosures(), targetFile, importedName);
if (followed === null) {
// Target resolvable but the name isn't exported — keep trying in case a
// re-export inside the target's SCC surfaces it in a later iteration.
@ -1605,7 +1610,7 @@ function expandWildcard(
byFilePath: Map<string, FinalizeFile>,
hooks: FinalizeHooks,
workspace: WorkspaceIndex,
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
getReexportClosures: () => ReadonlyMap<string, FileReexportClosure>,
): readonly ImportEdge[] {
if (edge.targetModuleScope === undefined || edge.targetFile === null) {
return [edge]; // unresolvable wildcard survives as a single unlinked edge
@ -1614,7 +1619,9 @@ function expandWildcard(
if (target === undefined) return [edge];
const targetClosure =
hooks.filterWildcardNames === undefined ? undefined : reexportClosures.get(edge.targetFile);
hooks.filterWildcardNames === undefined
? undefined
: getReexportClosures().get(edge.targetFile);
const names =
hooks.filterWildcardNames?.(
edge.targetModuleScope,

View file

@ -0,0 +1,53 @@
# Scope-resolution measurements
Run from `gitnexus/` after `npm run build`:
```sh
node --import tsx bench/scope-resolution/measure.mts --check --output /tmp/scope-resolution.json
```
CI runs every language in both `SupportedLanguages` and `SCOPE_RESOLVERS`, serially.
Missing corpus or resolver entries fail the inventory check. Each language has
100- and 1,000-entity workloads with 20 generated members or procedures per entity,
plus callers and helpers. Rust also needs a root
module file; Objective-C has a header and implementation per entity. Java adds an
isolated producer file and exercises transitive factory returns and repeated
unreachable return-name lookups. Swift uses same-module visibility; COBOL exercises
program references and procedure pointers. Other arms import the preceding file and call through its
return value. These generated sources exercise the analyzer; they are not builds
or representative application benchmarks.
Every sample runs in a fresh process through real parsing with one worker and the
production scope-resolution pass. COBOL also runs its standalone structural parser.
Configuration files are written to a temporary workspace and loaded through the resolver's normal
configuration hook. No production heap, file-count, or propagation limit is imposed.
The JSON artifact and GitHub job summary report the median of three samples:
- Parsing and scope-resolution wall time separately.
- JavaScript heap immediately after parsing and after resolution. These are endpoint
samples, not peak heap or retained heap after garbage collection.
- Process peak RSS through resolution, including worker memory. This is the OS
high-water mark, not a timer that can miss synchronous allocation spikes.
- Module and total type-binding counts, node/edge counts, and cross-file calls.
- An order-independent fingerprint of node identities and complete relationship
endpoints, types, confidence and reasons. Equal counts alone are insufficient.
The check requires every source to reach scope resolution, nonempty declarations
and cross-file calls, identical graph fingerprints between trials, and equality
with committed graph baselines. Type-binding counts must be deterministic and must
not exceed the recorded counts; reductions are allowed. These measured allocation
budgets catch redundant table growth even when the graph stays identical. Runtime
and heap/RSS are measurements, without uncalibrated
absolute performance thresholds. Compare the same workload on the same machine
before choosing an optimization.
For a smaller diagnostic run:
```sh
node --import tsx bench/scope-resolution/measure.mts --language java --sizes 10,100 --methods 20 --repetitions 3
```
To establish baselines after reviewing a fixture, semantic, or allocation change,
run the default all-language workload with `--record` instead of `--check` and inspect
`baselines.json`. CI never records or accepts a new baseline automatically.

View file

@ -0,0 +1,290 @@
{
"c/100/20": {
"fingerprint": "3c289f3c87de79d760e25b42eb7341796995d1bcb732909412cb28162cbf4f1b",
"nodes": 2399,
"edges": 2596,
"crossFileCalls": 198,
"moduleTypeBindings": 2099,
"typeBindings": 4198
},
"c/1000/20": {
"fingerprint": "74232bcf783b979e34479a8333e6bc87f812037b9500fb672b5dc8d416fbe08a",
"nodes": 23999,
"edges": 25996,
"crossFileCalls": 1998,
"moduleTypeBindings": 20999,
"typeBindings": 41998
},
"cobol/100/20": {
"fingerprint": "36c3fe6374ed108ea36ecfa38ecf2ba625623b4aae6de9d74220243c31434376",
"nodes": 2598,
"edges": 2796,
"crossFileCalls": 99,
"moduleTypeBindings": 0,
"typeBindings": 0
},
"cobol/1000/20": {
"fingerprint": "6b76194c20ee0897a52ac21300c2e159db93997137c00b92dfb09db44cf7a54c",
"nodes": 25998,
"edges": 27996,
"crossFileCalls": 999,
"moduleTypeBindings": 0,
"typeBindings": 0
},
"cpp/100/20": {
"fingerprint": "10a7fdf36be1221ab73184a1900b90180c7a1cf1d45a4dc5584ea6c4c2418448",
"nodes": 2299,
"edges": 2694,
"crossFileCalls": 198,
"moduleTypeBindings": 197,
"typeBindings": 2296
},
"cpp/1000/20": {
"fingerprint": "2dfe2d47dfebec5fbee15a9e6cce1c8b254f3594efec9a28ad82a941d43f3b8d",
"nodes": 22999,
"edges": 26994,
"crossFileCalls": 1998,
"moduleTypeBindings": 1997,
"typeBindings": 22996
},
"csharp/100/20": {
"fingerprint": "031987488f1dfbaf7d157c451a4b02fd679eed1f984212f74933d7d41c71bfd1",
"nodes": 2399,
"edges": 2596,
"crossFileCalls": 198,
"moduleTypeBindings": 2099,
"typeBindings": 4297
},
"csharp/1000/20": {
"fingerprint": "120f6cfec8eab8d476a5aa64b3d58b024005bf420911d2282153bdc1e1f980d4",
"nodes": 23999,
"edges": 25996,
"crossFileCalls": 1998,
"moduleTypeBindings": 20999,
"typeBindings": 42997
},
"dart/100/20": {
"fingerprint": "133b7b9c3eb4229d9ff673069cddacba530e32cd9011e26d590b9627f7815196",
"nodes": 2299,
"edges": 2496,
"crossFileCalls": 198,
"moduleTypeBindings": 4079,
"typeBindings": 6277
},
"dart/1000/20": {
"fingerprint": "f8a78d11b836ea73a662236f2ae57cb607025ec5fd1ab2a7c96ac491850426fb",
"nodes": 22999,
"edges": 24996,
"crossFileCalls": 1998,
"moduleTypeBindings": 40979,
"typeBindings": 62977
},
"go/100/20": {
"fingerprint": "33d0980b813bed5a8ac4a34ca44c50033b2517c966565f90f72057f422dc6a1f",
"nodes": 2299,
"edges": 4496,
"crossFileCalls": 198,
"moduleTypeBindings": 101099,
"typeBindings": 103198
},
"go/1000/20": {
"fingerprint": "7e0fff39a068ed15651614aa5df5baa1614a72014a0df2b2e926330d4fc9de42",
"nodes": 22999,
"edges": 44996,
"crossFileCalls": 1998,
"moduleTypeBindings": 10010999,
"typeBindings": 10031998
},
"java/100/20": {
"fingerprint": "cbfe462b1ba57dba73b8aa81e536bd2441c0680725963b0e9f2007bab8e77695",
"nodes": 2697,
"edges": 3188,
"crossFileCalls": 493,
"moduleTypeBindings": 4277,
"typeBindings": 7069
},
"java/1000/20": {
"fingerprint": "5d1b01e266a3d22c3e11376fd8900b82ef2d6d28ca1737a5dd0c7e925ebb233b",
"nodes": 26997,
"edges": 31988,
"crossFileCalls": 4993,
"moduleTypeBindings": 42977,
"typeBindings": 70969
},
"javascript/100/20": {
"fingerprint": "c3c3cd272d2cbbe704bbb2bd3ce8b3b153c3156f812c946fd5a4c70ff7eaa39c",
"nodes": 2299,
"edges": 2496,
"crossFileCalls": 198,
"moduleTypeBindings": 0,
"typeBindings": 2099
},
"javascript/1000/20": {
"fingerprint": "959f154fe61efb1966389a36731c8aa051be96d385b950c75a77f9b323044bcc",
"nodes": 22999,
"edges": 24996,
"crossFileCalls": 1998,
"moduleTypeBindings": 0,
"typeBindings": 20999
},
"kotlin/100/20": {
"fingerprint": "a27beaf18a1c48a93b1bee23a89de35e5afa4659980cbf30b1181c33d24cce0d",
"nodes": 2299,
"edges": 2496,
"crossFileCalls": 198,
"moduleTypeBindings": 2099,
"typeBindings": 4297
},
"kotlin/1000/20": {
"fingerprint": "f6b03d2de2fb25597c9f68f5f4ad821d1bcf7b52a2bada5923e8de1c620c84bc",
"nodes": 22999,
"edges": 24996,
"crossFileCalls": 1998,
"moduleTypeBindings": 20999,
"typeBindings": 42997
},
"objective-c/100/20": {
"fingerprint": "3cebcee9eac578cfde569eef48e45ac4427f822f5a3b3e7291cb4ee40d894e48",
"nodes": 4996,
"edges": 7492,
"crossFileCalls": 99,
"moduleTypeBindings": 0,
"typeBindings": 0
},
"objective-c/1000/20": {
"fingerprint": "1cde787c33fc9f44bdb1d5f23291044b615f4d245df2beba1222aaf3435cd07f",
"nodes": 49996,
"edges": 74992,
"crossFileCalls": 999,
"moduleTypeBindings": 0,
"typeBindings": 0
},
"php/100/20": {
"fingerprint": "e2b9b779517483da80c4a32575e745a497b601a60fa374b4081dfacacc14a6c9",
"nodes": 2399,
"edges": 2596,
"crossFileCalls": 198,
"moduleTypeBindings": 101099,
"typeBindings": 103297
},
"php/1000/20": {
"fingerprint": "f7abaf180b0404e942f0d9d27d3e9f7c20a6e098c1cbe7d2221d615ecb6674eb",
"nodes": 23999,
"edges": 25996,
"crossFileCalls": 1998,
"moduleTypeBindings": 10010999,
"typeBindings": 10032997
},
"python/100/20": {
"fingerprint": "58adb2bc6b7ac24ab3dcd500ec3cd577d3e6853fd1a8e84f658f423677d6df50",
"nodes": 2299,
"edges": 2397,
"crossFileCalls": 99,
"moduleTypeBindings": 0,
"typeBindings": 4099
},
"python/1000/20": {
"fingerprint": "cba608259275ea5a0b7ece3e9b76680afe2c0c385f482d8a7325164fc4b4f8f0",
"nodes": 22999,
"edges": 23997,
"crossFileCalls": 999,
"moduleTypeBindings": 0,
"typeBindings": 40999
},
"ruby/100/20": {
"fingerprint": "a2472fd7ca5b2493d24a354b65cd605946b19a00d65df9d5d9788d44da50865c",
"nodes": 2299,
"edges": 2397,
"crossFileCalls": 99,
"moduleTypeBindings": 0,
"typeBindings": 2099
},
"ruby/1000/20": {
"fingerprint": "9163eb4d316d339dc2169f9e60262019c4f81d31314b38e44f692e04baebf703",
"nodes": 22999,
"edges": 23997,
"crossFileCalls": 999,
"moduleTypeBindings": 0,
"typeBindings": 20999
},
"rust/100/20": {
"fingerprint": "c3221b201eedd52cf0d4a9bda609b7a4ff60033520cfec854dff47d185b0770d",
"nodes": 2500,
"edges": 4894,
"crossFileCalls": 198,
"moduleTypeBindings": 2099,
"typeBindings": 6698
},
"rust/1000/20": {
"fingerprint": "ec5d974b2ace8565d8946614b8da1246d8b5736415bbf48954289f52c01eb870",
"nodes": 25000,
"edges": 48994,
"crossFileCalls": 1998,
"moduleTypeBindings": 20999,
"typeBindings": 66998
},
"swift/100/20": {
"fingerprint": "ce08be70302793b51c6816bc0842562565a9bb21bc87ff72eb01f6312bba2323",
"nodes": 2300,
"edges": 2497,
"crossFileCalls": 198,
"moduleTypeBindings": 2099,
"typeBindings": 4297
},
"swift/1000/20": {
"fingerprint": "87d6dc9bd2db62a716fdcfd347475c89561520e0eaafb456b3ae16720ac390d4",
"nodes": 23000,
"edges": 24997,
"crossFileCalls": 1998,
"moduleTypeBindings": 20999,
"typeBindings": 42997
},
"typescript/100/20": {
"fingerprint": "48c3afe1d2e1e2750e0ed5d8139aa009f264a997de5a084e696b5175b5a4f2cd",
"nodes": 2299,
"edges": 4496,
"crossFileCalls": 198,
"moduleTypeBindings": 2000,
"typeBindings": 4099
},
"typescript/1000/20": {
"fingerprint": "f7efff0e855bc68220f4d19891dcade1c9d0df4bcb2b845a3310c83c212edca7",
"nodes": 22999,
"edges": 44996,
"crossFileCalls": 1998,
"moduleTypeBindings": 20000,
"typeBindings": 40999
},
"vue/100/20": {
"fingerprint": "a619b08a4c7098f13bf4708d6fb56f49f7e3723efb2092b2f5d84d9a53668627",
"nodes": 2299,
"edges": 4397,
"crossFileCalls": 99,
"moduleTypeBindings": 2000,
"typeBindings": 4099
},
"vue/1000/20": {
"fingerprint": "fd95d70ec20fe0c15eb1a545d2648cf0df27c1e2f2fd0288a64229be6a598e60",
"nodes": 22999,
"edges": 43997,
"crossFileCalls": 999,
"moduleTypeBindings": 20000,
"typeBindings": 40999
},
"zig/100/20": {
"fingerprint": "b623955c7d86a6884abdba939ea4ad620446b268cffd0b101c9f96b1424cc236",
"nodes": 2299,
"edges": 2694,
"crossFileCalls": 198,
"moduleTypeBindings": 99,
"typeBindings": 4198
},
"zig/1000/20": {
"fingerprint": "812ed4d8f7a8cd3958950a55759cbc73e34fd134e8a02920e71e0c9e2211ed9f",
"nodes": 22999,
"edges": 26994,
"crossFileCalls": 1998,
"moduleTypeBindings": 999,
"typeBindings": 41998
}
}

View file

@ -0,0 +1,219 @@
import { SupportedLanguages } from 'gitnexus-shared';
export interface SourceFile {
path: string;
content: string;
}
type Generator = (i: number, methods: number) => SourceFile | SourceFile[];
const members = (count: number, source: (j: number) => string) =>
Array.from({ length: count }, (_, j) => source(j)).join('\n');
const previous = (i: number, source: (p: number) => string) => (i > 0 ? source(i - 1) : '');
const script = (i: number, count: number, typed: boolean, extension: string): string =>
`${previous(i, (p) => `import { C${p} } from './C${p}.${extension}';`)}
export class C${i} {
${members(count, (j) => ` make${i}_${j}()${typed ? `: C${i}` : ''} { return this; }`)}
${previous(i, (p) => ` usePrevious() { return new C${p}().make${p}_0().make${p}_1(); }`)}
}`;
// Each arm has one chain of cross-file uses and distinct method names. Swift
// uses same-module visibility; COBOL uses program calls and procedure pointers.
export const generators = {
javascript: (i, n) => ({ path: `src/C${i}.js`, content: script(i, n, false, 'js') }),
typescript: (i, n) => ({ path: `src/C${i}.ts`, content: script(i, n, true, 'js') }),
vue: (i, n) => ({
path: `src/C${i}.vue`,
content: `<script lang="ts">\n${script(i, n, true, 'vue')}\nexport default {};\n</script>\n<template><div /></template>`,
}),
python: (i, n) => ({
path: `src/c${i}.py`,
content: `${previous(i, (p) => `from c${p} import C${p}`)}
class C${i}:
${members(n, (j) => ` def make${i}_${j}(self) -> "C${i}":\n return self`)}
${previous(i, (p) => ` def use_previous(self):\n return C${p}().make${p}_0().make${p}_1()`)}
`,
}),
java: (i, n) => ({
path: `src/p${i}/C${i}.java`,
content: `package p${i};
${previous(i, (p) => `import p${p}.C${p};`)}
public class C${i} {
${members(n, (j) => ` public C${i} make${i}_${j}() { return this; }`)}
${previous(i, (p) => ` public C${p} previous${i}() { return new C${p}(); }`)}
${previous(i, (p) => ` public C${p} usePrevious(C${p} value) { return value.make${p}_0().make${p}_1(); }`)}
${i > 1 ? ` public void useTransitive(C${i - 1} value) { var older = value.previous${i - 1}(); older.make${i - 2}_0(); }` : ''}
public void externalUsage(external.Client client) { client.externalBuild(); }
}`,
}),
c: (i, n) => ({
path: `src/C${i}.h`,
content: `#pragma once
${previous(i, (p) => `#include "C${p}.h"`)}
typedef struct C${i} { int value; } C${i};
${members(n, (j) => `static inline C${i}* make${i}_${j}(C${i}* value) { return value; }`)}
${previous(i, (p) => `static inline C${p}* usePrevious${i}(C${p}* value) { return make${p}_1(make${p}_0(value)); }`)}
`,
}),
cpp: (i, n) => ({
path: `src/C${i}.hpp`,
content: `#pragma once
${previous(i, (p) => `#include "C${p}.hpp"`)}
class C${i} { public:
${members(n, (j) => ` C${i}* make${i}_${j}() { return this; }`)}
};
${previous(i, (p) => `inline C${p}* usePrevious${i}(C${p}* value) { return value->make${p}_0()->make${p}_1(); }`)}
`,
}),
'objective-c': (i, n) => [
{
path: `src/C${i}.h`,
content: `#import <Foundation/Foundation.h>
${previous(i, (p) => `#import "C${p}.h"`)}
@interface C${i} : NSObject
${members(n, (j) => `- (C${i} *)make${i}_${j};`)}
${previous(i, (p) => `- (C${p} *)usePrevious:(C${p} *)value;`)}
@end
`,
},
{
path: `src/C${i}.m`,
content: `#import "C${i}.h"
@implementation C${i}
${members(n, (j) => `- (C${i} *)make${i}_${j} { return self; }`)}
${previous(i, (p) => `- (C${p} *)usePrevious:(C${p} *)value { return [[value make${p}_0] make${p}_1]; }`)}
@end
`,
},
],
csharp: (i, n) => ({
path: `src/P${i}/C${i}.cs`,
content: `${previous(i, (p) => `using P${p};`)}
namespace P${i} {
public class C${i} {
${members(n, (j) => ` public C${i} Make${i}_${j}() { return this; }`)}
${previous(i, (p) => ` public C${p} UsePrevious(C${p} value) { return value.Make${p}_0().Make${p}_1(); }`)}
}}
`,
}),
go: (i, n) => ({
path: `src/p${i}/c${i}.go`,
content: `package p${i}
${previous(i, (p) => `import "bench/src/p${p}"`)}
type C${i} struct {}
${members(n, (j) => `func (value *C${i}) Make${i}_${j}() *C${i} { return value }`)}
${previous(i, (p) => `func UsePrevious(value *p${p}.C${p}) *p${p}.C${p} { return value.Make${p}_0().Make${p}_1() }`)}
`,
}),
ruby: (i, n) => ({
path: `src/c${i}.rb`,
content: `${previous(i, (p) => `require_relative 'c${p}'`)}
class C${i}
${members(n, (j) => ` def make${i}_${j}()\n self\n end`)}
${previous(i, (p) => ` def use_previous()\n C${p}.new.make${p}_0().make${p}_1()\n end`)}
end
`,
}),
rust: (i, n) => ({
path: `src/c${i}.rs`,
content: `${previous(i, (p) => `use crate::c${p}::C${p};`)}
pub struct C${i} {}
impl C${i} {
${members(n, (j) => ` pub fn make${i}_${j}(&self) -> C${i} { C${i} {} }`)}
}
${previous(i, (p) => `pub fn use_previous(value: C${p}) -> C${p} { value.make${p}_0().make${p}_1() }`)}
`,
}),
php: (i, n) => ({
path: `src/P${i}/C${i}.php`,
content: `<?php
namespace Bench\\P${i};
${previous(i, (p) => `use Bench\\P${p}\\C${p};`)}
class C${i} {
${members(n, (j) => ` public function make${i}_${j}(): C${i} { return $this; }`)}
${previous(i, (p) => ` public function usePrevious(C${p} $value): C${p} { return $value->make${p}_0()->make${p}_1(); }`)}
}
`,
}),
kotlin: (i, n) => ({
path: `src/p${i}/C${i}.kt`,
content: `package p${i}
${previous(i, (p) => `import p${p}.C${p}`)}
class C${i} {
${members(n, (j) => ` fun make${i}_${j}(): C${i} = this`)}
${previous(i, (p) => ` fun usePrevious(value: C${p}): C${p} = value.make${p}_0().make${p}_1()`)}
}
`,
}),
swift: (i, n) => ({
path: `Sources/Bench/C${i}.swift`,
content: `class C${i} {
${members(n, (j) => ` func make${i}_${j}() -> C${i} { return self }`)}
${previous(i, (p) => ` func usePrevious(_ value: C${p}) -> C${p} { return value.make${p}_0().make${p}_1() }`)}
}
`,
}),
dart: (i, n) => ({
path: `lib/c${i}.dart`,
content: `${previous(i, (p) => `import 'c${p}.dart';`)}
class C${i} {
${members(n, (j) => ` C${i} make${i}_${j}() { return this; }`)}
${previous(i, (p) => ` C${p} usePrevious(C${p} value) { return value.make${p}_0().make${p}_1(); }`)}
}
`,
}),
cobol: (i, n) => ({
path: `src/C${i}.cbl`,
content: ` IDENTIFICATION DIVISION.
PROGRAM-ID. C${i}.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 TARGET-PTR USAGE PROCEDURE-POINTER.
PROCEDURE DIVISION.
MAIN-PARA.
${previous(i, (p) => ` SET TARGET-PTR TO ENTRY 'C${p}'\n CALL TARGET-PTR`)}
PERFORM MAKE-${i}-0
GOBACK.
${members(n, (j) => ` MAKE-${i}-${j}.\n CONTINUE.`)}
`,
}),
zig: (i, n) => ({
path: `src/c${i}.zig`,
content: `${previous(i, (p) => `const previous = @import("c${p}.zig");`)}
pub const C${i} = struct {
${members(n, (j) => ` pub fn make${i}_${j}(self: C${i}) C${i} { return self; }`)}
};
${previous(i, (p) => `pub fn usePrevious(value: previous.C${p}) previous.C${p} { return value.make${p}_0().make${p}_1(); }`)}
`,
}),
} satisfies Record<SupportedLanguages, Generator>;
export function corpus(language: SupportedLanguages, count: number, methods: number) {
const files: SourceFile[] = Array.from({ length: count }, (_, i) =>
generators[language](i, methods),
).flat();
const config: SourceFile[] = [];
if (language === SupportedLanguages.Java) {
// The shared name is indexed, but unreachable from every chain file.
// Repeated misses must not re-walk the whole import chain per caller.
files.push({
path: 'src/aux/Auxiliary.java',
content:
'package aux; public class Auxiliary { public Auxiliary externalBuild() { return this; } }',
});
} else if (language === SupportedLanguages.Go) {
config.push({ path: 'go.mod', content: 'module bench\n\ngo 1.22\n' });
} else if (language === SupportedLanguages.Rust) {
files.push({ path: 'src/lib.rs', content: members(count, (i) => `pub mod c${i};`) });
config.push({
path: 'Cargo.toml',
content: '[package]\nname = "bench"\nversion = "0.1.0"\nedition = "2021"\n',
});
} else if (language === SupportedLanguages.PHP) {
config.push({
path: 'composer.json',
content: JSON.stringify({ autoload: { 'psr-4': { 'Bench\\': 'src/' } } }),
});
}
return { files, config };
}

View file

@ -0,0 +1,300 @@
/** Real worker parsing + scope resolution, in a fresh process per sample. */
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
appendFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
import { SupportedLanguages } from 'gitnexus-shared';
import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js';
import { runScopeResolution } from '../../src/core/ingestion/scope-resolution/pipeline/run.js';
import { processCobol } from '../../src/core/ingestion/cobol-processor.js';
import { generateId } from '../../src/lib/utils.js';
import { parseFilesWithWorkers } from '../../test/helpers/worker-parse.js';
import { corpus, generators } from './corpus.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const BASELINES = join(HERE, 'baselines.json');
const MARKER = 'SCOPE_BENCH_RESULT ';
const digest = (rows: string[]) =>
createHash('sha256').update(rows.sort().join('\n')).digest('hex');
async function sample(language: SupportedLanguages, count: number, methods: number) {
const { files, config } = corpus(language, count, methods);
const dir = mkdtempSync(join(tmpdir(), 'gitnexus-scope-bench-'));
try {
for (const file of [...files, ...config]) {
mkdirSync(dirname(join(dir, file.path)), { recursive: true });
writeFileSync(join(dir, file.path), file.content);
}
const resolver = SCOPE_RESOLVERS.get(language);
assert.ok(resolver, `No scope resolver registered for ${language}`);
const resolutionConfig = await resolver.loadResolutionConfig?.(dir);
const parseStarted = performance.now();
const { graph, model, data } = await parseFilesWithWorkers(files, { poolSize: 1 });
// The normal pipeline creates these during structure processing, before
// either parser runs. The worker helper only supplies symbol nodes.
for (const file of files) {
graph.addNode({
id: generateId('File', file.path),
label: 'File',
properties: { name: basename(file.path), filePath: file.path },
});
}
// COBOL's structural graph is emitted by its standalone production parser.
if (language === SupportedLanguages.Cobol) {
processCobol(graph, files, new Set(files.map((file) => file.path)));
}
const parseMs = performance.now() - parseStarted;
const heapAfterParse = process.memoryUsage().heapUsed;
let parsedFileCount = 0;
let moduleTypeBindings = 0;
let typeBindings = 0;
const resolveStarted = performance.now();
runScopeResolution(
{
graph,
model,
files,
resolutionConfig,
preExtractedParsedFiles: new Map(data.parsedFiles.map((file) => [file.filePath, file])),
onResolved: ({ parsedFiles }) => {
parsedFileCount = parsedFiles.length;
for (const file of parsedFiles) {
for (const scope of file.scopes) {
typeBindings += scope.typeBindings.size;
if (scope.id === file.moduleScope) moduleTypeBindings += scope.typeBindings.size;
}
}
},
},
resolver,
);
const resolveMs = performance.now() - resolveStarted;
const heapAfterResolve = process.memoryUsage().heapUsed;
const peakRssBytes = process.resourceUsage().maxRSS * 1024;
assert.equal(parsedFileCount, files.length, `${language}: scope extraction omitted files`);
assert.ok(graph.nodeCount > files.length, `${language}: no declarations parsed`);
let calls = 0;
let crossFileCalls = 0;
const edges: string[] = [];
for (const edge of graph.iterRelationships()) {
edges.push(
JSON.stringify([edge.sourceId, edge.targetId, edge.type, edge.confidence, edge.reason]),
);
if (edge.type !== 'CALLS') continue;
calls++;
const source = graph.getNode(edge.sourceId);
const target = graph.getNode(edge.targetId);
if (
source !== undefined &&
target !== undefined &&
source.properties.filePath !== target.properties.filePath
)
crossFileCalls++;
}
assert.ok(crossFileCalls > 0, `${language}: cross-file workload resolved no calls`);
const nodes = [...graph.iterNodes()].map((node) =>
JSON.stringify([node.id, node.label, node.properties.name, node.properties.filePath]),
);
return {
language,
entities: count,
files: files.length,
methodsPerEntity: methods,
parseMs,
resolveMs,
heapAfterParse,
heapAfterResolve,
peakRssBytes,
moduleTypeBindings,
typeBindings,
nodes: graph.nodeCount,
edges: graph.relationshipCount,
calls,
crossFileCalls,
fingerprint: digest([...nodes, ...edges]),
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const args = process.argv.slice(2);
if (args[0] === '--worker') {
const result = await sample(args[1] as SupportedLanguages, Number(args[2]), Number(args[3]));
console.log(MARKER + JSON.stringify(result));
} else {
const value = (flag: string, fallback: string) => {
const index = args.indexOf(flag);
return index < 0 ? fallback : args[index + 1];
};
const languages = Object.values(SupportedLanguages).sort();
assert.deepEqual(Object.keys(generators).sort(), languages, 'Missing benchmark language');
assert.deepEqual([...SCOPE_RESOLVERS.keys()].sort(), languages, 'Resolver inventory changed');
const selected = value('--language', 'all');
assert.ok(
selected === 'all' || languages.includes(selected as SupportedLanguages),
'Unknown language',
);
const sizes = value('--sizes', '100,1000').split(',').map(Number);
const repetitions = Number(value('--repetitions', '3'));
const methods = Number(value('--methods', '20'));
assert.ok(
[...sizes, repetitions, methods].every((n) => Number.isSafeInteger(n) && n >= 2),
'Sizes, repetitions and methods must be integers >= 2',
);
const check = args.includes('--check');
const record = args.includes('--record');
assert.ok(!(check && record), '--check cannot record new baselines');
if (record) assert.equal(selected, 'all', '--record requires all languages');
const baselines = check ? JSON.parse(readFileSync(BASELINES, 'utf8')) : {};
const results: Awaited<ReturnType<typeof sample>>[] = [];
const trials: Awaited<ReturnType<typeof sample>>[] = [];
const failures: string[] = [];
const records: Record<
string,
{
fingerprint: string;
nodes: number;
edges: number;
crossFileCalls: number;
moduleTypeBindings: number;
typeBindings: number;
}
> = {};
for (const language of languages.filter((lang) => selected === 'all' || lang === selected)) {
for (const count of sizes) {
const samples: Awaited<ReturnType<typeof sample>>[] = [];
for (let trial = 0; trial < repetitions; trial++) {
const child = spawnSync(
process.execPath,
[
'--import',
'tsx',
fileURLToPath(import.meta.url),
'--worker',
language,
String(count),
String(methods),
],
{
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, GITNEXUS_LOG_LEVEL: 'error' },
},
);
const line = child.stdout?.split('\n').find((line) => line.startsWith(MARKER));
if (child.status !== 0 || !line) {
const failure = `${language}/${count}: ${child.error?.message || child.stderr || child.signal || `exit ${child.status}`}`;
failures.push(failure);
console.error(failure);
break;
}
samples.push(JSON.parse(line.slice(MARKER.length)));
}
trials.push(...samples);
if (samples.length !== repetitions) continue;
if (new Set(samples.map((item) => item.fingerprint)).size !== 1)
failures.push(`${language}/${count}: nondeterministic graph`);
for (const field of ['moduleTypeBindings', 'typeBindings'] as const) {
if (new Set(samples.map((item) => item[field])).size !== 1)
failures.push(`${language}/${count}: nondeterministic ${field}`);
}
const result = { ...samples[0] };
for (const key of [
'parseMs',
'resolveMs',
'heapAfterParse',
'heapAfterResolve',
'peakRssBytes',
] as const) {
const values = samples.map((item) => item[key]).sort((a, b) => a - b);
result[key] =
(values[Math.floor((values.length - 1) / 2)] + values[Math.floor(values.length / 2)]) / 2;
}
const key = `${language}/${count}/${methods}`;
records[key] = {
fingerprint: result.fingerprint,
nodes: result.nodes,
edges: result.edges,
crossFileCalls: result.crossFileCalls,
moduleTypeBindings: result.moduleTypeBindings,
typeBindings: result.typeBindings,
};
if (check) {
const baseline = baselines[key];
if (
!baseline ||
(['fingerprint', 'nodes', 'edges', 'crossFileCalls'] as const).some(
(field) => records[key][field] !== baseline[field],
)
)
failures.push(`${key}: graph differs from committed baseline`);
for (const field of ['moduleTypeBindings', 'typeBindings'] as const) {
if (!Number.isSafeInteger(baseline?.[field]) || result[field] > baseline[field])
failures.push(
`${key}: ${field} exceeds measured baseline (${result[field]} > ${baseline?.[field]})`,
);
}
}
results.push(result);
console.log(JSON.stringify(result));
}
}
const scaling = [...new Set(results.map((r) => r.language))].map((language) => {
const rows = results.filter((r) => r.language === language).sort((a, b) => a.files - b.files);
const small = rows[0];
const large = rows[rows.length - 1];
const fileRatio = large.files / small.files;
return {
language,
fileRatio,
normalizedResolveRatio: large.resolveMs / small.resolveMs / fileRatio,
heapRatio: large.heapAfterResolve / small.heapAfterResolve,
moduleBindingRatio:
small.moduleTypeBindings === 0 ? null : large.moduleTypeBindings / small.moduleTypeBindings,
};
});
const report = {
node: process.version,
platform: process.platform,
arch: process.arch,
parsingWorkers: 1,
repetitions,
results,
scaling,
trials,
failures,
};
const output = value('--output', '');
if (output) writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
const table = [
'## Scope-resolution benchmark',
'',
'Medians from fresh processes. Heap is sampled after each phase; RSS is the process high-water mark through resolution. These are synthetic workloads.',
'',
'| Language | Files | Parse ms | Resolve ms | Heap after resolve MiB | Peak RSS MiB | Module type bindings | Cross-file calls |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
...results.map(
(r) =>
`| ${r.language} | ${r.files} | ${r.parseMs.toFixed(1)} | ${r.resolveMs.toFixed(1)} | ${(r.heapAfterResolve / 2 ** 20).toFixed(1)} | ${(r.peakRssBytes / 2 ** 20).toFixed(1)} | ${r.moduleTypeBindings} | ${r.crossFileCalls} |`,
),
'',
...failures.map((failure) => `- FAILED: ${failure}`),
'',
].join('\n');
console.log(table);
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, table);
assert.equal(failures.length, 0, failures.join('\n'));
if (record) writeFileSync(BASELINES, JSON.stringify(records, null, 2) + '\n');
}

View file

@ -341,6 +341,7 @@ const WORKER_THREADS = [
// Native callback parsing and exact caller/summary ownership (#3489).
'test/integration/cfg/interproc-taint.test.ts',
'test/integration/function-local-identity.test.ts',
'test/integration/java-imported-return-types.test.ts',
'test/unit/community-processor.test.ts',
'test/integration/worker-pool.test.ts',
'test/integration/parse-impl-quarantine-cache-skip.test.ts',

View file

@ -15,6 +15,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l
import {
isClassLike,
lookupBindingsAt,
lookupNameClaim,
namesAtScope,
populateClassOwnedMembers,
} from '../../scope-resolution/scope/walkers.js';
@ -110,10 +111,35 @@ function populateJavaCrossFileReturnTypes(
indexes: ScopeResolutionIndexes,
): void {
const moduleScopeByFile = new Map<string, ParsedFile['scopes'][number]>();
const moduleReturnTypesByFile = new Map<string, Map<string, TypeRef>>();
const returnNames = new Set<string>();
const importedFamiliesByFile = new Map<string, string[][]>();
const ambiguousByFile = new Map<string, Set<string>>();
const returnTypeIds = new Map<TypeRef, string | undefined>();
const returnTypeId = (ref: TypeRef): string | undefined => {
if (!returnTypeIds.has(ref)) {
const ids = new Set(
lookupNameClaim(ref.declaredAtScope, ref.rawName, indexes, { purpose: 'type' })
.bindings.filter((binding) => isClassLike(binding.def.type))
.map((binding) => binding.def.nodeId),
);
returnTypeIds.set(ref, ids.size === 1 ? ids.values().next().value : undefined);
}
return returnTypeIds.get(ref);
};
const classScopesByFile = new Map<string, ParsedFile['scopes'][number][]>();
for (const parsed of parsedFiles) {
const ms = parsed.scopes.find((s) => s.kind === 'Module');
if (ms !== undefined) moduleScopeByFile.set(parsed.filePath, ms);
if (ms !== undefined) {
moduleScopeByFile.set(parsed.filePath, ms);
// Keep producer facts once. Missing transitive facts are looked up on
// demand below instead of copying every dependency's table (#3695).
const returns = new Map(
[...ms.typeBindings].filter(([, ref]) => ref.source === 'return-annotation'),
);
moduleReturnTypesByFile.set(parsed.filePath, returns);
for (const name of returns.keys()) returnNames.add(name);
}
const cs = parsed.scopes.filter((s) => s.kind === 'Class');
if (cs.length > 0) classScopesByFile.set(parsed.filePath, cs);
}
@ -123,6 +149,9 @@ function populateJavaCrossFileReturnTypes(
if (importerModule === undefined) continue;
const ambiguousMirrors = new Set<string>();
ambiguousByFile.set(parsed.filePath, ambiguousMirrors);
const importedFamilies: string[][] = [];
importedFamiliesByFile.set(parsed.filePath, importedFamilies);
for (const name of namesAtScope(importerModule.id, indexes)) {
const refs = lookupBindingsAt(importerModule.id, name, indexes);
for (const ref of refs) {
@ -133,16 +162,26 @@ function populateJavaCrossFileReturnTypes(
if (sourceModule === undefined) continue;
const tb = importerModule.typeBindings as Map<string, TypeRef>;
for (const [srcName, srcRef] of sourceModule.typeBindings) {
if (srcRef.source !== 'return-annotation') continue;
if (ambiguousMirrors.has(srcName)) continue;
const existing = tb.get(srcName);
if (existing !== undefined && existing.rawName !== srcRef.rawName) {
ambiguousMirrors.add(srcName);
tb.delete(srcName);
continue;
const sourceFiles = new Set([ref.def.filePath]);
for (const ancestorId of indexes.methodDispatch.mroFor(ref.def.nodeId)) {
const ancestor = indexes.defs.get(ancestorId);
if (ancestor !== undefined) sourceFiles.add(ancestor.filePath);
}
importedFamilies.push([...sourceFiles]);
const familyNames = new Set<string>();
for (const sourceFile of sourceFiles) {
for (const [srcName, srcRef] of moduleReturnTypesByFile.get(sourceFile) ?? []) {
if (familyNames.has(srcName)) continue;
familyNames.add(srcName);
if (ambiguousMirrors.has(srcName)) continue;
const existing = tb.get(srcName);
if (existing !== undefined && existing.rawName !== srcRef.rawName) {
ambiguousMirrors.add(srcName);
tb.delete(srcName);
continue;
}
if (existing === undefined) tb.set(srcName, srcRef);
}
if (existing === undefined) tb.set(srcName, srcRef);
}
for (const classScope of classScopesByFile.get(ref.def.filePath) ?? []) {
@ -160,6 +199,58 @@ function populateJavaCrossFileReturnTypes(
}
}
}
}
for (const parsed of parsedFiles) {
const importerModule = moduleScopeByFile.get(parsed.filePath);
if (importerModule === undefined) continue;
const tb = importerModule.typeBindings as Map<string, TypeRef>;
const needed = new Set(parsed.referenceSites.map((site) => site.name));
for (const scope of parsed.scopes) {
for (const ref of scope.typeBindings.values()) needed.add(ref.rawName);
}
for (const name of needed) {
if (tb.has(name) || !returnNames.has(name) || ambiguousByFile.get(parsed.filePath)?.has(name))
continue;
const pending = [...(importedFamiliesByFile.get(parsed.filePath) ?? [])];
const visited = new Set([parsed.filePath]);
let candidate: TypeRef | undefined;
let ambiguous = false;
while (pending.length > 0) {
const family = pending.pop();
if (family === undefined) break;
let found: TypeRef | undefined;
// The first declaration in the MRO owns an overridden method.
for (const file of family) {
found = moduleReturnTypesByFile.get(file)?.get(name);
if (found !== undefined) break;
}
if (found !== undefined) {
if (candidate !== undefined) {
// A simple name can denote different classes in different packages.
// Preserve shared return types only when their producer scopes agree.
const sameType =
candidate.rawName === found.rawName &&
(candidate.declaredAtScope === found.declaredAtScope ||
(returnTypeId(candidate) !== undefined &&
returnTypeId(candidate) === returnTypeId(found)));
if (!sameType) {
ambiguous = true;
break;
}
}
candidate ??= found;
continue;
}
for (const file of family) {
if (visited.has(file)) continue;
visited.add(file);
for (const dependency of importedFamiliesByFile.get(file) ?? []) pending.push(dependency);
}
}
if (!ambiguous && candidate !== undefined) tb.set(name, candidate);
}
for (const [name, ref] of importerModule.typeBindings) {
const resolved = followChainPostFinalize(ref, importerModule.id, indexes);

View file

@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest';
import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope-resolver.js';
import { runScopeResolution } from '../../src/core/ingestion/scope-resolution/pipeline/run.js';
import { parseFilesWithWorkers } from '../helpers/worker-parse.js';
describe('Java imported return types through worker parsing', () => {
it.each([
{ reverse: false, returns: 'same spelling' },
{ reverse: true, returns: 'same spelling' },
{ reverse: false, returns: 'different spelling' },
{ reverse: true, returns: 'different spelling' },
{ reverse: false, returns: 'same class' },
{ reverse: true, returns: 'same class' },
])(
'compares transitive return identities ($returns, reverse=$reverse)',
async ({ reverse, returns }) => {
const otherType = returns === 'different spelling' ? 'OtherNode' : 'Node';
const shared = returns === 'same class';
const files = [
{
path: 'a/Node.java',
content: 'package a; public class Node { public void visitA() {} }',
},
{
path: 'a/TreeA.java',
content: 'package a; public class TreeA { public Node next() { return new Node(); } }',
},
{
path: `b/${otherType}.java`,
content: `package b; public class ${otherType} { public void visitB() {} }`,
},
{
path: 'b/TreeB.java',
content: `package b; ${shared ? 'import a.Node;' : ''} public class TreeB { public ${otherType} next() { return new ${otherType}(); } }`,
},
{
path: 'mid/Mid.java',
content:
'package mid; import a.TreeA; import b.TreeB; public class Mid { public TreeA makeA() { return new TreeA(); } }',
},
{
path: 'app/App.java',
content:
'package app; import mid.Mid; public class App { public void run() { Mid mid = new Mid(); var a = mid.makeA(); var n = a.next(); n.visitA(); n.visitB(); } }',
},
];
// No same-package competitor in the positive case: both producers return a.Node.
if (shared) files.splice(2, 1);
if (reverse) files.reverse();
javaScopeResolver.loadResolutionConfig?.('');
const { graph, model, data } = await parseFilesWithWorkers(files);
runScopeResolution(
{
graph,
model,
files,
preExtractedParsedFiles: new Map(data.parsedFiles.map((file) => [file.filePath, file])),
onResolved: ({ parsedFiles }) => {
const app = parsedFiles.find((file) => file.filePath === 'app/App.java');
const next = app?.scopes
.find((scope) => scope.id === app.moduleScope)
?.typeBindings.get('next');
if (shared) expect(next?.rawName).toBe('Node');
else expect(next).toBeUndefined();
},
},
javaScopeResolver,
);
const targets = [...graph.iterRelationshipsByType('CALLS')]
.filter((edge) => edge.sourceId === 'Method:app/App.java:App.run#0')
.map((edge) => edge.targetId);
expect(targets).not.toContain(`Method:b/${otherType}.java:${otherType}.visitB#0`);
if (shared) expect(targets).toContain('Method:a/Node.java:Node.visitA#0');
},
);
it.each([
{ reverse: false, cycle: false },
{ reverse: true, cycle: false },
{ reverse: false, cycle: true },
{ reverse: true, cycle: true },
])(
'resolves a 12-file factory chain (reverse=$reverse, cycle=$cycle)',
async ({ reverse, cycle }) => {
const count = 12;
const files = Array.from({ length: count }, (_, i) => ({
path: `p${i}/C${i}.java`,
content: `package p${i};
${i + 1 < count ? `import p${i + 1}.C${i + 1};` : cycle ? 'import p0.C0;' : ''}
public class C${i} {
${i + 1 < count ? `public C${i + 1} next${i}() { return new C${i + 1}(); }` : 'public void finish() {}'}
}`,
}));
files.push({
path: 'app/App.java',
content: `package app; import p0.C0; public class App { public void run() {
C0 value0 = new C0();
${Array.from({ length: count - 1 }, (_, i) => `var value${i + 1} = value${i}.next${i}();`).join('\n')}
value${count - 1}.finish();
} }`,
});
if (reverse) files.reverse();
javaScopeResolver.loadResolutionConfig?.('');
const { graph, model, data } = await parseFilesWithWorkers(files);
runScopeResolution(
{
graph,
model,
files,
preExtractedParsedFiles: new Map(data.parsedFiles.map((file) => [file.filePath, file])),
},
javaScopeResolver,
);
const targets = [...graph.iterRelationshipsByType('CALLS')]
.filter((edge) => edge.sourceId === 'Method:app/App.java:App.run#0')
.map((edge) => edge.targetId);
expect(targets).toEqual(
expect.arrayContaining(
Array.from(
{ length: count },
(_, i) => `Method:p${i}/C${i}.java:C${i}.${i + 1 < count ? `next${i}` : 'finish'}#0`,
),
),
);
},
);
it.each([
{ order: 'forward', override: false },
{ order: 'reverse', override: false },
{ order: 'forward', override: true },
{ order: 'reverse', override: true },
])(
'resolves inherited return calls ($order, override=$override)',
async ({ order, override }) => {
const files = [
{
path: 'p0/Foo.java',
content: 'package p0; public class Foo { public void fooMethod() {} }',
},
{
path: 'p0/Base.java',
content:
'package p0; public class Base { public Foo baseMethod() { return new Foo(); } }',
},
{
path: 'p1/ChildFoo.java',
content:
'package p1; import p0.Foo; public class ChildFoo extends Foo { public void childOnly() {} }',
},
{
path: 'p1/C1.java',
content: `package p1; import p0.Base; public class C1 extends Base {
${override ? 'public ChildFoo baseMethod() { return new ChildFoo(); }' : ''}
}`,
},
{
path: 'p2/App.java',
content: `package p2; import p1.C1; public class App { public void run() {
C1 c = new C1(); var r = c.baseMethod(); r.${override ? 'childOnly' : 'fooMethod'}();
} }`,
},
];
if (order === 'reverse') files.reverse();
javaScopeResolver.loadResolutionConfig?.('');
const { graph, model, data } = await parseFilesWithWorkers(files);
runScopeResolution(
{
graph,
model,
files,
preExtractedParsedFiles: new Map(data.parsedFiles.map((file) => [file.filePath, file])),
},
javaScopeResolver,
);
const target = override
? 'Method:p1/ChildFoo.java:ChildFoo.childOnly#0'
: 'Method:p0/Foo.java:Foo.fooMethod#0';
expect([...graph.iterRelationshipsByType('CALLS')]).toEqual(
expect.arrayContaining([
expect.objectContaining({ sourceId: 'Method:p2/App.java:App.run#0', targetId: target }),
]),
);
},
);
});

View file

@ -18,15 +18,16 @@
* 4. **Cyclic SCC partial fixpoint** — the pass does not throw and
* makes best-effort progress.
*
* The tests use the real TypeScript scope-resolver to keep them
* The TypeScript fixtures use the real scope-resolver to keep them
* close to production behavior — synthetic ParsedFiles would have
* to fabricate `Scope.typeBindings` correctly, defeating the
* purpose. Each fixture is small (4 files max) and runs in <100ms.
* purpose. Java fixtures also cover import-chain storage and traversal order.
*/
import { describe, it, expect } from 'vitest';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import { javaScopeResolver } from '../../../src/core/ingestion/languages/java/scope-resolver.js';
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js';
import { propagateImportedReturnTypes } from '../../../src/core/ingestion/scope-resolution/passes/imported-return-types.js';
@ -46,6 +47,73 @@ interface InMemoryFile {
readonly content: string;
}
function runJavaPropagation(files: readonly InMemoryFile[]): ParsedFile[] {
javaScopeResolver.loadResolutionConfig?.('');
const parsedFiles = files.map((file) => {
const parsed = extractParsedFile(javaScopeResolver.languageProvider, file.content, file.path);
if (parsed === undefined) throw new Error(`scope extraction failed for ${file.path}`);
javaScopeResolver.populateOwners(parsed);
return parsed;
});
const paths = new Set(files.map((file) => file.path));
const indexes = finalizeScopeModel(parsedFiles, {
hooks: {
resolveImportTarget: (target, fromFile) =>
javaScopeResolver.resolveImportTarget(target, fromFile, paths, undefined, { parsedFiles }),
mergeBindings: javaScopeResolver.mergeBindings,
},
});
propagateImportedReturnTypes(parsedFiles, indexes, buildWorkspaceResolutionIndex(parsedFiles));
const populate = javaScopeResolver.populateRangeBindings;
if (populate === undefined) throw new Error('Java return-type hook is missing');
populate(parsedFiles, indexes, { fileContents: new Map() });
return parsedFiles;
}
function moduleTypeBindings(file: ParsedFile): Scope['typeBindings'] {
const module = file.scopes.find((scope) => scope.id === file.moduleScope);
if (module === undefined) throw new Error(`no module scope for ${file.filePath}`);
return module.typeBindings;
}
describe('Java imported method return types', () => {
const chain = (count: number): InMemoryFile[] =>
Array.from({ length: count }, (_, i) => ({
path: `p${i}/C${i}.java`,
content: `package p${i};
${i > 0 ? `import p${i - 1}.C${i - 1};` : ''}
public class C${i} { public C${i} create${i}() { return this; } }`,
}));
it('keeps return-type storage proportional to direct imports along a long import chain', () => {
const files = runJavaPropagation(chain(64));
let bindingCount = 0;
for (const [i, file] of files.entries()) {
const types = moduleTypeBindings(file);
expect(types.get(`create${i}`)?.rawName).toBe(`C${i}`);
if (i > 0) expect(types.get(`create${i - 1}`)?.rawName).toBe(`C${i - 1}`);
bindingCount += types.size;
}
// Re-copying previously imported methods makes this n * (n + 1) / 2.
expect(bindingCount).toBe(2 * files.length - 1);
});
it('does not change imported return-type visibility with file traversal order', () => {
const input = chain(4);
const forward = runJavaPropagation(input);
const reversed = runJavaPropagation([...input].reverse());
const bindings = (files: ParsedFile[]) =>
files
.map((file) => ({
path: file.filePath,
names: [...moduleTypeBindings(file).keys()].sort(),
}))
.sort((a, b) => a.path.localeCompare(b.path));
expect(bindings(forward)).toEqual(bindings(reversed));
expect(moduleTypeBinding(forward, 'p3/C3.java', 'create0')).toBeUndefined();
});
});
function parseAll(files: readonly InMemoryFile[]): ParsedFile[] {
const parsed: ParsedFile[] = [];
for (const f of files) {