diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index f79b674ee..266097f20 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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: diff --git a/eval/tests/test_workflow_bench.py b/eval/tests/test_workflow_bench.py index 2c5ec7899..2eff2f2b9 100644 --- a/eval/tests/test_workflow_bench.py +++ b/eval/tests/test_workflow_bench.py @@ -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 diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index 1106d8619..2ee32c3a7 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -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 | 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, - reexportClosures: ReadonlyMap, + getReexportClosures: () => ReadonlyMap, 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, hooks: FinalizeHooks, workspace: WorkspaceIndex, - reexportClosures: ReadonlyMap, + getReexportClosures: () => ReadonlyMap, ): 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, diff --git a/gitnexus/bench/scope-resolution/README.md b/gitnexus/bench/scope-resolution/README.md new file mode 100644 index 000000000..7f853847a --- /dev/null +++ b/gitnexus/bench/scope-resolution/README.md @@ -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. diff --git a/gitnexus/bench/scope-resolution/baselines.json b/gitnexus/bench/scope-resolution/baselines.json new file mode 100644 index 000000000..5d13bfa00 --- /dev/null +++ b/gitnexus/bench/scope-resolution/baselines.json @@ -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 + } +} diff --git a/gitnexus/bench/scope-resolution/corpus.mts b/gitnexus/bench/scope-resolution/corpus.mts new file mode 100644 index 000000000..5f38eb3ec --- /dev/null +++ b/gitnexus/bench/scope-resolution/corpus.mts @@ -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: `\n`, + }), + 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 +${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: ` `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; + +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 }; +} diff --git a/gitnexus/bench/scope-resolution/measure.mts b/gitnexus/bench/scope-resolution/measure.mts new file mode 100644 index 000000000..0ff4fd9e2 --- /dev/null +++ b/gitnexus/bench/scope-resolution/measure.mts @@ -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>[] = []; + const trials: Awaited>[] = []; + 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>[] = []; + 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'); +} diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 4f6ec5ad6..612875b51 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -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', diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index fc5757843..3879206ee 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.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(); + const moduleReturnTypesByFile = new Map>(); + const returnNames = new Set(); + const importedFamiliesByFile = new Map(); + const ambiguousByFile = new Map>(); + const returnTypeIds = new Map(); + 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(); 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(); + 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; - 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(); + 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; + 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); diff --git a/gitnexus/test/integration/java-imported-return-types.test.ts b/gitnexus/test/integration/java-imported-return-types.test.ts new file mode 100644 index 000000000..111f425a9 --- /dev/null +++ b/gitnexus/test/integration/java-imported-return-types.test.ts @@ -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 }), + ]), + ); + }, + ); +}); diff --git a/gitnexus/test/unit/scope-resolution/imported-return-types.test.ts b/gitnexus/test/unit/scope-resolution/imported-return-types.test.ts index 078588f86..1a4df7c7e 100644 --- a/gitnexus/test/unit/scope-resolution/imported-return-types.test.ts +++ b/gitnexus/test/unit/scope-resolution/imported-return-types.test.ts @@ -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) {