mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-11 08:37:35 +00:00
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:
parent
d8a256a03a
commit
36227d93d1
11 changed files with 1291 additions and 37 deletions
39
.github/workflows/ci-tests.yml
vendored
39
.github/workflows/ci-tests.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
53
gitnexus/bench/scope-resolution/README.md
Normal file
53
gitnexus/bench/scope-resolution/README.md
Normal 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.
|
||||
290
gitnexus/bench/scope-resolution/baselines.json
Normal file
290
gitnexus/bench/scope-resolution/baselines.json
Normal 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
|
||||
}
|
||||
}
|
||||
219
gitnexus/bench/scope-resolution/corpus.mts
Normal file
219
gitnexus/bench/scope-resolution/corpus.mts
Normal 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 };
|
||||
}
|
||||
300
gitnexus/bench/scope-resolution/measure.mts
Normal file
300
gitnexus/bench/scope-resolution/measure.mts
Normal 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');
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
186
gitnexus/test/integration/java-imported-return-types.test.ts
Normal file
186
gitnexus/test/integration/java-imported-return-types.test.ts
Normal 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 }),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue