diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index aab78e679..7e91c8201 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -1352,6 +1352,9 @@ jobs: GITNEXUS_DEBUG_HEAP: '1' run: node --import tsx bench/scope-resolution/dense-java.mts 300 20 10 --check 2>&1 | tee scope-resolution-dense-java.log working-directory: gitnexus + - name: Check Java overload and ingestion scaling + run: node bench/java-overload-resolution/measure.js --check --output java-overload-resolution-benchmark.json + working-directory: gitnexus - name: Upload scope-resolution measurements if: always() uses: ./.github/actions/upload-test-artifact @@ -1361,6 +1364,7 @@ jobs: gitnexus/scope-resolution-benchmark.json gitnexus/scope-resolution-call-signature-memory.json gitnexus/scope-resolution-dense-java.log + gitnexus/java-overload-resolution-benchmark.json retention-days: 5 # Locked eval suite. setup-uv and uv itself are immutable so CI exercises diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4635dbff8..b0ae8a977 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -432,6 +432,56 @@ Wire format is **v2**: `2|||…`, one-character version prefix Receivers the resolver could not type are not silently dropped. Each records a `ResolutionOutcome` (`scope-resolution/resolution-outcome.ts`) carrying the receiver's *shape* (`classifyReceiverShape`: `chain-call` / `chain-field` / `chain-mixed` / `chain-unwrap` / `no-chain` — the bench censuses these) and its *origin* (`in-program` / `external` / `unknown`). `scope-resolution/unresolved-receivers.ts` aggregates them per member name into the index-persisted `unresolvedReceiverMembers` summary, keeping in-program and external counts under separate keys. Only in-program drops make a count short: an external-rooted call (`System.out.println`, `fetch(...)`) has no in-graph node an edge could have reached, so it is reported but does not hedge. `impact` / `context` read that summary and publish `epistemic: 'exact' | 'lower-bound'`, prose `boundaries`, and the machine-readable `causes` split (`EpistemicCauses` in `mcp/local/local-backend.ts`). +### Java overload selection and missing call edges (#3789) + +Java resolves an overload family through provider hooks before the shared passes +prune candidates by owner or conversion rank. The provider gathers eligible +members across the receiver's compile-time hierarchy, removes overridden +signatures, and applies the JLS 15.12.2 phases in order: strict invocation, loose +invocation, then variable arity. Within the first applicable phase it compares +parameter types pairwise for specificity. Boxing, primitive widening, arrays, +null, and concrete invariant generic arguments retain their distinct meanings. +Lexically scoped argument facts travel in Java's existing `captureSideChannel`, +so parse workers and durable cache replay make the same decisions. +Static imports use accessible overload families; an explicit import shadows only +the same signature from an on-demand import. Enum constant bodies and implicit +record accessors reuse the provider's existing synthetic identities. Field, +hierarchy, and subtype walks memoize shared ancestors while preserving cycle and +depth bounds. + +The provider models a bounded set of qualified JDK type relationships and +`Arrays.asList` / `Collections.singletonList` calls. These factories are poly +expressions: their type arguments depend on the candidate's parameter type. +Source declarations and lexical shadowing take precedence over these summaries. +Unsupported generic inference, unavailable argument types, and incomplete +inherited inventories leave an overload unresolved. A complete original family +containing only one declaration may retain the existing unique-member inference +for opaque arguments; this exception does not apply to a family narrowed to one +candidate. Runtime implementation dispatch follows the selected signature. +It preserves the original receiver's subtype bound and excludes static and private +methods from virtual dispatch. + +Captures also retain callable-owned return annotations and structural identities +for `var` initializers. The shared passes keep the selected compile-time method +separate from runtime dispatch targets, then propagate owned return types to +untyped assignments and revisit waiting receiver calls through a dependency +queue. Sequential assignments have no expression-depth cutoff. Unresolved producers +cannot restore an earlier name-based return guess. Generic implementation +signatures substitute a whole formal type parameter only when concrete source +heritage proves its binding; unsupported substitutions remain unknown. + +Suppressed overload calls record graph identities for their candidate methods, +declaring owners, and caller. `unresolved-overloads.ts` reconciles decisions per +call site and resolution phase and persists a bounded `unresolvedOverloads` +receipt, including an explicit complete-empty receipt. `impact` and `context` +report matching omitted sites through `causes.overloadResolution` and +`epistemic: 'lower-bound'`; possible callers never become ordinary `CALLS` +edges or inflate known caller counts. Impact checks the nodes whose neighbors +were actually expanded before pagination, using candidate identities upstream +and caller identities downstream. Missing, malformed, or capped receipts retain +an uncertainty boundary. Re-analyze with the current build (`--force` when +repairing metadata) to replace a legacy receipt. + ### Optional CFG/PDG emission (`--pdg`, #2081–#2086) On a `--pdg` run providers build control-flow graphs through `LanguageProvider.cfgVisitor` (Tree-sitter ASTs) or `collectSourceCfgs` (standalone source processing). Every supported language supplies one of these hooks; Vue covers executable scripts. AST visitors run in the parse worker, while source-backed providers run during fresh scope extraction. Both produce plain `ParsedFile.cfgSideChannel` data. Scope-resolution emits the program-dependence layers **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live**. The store is cleared after that phase, so a later standalone phase cannot consume it. diff --git a/gitnexus/bench/java-overload-resolution/README.md b/gitnexus/bench/java-overload-resolution/README.md new file mode 100644 index 000000000..96e5a098e --- /dev/null +++ b/gitnexus/bench/java-overload-resolution/README.md @@ -0,0 +1,67 @@ +# Java overload and ingestion scaling + +Build the package, then measure the current implementation: + +```sh +npm run build +node bench/java-overload-resolution/measure.js --check --output /tmp/java-overload-check.json +``` + +CI runs this check in the scope-resolution benchmark job and uploads +`java-overload-resolution-benchmark.json` with that job's measurements. + +`measure.js` runs three fresh-process samples at 128, 512 and 2,048 units. Each +unit adds seven methods and four calls. Two shapes exercise increasing file +counts and increasing declarations within one class. Every overload family has +two candidates; package width and inheritance depth stay fixed. The spread +shape uses separate packages with an explicit import so package-size limits +cannot silently reduce resolution work. + +Each sample runs real worker parsing, scope resolution, graph construction and +database writes, with a cold cache, a warm cache and one edited source file. +The edit casts one argument to `Object`, changing exactly one selected overload. +The calls cover reference specificity, widening before boxing, `var` receiver +inference through a factory call and cross-file member resolution. Expected target IDs +are generated independently of the implementation. Missing methods or calls, +incorrect targets, suppressed calls, extraction failures, incorrect cache reuse +and truncated process detection all fail the run. + +The harness uses one parser worker, private temporary storage, a 128 MiB database +buffer pool, and no FTS or embeddings. It raises the file-size and process limits +to include the entire generated corpus. Each scenario forces graph reconstruction +and persistence; warm and changed scenarios reuse parse-cache chunks. Editing one +file can reparse other files in its cache chunk. This measures full graph writes, +not the separate selective database-update path. + +The report includes raw samples, median wall time and process CPU time for the +whole analysis and the scope-resolution phase, main-thread heap at completion, +and process peak RSS (including workers/native memory). Peak RSS is cumulative +across the three scenarios in each child. Heap is sampled before an explicit +post-analysis collection. The built JavaScript digest identifies the measured +runtime and detects changes to it during the run. + +`--check` verifies the committed corpus, call-target and cache fingerprints, then +gates every adjacent size pair and the full span. For every time and memory +metric, `(large cost / small cost) / (large size / small size)` must be at most +**1.5**. The shared gate and its quadratic-growth tests come from +`../method-declaration-facts/scaling.mjs`. Absolute baseline timings are reference +measurements, not machine-independent thresholds. + +These are bounded scaling checks, not a proof of linear complexity for arbitrary +Java programs. In particular, this corpus does not increase overload-family +width, inheritance depth or generic constraint complexity. Startup and database +costs can hide growth at small sizes; the separate scope measurements and +deterministic lookup-work regression tests help expose it. + +After reviewing an intentional workload or semantic change, record a baseline: + +```sh +node bench/java-overload-resolution/measure.js --record --output /tmp/java-overload-record.json +``` + +Recording also requires all scaling and correctness checks to pass, uses at least +three samples, and is disabled in CI. `--root /path/to/built/gitnexus` selects an +isolated runtime. `--sizes 128,512,2048 --samples 1` is useful for diagnostic runs; +one-sample runs cannot record or check a baseline. Completed samples are saved +after each child so a later failure leaves usable diagnostic evidence. +The output path must not already exist; existing files and symlinks are rejected. diff --git a/gitnexus/bench/java-overload-resolution/baseline.json b/gitnexus/bench/java-overload-resolution/baseline.json new file mode 100644 index 000000000..354fc0795 --- /dev/null +++ b/gitnexus/bench/java-overload-resolution/baseline.json @@ -0,0 +1,926 @@ +{ + "_note": "Measured baseline. --check gates same-run normalized growth at 1.5 and exact call targets/cache counts. Absolute timings and memory are references, not portable budgets.", + "schemaVersion": 1, + "recordedAt": "2026-10-31T19:46:00.972Z", + "runtimeSha256": "4b3e97e7ebb9f21e6aa4048f584fe10fd5ac049d5a4b60488947f45b01fc2057", + "workload": { + "sizes": [128, 512, 2048], + "shapes": ["spread", "concentrated"], + "scenarios": ["cold", "warm", "changed"], + "overloadsPerFamily": 2, + "callsPerUnit": 4, + "workers": 1 + }, + "environment": { + "node": "v22.23.3", + "platform": "linux", + "arch": "x64", + "cpu": "12th Gen Intel(R) Core(TM) i9-12900K" + }, + "correctness": [ + { + "shape": "spread", + "size": 128, + "scenario": "cold", + "corpus": "ba00fdce998f6c93ded7b9e48249deb2195f435cd9f498d2585c028a4745b18c", + "correctness": { + "files": 129, + "methods": 898, + "calls": 512, + "fingerprint": "9c7584e6908a263beed06089876413d7d73a0d787d1eff7681dde0dd26f7b672", + "reparsed": 129, + "hits": 0 + } + }, + { + "shape": "spread", + "size": 128, + "scenario": "warm", + "corpus": "ba00fdce998f6c93ded7b9e48249deb2195f435cd9f498d2585c028a4745b18c", + "correctness": { + "files": 129, + "methods": 898, + "calls": 512, + "fingerprint": "9c7584e6908a263beed06089876413d7d73a0d787d1eff7681dde0dd26f7b672", + "reparsed": 0, + "hits": 129 + } + }, + { + "shape": "spread", + "size": 128, + "scenario": "changed", + "corpus": "ba00fdce998f6c93ded7b9e48249deb2195f435cd9f498d2585c028a4745b18c", + "correctness": { + "files": 129, + "methods": 898, + "calls": 512, + "fingerprint": "def7d18a8b462dcf231bd2192afebe764911163d3ffc65e75fe5fefeb7e3a54c", + "reparsed": 2, + "hits": 127 + } + }, + { + "shape": "spread", + "size": 512, + "scenario": "cold", + "corpus": "dd070e75fcba816f5775dff5efbfdd3fdda28e4b01d0c1603fd9e294587896c7", + "correctness": { + "files": 513, + "methods": 3586, + "calls": 2048, + "fingerprint": "f126c9b94d15f6c85f55e802db43430abbb378a6e82a78492b3d6e8fcc18a69b", + "reparsed": 513, + "hits": 0 + } + }, + { + "shape": "spread", + "size": 512, + "scenario": "warm", + "corpus": "dd070e75fcba816f5775dff5efbfdd3fdda28e4b01d0c1603fd9e294587896c7", + "correctness": { + "files": 513, + "methods": 3586, + "calls": 2048, + "fingerprint": "f126c9b94d15f6c85f55e802db43430abbb378a6e82a78492b3d6e8fcc18a69b", + "reparsed": 0, + "hits": 513 + } + }, + { + "shape": "spread", + "size": 512, + "scenario": "changed", + "corpus": "dd070e75fcba816f5775dff5efbfdd3fdda28e4b01d0c1603fd9e294587896c7", + "correctness": { + "files": 513, + "methods": 3586, + "calls": 2048, + "fingerprint": "360cdbc01738746de63cca79adba7410797749c171d3e324579a7a53deee56d0", + "reparsed": 5, + "hits": 508 + } + }, + { + "shape": "spread", + "size": 2048, + "scenario": "cold", + "corpus": "5566903502880e054a6e2779f37d4c71da7333cfef65fb51e60d7ce3d03f8c77", + "correctness": { + "files": 2049, + "methods": 14338, + "calls": 8192, + "fingerprint": "9c07a075de05a15a73b0649a9fb45b730e6a6bf68d5152a315e5fae4fd1365a7", + "reparsed": 2049, + "hits": 0 + } + }, + { + "shape": "spread", + "size": 2048, + "scenario": "warm", + "corpus": "5566903502880e054a6e2779f37d4c71da7333cfef65fb51e60d7ce3d03f8c77", + "correctness": { + "files": 2049, + "methods": 14338, + "calls": 8192, + "fingerprint": "9c07a075de05a15a73b0649a9fb45b730e6a6bf68d5152a315e5fae4fd1365a7", + "reparsed": 0, + "hits": 2049 + } + }, + { + "shape": "spread", + "size": 2048, + "scenario": "changed", + "corpus": "5566903502880e054a6e2779f37d4c71da7333cfef65fb51e60d7ce3d03f8c77", + "correctness": { + "files": 2049, + "methods": 14338, + "calls": 8192, + "fingerprint": "8b2269dfa3d017f04e05f86afd8f6cac6364c1228acab0507de5287feb249969", + "reparsed": 15, + "hits": 2034 + } + }, + { + "shape": "concentrated", + "size": 128, + "scenario": "cold", + "corpus": "4043c0f69f54bcb6409bfeb70b974c2aa82b373d28f92f294a60c8199a8272fb", + "correctness": { + "files": 2, + "methods": 898, + "calls": 512, + "fingerprint": "f2f037938ed3b85bfac7ea91c8460bcef0bea3b73c38b84630e2ac36d8185b5d", + "reparsed": 2, + "hits": 0 + } + }, + { + "shape": "concentrated", + "size": 128, + "scenario": "warm", + "corpus": "4043c0f69f54bcb6409bfeb70b974c2aa82b373d28f92f294a60c8199a8272fb", + "correctness": { + "files": 2, + "methods": 898, + "calls": 512, + "fingerprint": "f2f037938ed3b85bfac7ea91c8460bcef0bea3b73c38b84630e2ac36d8185b5d", + "reparsed": 0, + "hits": 2 + } + }, + { + "shape": "concentrated", + "size": 128, + "scenario": "changed", + "corpus": "4043c0f69f54bcb6409bfeb70b974c2aa82b373d28f92f294a60c8199a8272fb", + "correctness": { + "files": 2, + "methods": 898, + "calls": 512, + "fingerprint": "5e4d7c334a4a1f6de8b594ae62d0792bd5895b828893103af8bbc4789f1f11fb", + "reparsed": 1, + "hits": 1 + } + }, + { + "shape": "concentrated", + "size": 512, + "scenario": "cold", + "corpus": "d6c830c77f6515d7e9157bc48d96a2bfa3ec3b7c81e315c61738ae5a5079a579", + "correctness": { + "files": 2, + "methods": 3586, + "calls": 2048, + "fingerprint": "e1e74e9da813db36ccea611bf2374dbcc079b4dc29466d5da127879d08cdaf00", + "reparsed": 2, + "hits": 0 + } + }, + { + "shape": "concentrated", + "size": 512, + "scenario": "warm", + "corpus": "d6c830c77f6515d7e9157bc48d96a2bfa3ec3b7c81e315c61738ae5a5079a579", + "correctness": { + "files": 2, + "methods": 3586, + "calls": 2048, + "fingerprint": "e1e74e9da813db36ccea611bf2374dbcc079b4dc29466d5da127879d08cdaf00", + "reparsed": 0, + "hits": 2 + } + }, + { + "shape": "concentrated", + "size": 512, + "scenario": "changed", + "corpus": "d6c830c77f6515d7e9157bc48d96a2bfa3ec3b7c81e315c61738ae5a5079a579", + "correctness": { + "files": 2, + "methods": 3586, + "calls": 2048, + "fingerprint": "c9beb4f12867432b2e53a6e52d337b39dfc0b05e1a18939575579157b425d29f", + "reparsed": 1, + "hits": 1 + } + }, + { + "shape": "concentrated", + "size": 2048, + "scenario": "cold", + "corpus": "b0698581ac113f8b46b4cc13cc1a8744b37aa41945e40245423eef383ca296b3", + "correctness": { + "files": 2, + "methods": 14338, + "calls": 8192, + "fingerprint": "7f349aefb5d96c2d46cda31484a7d696e26c4e55253336625d001462799b0de6", + "reparsed": 2, + "hits": 0 + } + }, + { + "shape": "concentrated", + "size": 2048, + "scenario": "warm", + "corpus": "b0698581ac113f8b46b4cc13cc1a8744b37aa41945e40245423eef383ca296b3", + "correctness": { + "files": 2, + "methods": 14338, + "calls": 8192, + "fingerprint": "7f349aefb5d96c2d46cda31484a7d696e26c4e55253336625d001462799b0de6", + "reparsed": 0, + "hits": 2 + } + }, + { + "shape": "concentrated", + "size": 2048, + "scenario": "changed", + "corpus": "b0698581ac113f8b46b4cc13cc1a8744b37aa41945e40245423eef383ca296b3", + "correctness": { + "files": 2, + "methods": 14338, + "calls": 8192, + "fingerprint": "1786baf149b46e933b59c54f2db3f6b3e1cb4d6e356497029546cd1afbe738fb", + "reparsed": 1, + "hits": 1 + } + } + ], + "measured": [ + { + "shape": "spread", + "size": 128, + "scenario": "cold", + "corpus": "ba00fdce998f6c93ded7b9e48249deb2195f435cd9f498d2585c028a4745b18c", + "correctness": { + "files": 129, + "methods": 898, + "calls": 512, + "fingerprint": "9c7584e6908a263beed06089876413d7d73a0d787d1eff7681dde0dd26f7b672", + "reparsed": 129, + "hits": 0 + }, + "wallMs": 6382.313993999999, + "cpuMs": 6573.025, + "scopeWallMs": 265.60164499999973, + "scopeCpuMs": 422.705, + "heapBytes": 72082216, + "peakRssBytes": 563265536 + }, + { + "shape": "spread", + "size": 128, + "scenario": "warm", + "corpus": "ba00fdce998f6c93ded7b9e48249deb2195f435cd9f498d2585c028a4745b18c", + "correctness": { + "files": 129, + "methods": 898, + "calls": 512, + "fingerprint": "9c7584e6908a263beed06089876413d7d73a0d787d1eff7681dde0dd26f7b672", + "reparsed": 0, + "hits": 129 + }, + "wallMs": 2412.3159590000005, + "cpuMs": 2055.392, + "scopeWallMs": 236.99802499999987, + "scopeCpuMs": 423.928, + "heapBytes": 106224584, + "peakRssBytes": 693342208 + }, + { + "shape": "spread", + "size": 128, + "scenario": "changed", + "corpus": "ba00fdce998f6c93ded7b9e48249deb2195f435cd9f498d2585c028a4745b18c", + "correctness": { + "files": 129, + "methods": 898, + "calls": 512, + "fingerprint": "def7d18a8b462dcf231bd2192afebe764911163d3ffc65e75fe5fefeb7e3a54c", + "reparsed": 2, + "hits": 127 + }, + "wallMs": 3324.9829769999997, + "cpuMs": 2986.027, + "scopeWallMs": 279.4751209999995, + "scopeCpuMs": 570.481, + "heapBytes": 74642032, + "peakRssBytes": 747954176 + }, + { + "shape": "spread", + "size": 512, + "scenario": "cold", + "corpus": "dd070e75fcba816f5775dff5efbfdd3fdda28e4b01d0c1603fd9e294587896c7", + "correctness": { + "files": 513, + "methods": 3586, + "calls": 2048, + "fingerprint": "f126c9b94d15f6c85f55e802db43430abbb378a6e82a78492b3d6e8fcc18a69b", + "reparsed": 513, + "hits": 0 + }, + "wallMs": 9657.072656, + "cpuMs": 11590.296, + "scopeWallMs": 787.7755969999998, + "scopeCpuMs": 1342.358, + "heapBytes": 83321312, + "peakRssBytes": 641908736 + }, + { + "shape": "spread", + "size": 512, + "scenario": "warm", + "corpus": "dd070e75fcba816f5775dff5efbfdd3fdda28e4b01d0c1603fd9e294587896c7", + "correctness": { + "files": 513, + "methods": 3586, + "calls": 2048, + "fingerprint": "f126c9b94d15f6c85f55e802db43430abbb378a6e82a78492b3d6e8fcc18a69b", + "reparsed": 0, + "hits": 513 + }, + "wallMs": 4034.202557999999, + "cpuMs": 4545.678, + "scopeWallMs": 876.6461720000007, + "scopeCpuMs": 1818.994, + "heapBytes": 140587192, + "peakRssBytes": 788180992 + }, + { + "shape": "spread", + "size": 512, + "scenario": "changed", + "corpus": "dd070e75fcba816f5775dff5efbfdd3fdda28e4b01d0c1603fd9e294587896c7", + "correctness": { + "files": 513, + "methods": 3586, + "calls": 2048, + "fingerprint": "360cdbc01738746de63cca79adba7410797749c171d3e324579a7a53deee56d0", + "reparsed": 5, + "hits": 508 + }, + "wallMs": 4542.365702000001, + "cpuMs": 4986.714, + "scopeWallMs": 745.671574, + "scopeCpuMs": 1462.018, + "heapBytes": 141094984, + "peakRssBytes": 897118208 + }, + { + "shape": "spread", + "size": 2048, + "scenario": "cold", + "corpus": "5566903502880e054a6e2779f37d4c71da7333cfef65fb51e60d7ce3d03f8c77", + "correctness": { + "files": 2049, + "methods": 14338, + "calls": 8192, + "fingerprint": "9c07a075de05a15a73b0649a9fb45b730e6a6bf68d5152a315e5fae4fd1365a7", + "reparsed": 2049, + "hits": 0 + }, + "wallMs": 22421.167622, + "cpuMs": 28309.111, + "scopeWallMs": 2729.000151, + "scopeCpuMs": 4387.326, + "heapBytes": 110154224, + "peakRssBytes": 760012800 + }, + { + "shape": "spread", + "size": 2048, + "scenario": "warm", + "corpus": "5566903502880e054a6e2779f37d4c71da7333cfef65fb51e60d7ce3d03f8c77", + "correctness": { + "files": 2049, + "methods": 14338, + "calls": 8192, + "fingerprint": "9c07a075de05a15a73b0649a9fb45b730e6a6bf68d5152a315e5fae4fd1365a7", + "reparsed": 0, + "hits": 2049 + }, + "wallMs": 7275.022228999998, + "cpuMs": 9205.167, + "scopeWallMs": 2452.0143179999977, + "scopeCpuMs": 4076.376, + "heapBytes": 216411400, + "peakRssBytes": 931454976 + }, + { + "shape": "spread", + "size": 2048, + "scenario": "changed", + "corpus": "5566903502880e054a6e2779f37d4c71da7333cfef65fb51e60d7ce3d03f8c77", + "correctness": { + "files": 2049, + "methods": 14338, + "calls": 8192, + "fingerprint": "8b2269dfa3d017f04e05f86afd8f6cac6364c1228acab0507de5287feb249969", + "reparsed": 15, + "hits": 2034 + }, + "wallMs": 8042.547480000001, + "cpuMs": 10680.432, + "scopeWallMs": 2782.9499240000005, + "scopeCpuMs": 4797.166, + "heapBytes": 221611264, + "peakRssBytes": 1018769408 + }, + { + "shape": "concentrated", + "size": 128, + "scenario": "cold", + "corpus": "4043c0f69f54bcb6409bfeb70b974c2aa82b373d28f92f294a60c8199a8272fb", + "correctness": { + "files": 2, + "methods": 898, + "calls": 512, + "fingerprint": "f2f037938ed3b85bfac7ea91c8460bcef0bea3b73c38b84630e2ac36d8185b5d", + "reparsed": 2, + "hits": 0 + }, + "wallMs": 5724.462823000001, + "cpuMs": 5990.56, + "scopeWallMs": 216.9987230000006, + "scopeCpuMs": 359.581, + "heapBytes": 66736864, + "peakRssBytes": 549969920 + }, + { + "shape": "concentrated", + "size": 128, + "scenario": "warm", + "corpus": "4043c0f69f54bcb6409bfeb70b974c2aa82b373d28f92f294a60c8199a8272fb", + "correctness": { + "files": 2, + "methods": 898, + "calls": 512, + "fingerprint": "f2f037938ed3b85bfac7ea91c8460bcef0bea3b73c38b84630e2ac36d8185b5d", + "reparsed": 0, + "hits": 2 + }, + "wallMs": 2307.437070000001, + "cpuMs": 1697.642, + "scopeWallMs": 165.59543699999995, + "scopeCpuMs": 366.701, + "heapBytes": 89981272, + "peakRssBytes": 673837056 + }, + { + "shape": "concentrated", + "size": 128, + "scenario": "changed", + "corpus": "4043c0f69f54bcb6409bfeb70b974c2aa82b373d28f92f294a60c8199a8272fb", + "correctness": { + "files": 2, + "methods": 898, + "calls": 512, + "fingerprint": "5e4d7c334a4a1f6de8b594ae62d0792bd5895b828893103af8bbc4789f1f11fb", + "reparsed": 1, + "hits": 1 + }, + "wallMs": 3410.6907119999996, + "cpuMs": 3338.962, + "scopeWallMs": 166.35203, + "scopeCpuMs": 366.192, + "heapBytes": 69753416, + "peakRssBytes": 766402560 + }, + { + "shape": "concentrated", + "size": 512, + "scenario": "cold", + "corpus": "d6c830c77f6515d7e9157bc48d96a2bfa3ec3b7c81e315c61738ae5a5079a579", + "correctness": { + "files": 2, + "methods": 3586, + "calls": 2048, + "fingerprint": "e1e74e9da813db36ccea611bf2374dbcc079b4dc29466d5da127879d08cdaf00", + "reparsed": 2, + "hits": 0 + }, + "wallMs": 9268.474702000001, + "cpuMs": 10475.483, + "scopeWallMs": 625.0715480000008, + "scopeCpuMs": 1136.577, + "heapBytes": 116100256, + "peakRssBytes": 584364032 + }, + { + "shape": "concentrated", + "size": 512, + "scenario": "warm", + "corpus": "d6c830c77f6515d7e9157bc48d96a2bfa3ec3b7c81e315c61738ae5a5079a579", + "correctness": { + "files": 2, + "methods": 3586, + "calls": 2048, + "fingerprint": "e1e74e9da813db36ccea611bf2374dbcc079b4dc29466d5da127879d08cdaf00", + "reparsed": 0, + "hits": 2 + }, + "wallMs": 2986.604102000001, + "cpuMs": 3221.225, + "scopeWallMs": 582.4847769999997, + "scopeCpuMs": 1302.585, + "heapBytes": 115896680, + "peakRssBytes": 776753152 + }, + { + "shape": "concentrated", + "size": 512, + "scenario": "changed", + "corpus": "d6c830c77f6515d7e9157bc48d96a2bfa3ec3b7c81e315c61738ae5a5079a579", + "correctness": { + "files": 2, + "methods": 3586, + "calls": 2048, + "fingerprint": "c9beb4f12867432b2e53a6e52d337b39dfc0b05e1a18939575579157b425d29f", + "reparsed": 1, + "hits": 1 + }, + "wallMs": 6360.436007999999, + "cpuMs": 7743.869, + "scopeWallMs": 510.34782500000074, + "scopeCpuMs": 1284.846, + "heapBytes": 123689144, + "peakRssBytes": 958959616 + }, + { + "shape": "concentrated", + "size": 2048, + "scenario": "cold", + "corpus": "b0698581ac113f8b46b4cc13cc1a8744b37aa41945e40245423eef383ca296b3", + "correctness": { + "files": 2, + "methods": 14338, + "calls": 8192, + "fingerprint": "7f349aefb5d96c2d46cda31484a7d696e26c4e55253336625d001462799b0de6", + "reparsed": 2, + "hits": 0 + }, + "wallMs": 21677.11593, + "cpuMs": 25262.452, + "scopeWallMs": 2939.4483809999983, + "scopeCpuMs": 4160.173, + "heapBytes": 97430168, + "peakRssBytes": 883941376 + }, + { + "shape": "concentrated", + "size": 2048, + "scenario": "warm", + "corpus": "b0698581ac113f8b46b4cc13cc1a8744b37aa41945e40245423eef383ca296b3", + "correctness": { + "files": 2, + "methods": 14338, + "calls": 8192, + "fingerprint": "7f349aefb5d96c2d46cda31484a7d696e26c4e55253336625d001462799b0de6", + "reparsed": 0, + "hits": 2 + }, + "wallMs": 4958.608856999999, + "cpuMs": 6291.745, + "scopeWallMs": 2304.6741980000006, + "scopeCpuMs": 3572.083, + "heapBytes": 123585896, + "peakRssBytes": 1016422400 + }, + { + "shape": "concentrated", + "size": 2048, + "scenario": "changed", + "corpus": "b0698581ac113f8b46b4cc13cc1a8744b37aa41945e40245423eef383ca296b3", + "correctness": { + "files": 2, + "methods": 14338, + "calls": 8192, + "fingerprint": "1786baf149b46e933b59c54f2db3f6b3e1cb4d6e356497029546cd1afbe738fb", + "reparsed": 1, + "hits": 1 + }, + "wallMs": 18368.502542000002, + "cpuMs": 22049.709, + "scopeWallMs": 2615.8797609999965, + "scopeCpuMs": 4184.537, + "heapBytes": 126797336, + "peakRssBytes": 1626734592 + } + ], + "scaling": { + "spread/cold": { + "budget": 1.5, + "comparisons": [ + { + "from": 128, + "to": 512, + "inputRatio": 4, + "ratios": { + "wallMs": 0.37827473958029156, + "cpuMs": 0.44082808143891133, + "scopeWallMs": 0.7415010522619322, + "scopeCpuMs": 0.7939094640470304, + "heapBytes": 0.2889801279139365, + "peakRssBytes": 0.28490502923296196 + }, + "passed": true + }, + { + "from": 512, + "to": 2048, + "inputRatio": 4, + "ratios": { + "wallMs": 0.5804338545612368, + "cpuMs": 0.6106209668847111, + "scopeWallMs": 0.8660461689193454, + "scopeCpuMs": 0.8170931301485893, + "heapBytes": 0.33051035010106417, + "peakRssBytes": 0.29599721789734296 + }, + "passed": true + }, + { + "from": 128, + "to": 2048, + "inputRatio": 16, + "ratios": { + "wallMs": 0.21956346517773664, + "cpuMs": 0.2691788693181602, + "scopeWallMs": 0.6421741455611097, + "scopeCpuMs": 0.648697969032777, + "heapBytes": 0.09551092324908546, + "peakRssBytes": 0.08433109601791791 + }, + "passed": true + } + ], + "passed": true + }, + "spread/warm": { + "budget": 1.5, + "comparisons": [ + { + "from": 128, + "to": 512, + "inputRatio": 4, + "ratios": { + "wallMs": 0.41808397268079406, + "cpuMs": 0.5528967223770453, + "scopeWallMs": 0.9247399551114415, + "scopeCpuMs": 1.0727022041478742, + "heapBytes": 0.33087254076702244, + "peakRssBytes": 0.284196239211215 + }, + "passed": true + }, + { + "from": 512, + "to": 2048, + "inputRatio": 4, + "ratios": { + "wallMs": 0.45083397055592267, + "cpuMs": 0.506259297292945, + "scopeWallMs": 0.6992599740685332, + "scopeCpuMs": 0.5602514356836802, + "heapBytes": 0.38483484327647716, + "peakRssBytes": 0.29544450622833596 + }, + "passed": true + }, + { + "from": 128, + "to": 2048, + "inputRatio": 16, + "ratios": { + "wallMs": 0.1884864574294763, + "cpuMs": 0.27990910614617553, + "scopeWallMs": 0.6466336370313632, + "scopeCpuMs": 0.6009829499348947, + "heapBytes": 0.12733128237056687, + "peakRssBytes": 0.08396421756570746 + }, + "passed": true + } + ], + "passed": true + }, + "spread/changed": { + "budget": 1.5, + "comparisons": [ + { + "from": 128, + "to": 512, + "inputRatio": 4, + "ratios": { + "wallMs": 0.3415330043357393, + "cpuMs": 0.4175040949060407, + "scopeWallMs": 0.6670285814099364, + "scopeCpuMs": 0.6406953079944818, + "heapBytes": 0.47257215612779674, + "peakRssBytes": 0.29985734313220813 + }, + "passed": true + }, + { + "from": 512, + "to": 2048, + "inputRatio": 4, + "ratios": { + "wallMs": 0.44264090606238904, + "cpuMs": 0.5354443828140134, + "scopeWallMs": 0.9330347370865449, + "scopeCpuMs": 0.8202987240923163, + "heapBytes": 0.3926632572565443, + "peakRssBytes": 0.28390054925738395 + }, + "passed": true + }, + { + "from": 128, + "to": 2048, + "inputRatio": 16, + "ratios": { + "wallMs": 0.15117647848938148, + "cpuMs": 0.22355022241928824, + "scopeWallMs": 0.6223608370850311, + "scopeCpuMs": 0.525561543679807, + "heapBytes": 0.18556172211388886, + "peakRssBytes": 0.08512966441409373 + }, + "passed": true + } + ], + "passed": true + }, + "concentrated/cold": { + "budget": 1.5, + "comparisons": [ + { + "from": 128, + "to": 512, + "inputRatio": 4, + "ratios": { + "wallMs": 0.4047748665936266, + "cpuMs": 0.43716626659277263, + "scopeWallMs": 0.7201327493526298, + "scopeCpuMs": 0.7902092991565183, + "heapBytes": 0.43491800873352393, + "peakRssBytes": 0.26563454234006106 + }, + "passed": true + }, + { + "from": 512, + "to": 2048, + "inputRatio": 4, + "ratios": { + "wallMs": 0.5847001968220941, + "cpuMs": 0.6028946827559168, + "scopeWallMs": 1.1756447683489806, + "scopeCpuMs": 0.9150662471614329, + "heapBytes": 0.20979748744051005, + "peakRssBytes": 0.37816383606580356 + }, + "passed": true + }, + { + "from": 128, + "to": 2048, + "inputRatio": 16, + "ratios": { + "wallMs": 0.23667194416593032, + "cpuMs": 0.2635652176090382, + "scopeWallMs": 0.8466202992931869, + "scopeCpuMs": 0.7230938578512212, + "heapBytes": 0.09124470547492312, + "peakRssBytes": 0.10045337752290162 + }, + "passed": true + } + ], + "passed": true + }, + "concentrated/warm": { + "budget": 1.5, + "comparisons": [ + { + "from": 128, + "to": 512, + "inputRatio": 4, + "ratios": { + "wallMs": 0.3235845671405461, + "cpuMs": 0.4743675344978505, + "scopeWallMs": 0.87937926846378, + "scopeCpuMs": 0.8880429832479322, + "heapBytes": 0.3220022273079225, + "peakRssBytes": 0.2881828570733872 + }, + "passed": true + }, + { + "from": 512, + "to": 2048, + "inputRatio": 4, + "ratios": { + "wallMs": 0.4150708202067551, + "cpuMs": 0.4883037509022189, + "scopeWallMs": 0.9891564076017054, + "scopeCpuMs": 0.6855757973567944, + "heapBytes": 0.2665863595057253, + "peakRssBytes": 0.32713816396589274 + }, + "passed": true + }, + { + "from": 128, + "to": 2048, + "inputRatio": 16, + "ratios": { + "wallMs": 0.1343105116892743, + "cpuMs": 0.23163544640153813, + "scopeWallMs": 0.8698436381130483, + "scopeCpuMs": 0.6088207763273075, + "heapBytes": 0.08584140153075409, + "peakRssBytes": 0.09427561074943316 + }, + "passed": true + } + ], + "passed": true + }, + "concentrated/changed": { + "budget": 1.5, + "comparisons": [ + { + "from": 128, + "to": 512, + "inputRatio": 4, + "ratios": { + "wallMs": 0.4662131914821363, + "cpuMs": 0.579811105966465, + "scopeWallMs": 0.766969638122241, + "scopeCpuMs": 0.8771668960545288, + "heapBytes": 0.4433085542362542, + "peakRssBytes": 0.3128119822564267 + }, + "passed": true + }, + { + "from": 512, + "to": 2048, + "inputRatio": 4, + "ratios": { + "wallMs": 0.7219828372967103, + "cpuMs": 0.7118440730337767, + "scopeWallMs": 1.281420059446708, + "scopeCpuMs": 0.8142098352643041, + "heapBytes": 0.25628226515982677, + "peakRssBytes": 0.42408839873398796 + }, + "passed": true + }, + { + "from": 128, + "to": 2048, + "inputRatio": 16, + "ratios": { + "wallMs": 0.33659792277142725, + "cpuMs": 0.4127350992613872, + "scopeWallMs": 0.9828102792764222, + "scopeCpuMs": 0.7141979139358589, + "heapBytes": 0.11361212044439516, + "peakRssBytes": 0.13265993265993267 + }, + "passed": true + } + ], + "passed": true + } + } +} diff --git a/gitnexus/bench/java-overload-resolution/measure.js b/gitnexus/bench/java-overload-resolution/measure.js new file mode 100644 index 000000000..a954c84d2 --- /dev/null +++ b/gitnexus/bench/java-overload-resolution/measure.js @@ -0,0 +1,382 @@ +#!/usr/bin/env node +/** Build first. Each sample measures real worker parsing, resolution and DB writes. */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { evaluateScaling } from '../method-declaration-facts/scaling.mjs'; + +const script = fileURLToPath(import.meta.url); +const baselinePath = new URL('./baseline.json', import.meta.url); +const { values } = parseArgs({ + options: { + check: { type: 'boolean' }, + record: { type: 'boolean' }, + root: { type: 'string', default: path.resolve(path.dirname(script), '../..') }, + sizes: { type: 'string', default: '128,512,2048' }, + samples: { type: 'string', default: '3' }, + output: { type: 'string' }, + child: { type: 'string' }, + size: { type: 'string' }, + }, +}); +const root = path.resolve(values.root); +const load = (name) => import(pathToFileURL(path.join(root, 'dist', name))); +const hash = (rows) => createHash('sha256').update(JSON.stringify(rows)).digest('hex'); +const marker = 'JAVA_OVERLOAD_BENCH='; +const shapes = ['spread', 'concentrated']; +const scenarios = ['cold', 'warm', 'changed']; +const metrics = ['wallMs', 'cpuMs', 'scopeWallMs', 'scopeCpuMs', 'heapBytes', 'peakRssBytes']; +const median = (numbers) => [...numbers].sort((a, b) => a - b)[Math.floor(numbers.length / 2)]; + +async function runtimeFingerprint() { + const digest = createHash('sha256'); + const dist = path.join(root, 'dist'); + for (const file of (await readdir(dist, { recursive: true })) + .filter((file) => file.endsWith('.js')) + .sort()) { + digest.update(file.replaceAll(path.sep, '/')).update('\0'); + digest.update(await readFile(path.join(dist, file))).update('\0'); + } + return digest.digest('hex'); +} + +function corpus(shape, size, changed = false) { + const files = new Map([ + [ + 'Product.java', + `package bench; +public class Product { + public void accept(Object value) {} + public void accept(String value) {} +} +`, + ], + ]); + const expected = []; + const declarations = []; + for (let i = 0; i < size; i++) { + const owner = shape === 'spread' ? `Case${i}` : 'Cases'; + const file = `${owner}.java`; + const suffix = shape === 'spread' ? '' : `_${i}`; + const body = ` + String choose${suffix}(Object value) { return "object"; } + String choose${suffix}(String value) { return "string"; } + long widen${suffix}(long value) { return value; } + long widen${suffix}(Integer value) { return 0; } + Product make${suffix}(Object value) { return null; } + Product make${suffix}(String value) { return null; } + void run${suffix}() { + choose${suffix}(${changed && i === 0 ? '(Object) ' : ''}"text"); + widen${suffix}(1); + var product = make${suffix}("text"); + product.accept("text"); + } +`; + if (shape === 'spread') + files.set( + file, + `package clients.p${i}; import bench.Product; public class ${owner} {${body}}\n`, + ); + else declarations.push(body); + const caller = `Method:${file}:${owner}.run${suffix}#0`; + for (const [name, type] of [ + ['choose', changed && i === 0 ? 'Object' : 'String'], + ['widen', 'long'], + ['make', 'String'], + ]) + expected.push([caller, `Method:${file}:${owner}.${name}${suffix}#1~${type}`]); + expected.push([caller, 'Method:Product.java:Product.accept#1~String']); + } + if (shape === 'concentrated') + files.set('Cases.java', `package bench; public class Cases {${declarations.join('')}}\n`); + return { + files, + expected: expected.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + }; +} + +async function sample(shape, size) { + assert(shapes.includes(shape) && Number.isSafeInteger(size) && size > 0); + const directory = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-java-overload-bench-')); + const repo = path.join(directory, 'repo'); + const input = corpus(shape, size); + Object.assign(process.env, { + GITNEXUS_HOME: path.join(directory, 'home'), + GITNEXUS_SHARED_STORE: 'off', + GITNEXUS_MEMORY: 'off', + GITNEXUS_LANG: 'en', + GITNEXUS_LBUG_BUFFER_POOL_SIZE: '134217728', + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', + XDG_RUNTIME_DIR: path.join(directory, 'runtime'), + GITNEXUS_MAX_FILE_SIZE: String( + Math.ceil( + Math.max(...[...input.files.values()].map((source) => Buffer.byteLength(source))) / 1024, + ) + 1, + ), + }); + delete process.env.GITNEXUS_STORAGE_PATH; + delete process.env.GITNEXUS_STORAGE_ROOT; + await mkdir(repo); + await mkdir(process.env.XDG_RUNTIME_DIR, { mode: 0o700 }); + const { runFullAnalysis } = await load('core/run-analyze.js'); + const { scopeResolutionPhase } = await load('core/ingestion/scope-resolution/pipeline/phase.js'); + const execute = scopeResolutionPhase.execute; + let scopeWallMs = 0, + scopeCpuMs = 0, + scopeRuns = 0; + scopeResolutionPhase.execute = async function (...args) { + const cpu = process.cpuUsage(), + start = performance.now(); + try { + return await execute.apply(this, args); + } finally { + scopeWallMs += performance.now() - start; + const used = process.cpuUsage(cpu); + scopeCpuMs += (used.user + used.system) / 1000; + scopeRuns++; + } + }; + try { + for (const [file, source] of input.files) await writeFile(path.join(repo, file), source); + const runs = []; + for (const scenario of scenarios) { + const fixture = scenario === 'changed' ? corpus(shape, size, true) : input; + if (scenario === 'changed') { + const edited = shape === 'spread' ? 'Case0.java' : 'Cases.java'; + await writeFile(path.join(repo, edited), fixture.files.get(edited)); + } + global.gc(); + scopeWallMs = scopeCpuMs = scopeRuns = 0; + const cpu = process.cpuUsage(), + start = performance.now(); + const analysis = await runFullAnalysis( + repo, + { + force: true, + skipAgentsMd: true, + skipSkills: true, + skipFts: true, + streamGraphEmit: false, + workerPoolSize: 1, + maxEntryPointCandidates: 7 * size + 2, + maxProcesses: 7 * size + 2, + }, + { onProgress: () => {} }, + ); + const wallMs = performance.now() - start; + const used = process.cpuUsage(cpu); + const heapBytes = process.memoryUsage().heapUsed; + const peakRssBytes = process.resourceUsage().maxRSS * 1024; + const pipeline = analysis.pipelineResult; + assert(pipeline, 'Missing pipeline output'); + assert.equal(scopeRuns, 1, 'Every analysis must execute scope resolution'); + assert.deepEqual(pipeline.scopeExtractionFailures, []); + assert.equal(pipeline.unavailableScopeLanguageFiles, 0); + assert.equal( + pipeline.processResult?.stats.truncation.truncated, + false, + 'Process detection must cover the workload', + ); + const calls = [...pipeline.graph.iterRelationshipsByType('CALLS')] + .map((edge) => [edge.sourceId, edge.targetId]) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + assert.equal( + calls.length, + fixture.expected.length, + `${shape}/${size}/${scenario}: lost calls`, + ); + assert.equal( + hash(calls), + hash(fixture.expected), + `${shape}/${size}/${scenario}: incorrect overload targets`, + ); + const methodCount = [...pipeline.graph.iterNodes()].filter( + (node) => node.label === 'Method', + ).length; + assert.equal(methodCount, 7 * size + 2, 'Workload lost method declarations'); + const reparsed = pipeline.reparsedFileCount; + if (scenario === 'cold') assert.equal(reparsed, input.files.size); + else if (scenario === 'warm') assert.equal(reparsed, 0); + else { + // Cache entries contain chunks: editing one file can reparse its neighbors. + assert(reparsed > 0, 'The edited source must reach parser workers'); + if (shape === 'spread') + assert(reparsed < input.files.size, 'The edit must retain cache hits'); + } + assert.equal(pipeline.parseCacheHitFileCount, input.files.size - reparsed); + assert.equal(pipeline.usedWorkerPool, scenario !== 'warm'); + assert( + !pipeline.resolutionOutcomes.some((outcome) => outcome.kind === 'suppressed'), + 'Unexpected suppressed calls', + ); + runs.push({ + scenario, + wallMs, + cpuMs: (used.user + used.system) / 1000, + scopeWallMs, + scopeCpuMs, + heapBytes, + peakRssBytes, + correctness: { + files: input.files.size, + methods: methodCount, + calls: calls.length, + fingerprint: hash(calls), + reparsed, + hits: pipeline.parseCacheHitFileCount, + }, + }); + } + return { shape, size, corpus: hash([...input.files]), runs }; + } finally { + scopeResolutionPhase.execute = execute; + await rm(directory, { recursive: true, force: true }); + } +} + +async function measure() { + assert(!(values.check && values.record), '--check cannot record a baseline'); + assert(!values.record || !process.env.CI, 'CI must not record baselines'); + assert( + !values.output || path.resolve(values.output) !== fileURLToPath(baselinePath), + '--output must not overwrite the baseline', + ); + const sizes = values.sizes.split(',').map(Number), + samples = Number(values.samples); + assert( + sizes.length >= 3 && + sizes.every((n, i) => Number.isSafeInteger(n) && n > 0 && (!i || n > sizes[i - 1])), + 'Supply at least three increasing positive sizes', + ); + assert( + Number.isSafeInteger(samples) && samples >= (values.check || values.record ? 3 : 1), + 'Checks and baseline recording require at least three samples', + ); + // Refuse existing files (including symlinks), as in the other ingestion harness. + if (values.output) await writeFile(values.output, '', { flag: 'wx' }); + const report = { + schemaVersion: 1, + recordedAt: new Date().toISOString(), + runtimeSha256: await runtimeFingerprint(), + workload: { sizes, shapes, scenarios, overloadsPerFamily: 2, callsPerUnit: 4, workers: 1 }, + environment: { + node: process.version, + platform: process.platform, + arch: process.arch, + cpu: os.cpus()[0]?.model, + }, + samples: [], + measured: [], + scaling: {}, + }; + for (let repetition = 0; repetition < samples; repetition++) { + for (const size of repetition % 2 ? [...sizes].reverse() : sizes) { + for (const shape of repetition % 2 ? [...shapes].reverse() : shapes) { + console.error( + `java-overload-resolution: ${shape}/${size}, sample ${repetition + 1}/${samples}`, + ); + const output = execFileSync( + process.execPath, + ['--expose-gc', script, '--child', shape, '--size', String(size), '--root', root], + { + encoding: 'utf8', + timeout: 300_000, + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, GITNEXUS_MEMORY: 'off' }, + }, + ); + const result = output.split('\n').find((line) => line.startsWith(marker)); + assert(result, 'Child did not report measurements'); + report.samples.push(JSON.parse(result.slice(marker.length))); + if (values.output) await writeFile(values.output, JSON.stringify(report, null, 2) + '\n'); + } + } + } + for (const shape of shapes) + for (const size of sizes) { + const group = report.samples.filter((row) => row.shape === shape && row.size === size); + assert.equal(group.length, samples); + for (const scenario of scenarios) { + const rows = group.map((row) => row.runs.find((run) => run.scenario === scenario)); + for (const row of rows) assert.deepEqual(row.correctness, rows[0].correctness); + report.measured.push({ + shape, + size, + scenario, + corpus: group[0].corpus, + correctness: rows[0].correctness, + ...Object.fromEntries( + metrics.map((metric) => [metric, median(rows.map((row) => row[metric]))]), + ), + }); + } + } + assert.equal( + await runtimeFingerprint(), + report.runtimeSha256, + 'Built runtime changed during measurement', + ); + for (const shape of shapes) + for (const scenario of scenarios) { + report.scaling[`${shape}/${scenario}`] = evaluateScaling( + report.measured.filter((row) => row.shape === shape && row.scenario === scenario), + metrics, + ); + } + if (values.output) await writeFile(values.output, JSON.stringify(report, null, 2) + '\n'); + console.log(JSON.stringify({ measured: report.measured, scaling: report.scaling }, null, 2)); + if (values.check || values.record) { + assert( + Object.values(report.scaling).every((row) => row.passed), + 'Time or memory growth exceeds the 1.5 normalized scaling budget', + ); + const correctness = report.measured.map(({ shape, size, scenario, corpus, correctness }) => ({ + shape, + size, + scenario, + corpus, + correctness, + })); + if (values.check) { + const baseline = JSON.parse(await readFile(baselinePath, 'utf8')); + assert.equal(baseline.schemaVersion, report.schemaVersion); + assert.deepEqual(report.workload, baseline.workload, 'Workload differs from baseline'); + assert.deepEqual( + correctness, + baseline.correctness, + 'Semantic or cache results differ from baseline', + ); + } else { + await writeFile( + baselinePath, + JSON.stringify( + { + _note: + 'Measured baseline. --check gates same-run normalized growth at 1.5 and exact call targets/cache counts. Absolute timings and memory are references, not portable budgets.', + schemaVersion: report.schemaVersion, + recordedAt: report.recordedAt, + runtimeSha256: report.runtimeSha256, + workload: report.workload, + environment: report.environment, + correctness, + measured: report.measured, + scaling: report.scaling, + }, + null, + 2, + ) + '\n', + ); + } + console.log(`[java-overload-resolution --${values.record ? 'record' : 'check'}] PASS`); + } +} + +if (values.child) + console.log(marker + JSON.stringify(await sample(values.child, Number(values.size)))); +else await measure(); diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index a973f6f58..7de5ee51f 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -202,14 +202,14 @@ } }, "countArm": { - "callDrops": 140, - "totalDropsAllKinds": 171, + "callDrops": 141, + "totalDropsAllKinds": 172, "bySiteKind": { - "call": 140, + "call": 141, "read": 31 }, "callDropsByExtension": { - ".java": 49, + ".java": 50, ".py": 24, ".ts": 18, ".zig": 10, @@ -224,16 +224,16 @@ ".swift": 1 }, "callDropsByShape": { - "chain-field": 56, - "no-chain": 38, - "chain-call": 32, + "chain-field": 57, + "no-chain": 39, + "chain-call": 31, "<>": 13, "chain-unwrap": 1 }, "callDropsByOrigin": { - "in-program": 58, - "unknown": 49, - "external": 31, + "unknown": 64, + "in-program": 53, + "external": 22, "<>": 2 } } diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index e7f6ebf07..ca9d42101 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -194,7 +194,8 @@ "_rebaselined_3498_prefixed_call_bindings": "#3498: a local initialized by exactly `prefix.member(...)` now gets a call-derived @type-binding.constructor whose type keeps the dotted callee, so the shared chain follow can type namespace factories (`final x = rp.make()`). Two existing fixture sites gain a binding, both in dart-method-enrichment/app.dart: `var sound = dog.speak()` and `var category = Animal.classify(\"dog\")`; neither variable is used as a receiver, and a full lang-resolution A/B against origin/main emits the identical CALLS/ACCESSES edges outside the new fixture. The new dart-prefixed-factory-return fixture adds its own captures. Prior 4690403d2be0d270e28a55791df82eaa627d26d28c52d81bfafa59761badcaf3 (897dfc745d4ba0dfe0ae5fcfffcd983563ddab6d0a870fc8ee0de1d8df2c7c87 with the existing fixtures alone) -> a8b2e674a7f695caba32b5a0912686119944e8e4874a6ceeb65b3b5ac420bebb. scaling_ratio 1.173 against the 1.5 budget. Review follow-up: the fixture gains an awaited `rp.makeAsync()` row (corpus growth only; the Dart emitter change in that commit is a docstring) -> 1c5b892a136318eddee3435225284896c2effb52dd51ca5717c92887ae4a39a3." }, "java": { - "fingerprint": "fbd822c3b6848dc6f5e708218c1ab9c1244f2f9f02f4e3df5ed9296137b776d8", + "fingerprint": "a0fc49d8d1e80678ab05004eabc4e2229b5954df3b884a8149017e48a6ebe425", + "_rebaselined_3789_overloads": "#3789: Compared base 82966afa captures with this provider on the same corpus. Existing entries differ only in 387 method return-type captures and four added var call-result assignments. Seven new fixture files add 284 capture groups; total groups 3515 -> 3803, entries 222 -> 229. Removing those two capture changes reproduces every existing entry. The base emitter reproduces the prior fingerprint exactly. Scaling budget remains 1.5.", "scaling_budget": 1.5, "_rebaselined_3190_ctor_qualified_name": "#3190: qualified constructors (`new pkg.Foo()`, `new pkg.Box()`) now copy @reference.call.constructor.qualified onto @reference.qualified-name so constructor fallback treats the written qualifier as precise. DIGEST DRIFT ONLY: the tag is added to existing F35 matches. Prior 2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff -> e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f; capture_groups_fp 3586, fixture_count 209.", "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.", @@ -212,24 +213,25 @@ "_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.", - "capture_groups_small": 6255, - "capture_groups_large": 20005, - "capture_groups_fp": 3515, - "fixture_count": 222, + "capture_groups_small": 6505, + "capture_groups_large": 20805, + "capture_groups_fp": 3803, + "fixture_count": 229, "_rebaselined_2910_declared_package_fixtures": "#2910 adds three Java resolver fixture files covering an external JDK lookalike, a path/package mismatch, and wildcard package membership. Fixture-corpus growth only: Java query rules and synthetic scaling sources are unchanged; capture_groups_small/large remain 6255/20005. capture_groups_fp 3560 -> 3586 and fixture_count 206 -> 209.", "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 39 constructor-parameter type bindings are added, 6 member reads gain receiver chains, and 196 duplicate qualified free-call matches are removed. Every removed free-call match retains a member-call match with the identical call anchor and name capture; no qualified call disappears. Fixture entries 209; changed entries 89; capture groups 3586 -> 3429. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged.", "_rebaselined_same_namespace_heritage_fixture": "Same-namespace heritage fixture: adds `java-same-package-extends-collision` (9 files). FIXTURE ADDITION ONLY, no capture code changed: with that directory parked, --check passes against the prior fingerprint 41c4338f5813a73199dd5c2609313c11076812df21fd9c429039a833325870e0 (measured fixture_count 209, capture_groups_fp 3429); with it, fixture_count 218, capture_groups_fp 3483. Synthetic counts and scaling unchanged.", "_rebaselined_duplicate_fqn_import_fixture": "Duplicate-FQN import fixture: adds `java-duplicate-fqn-source-roots` (4 files). FIXTURE ADDITION ONLY, no capture code changed: with that directory parked, --check passes against the prior fingerprint 82a4b09708caed2350ab36d2b7a1ff00639a34bca73dd1536e37f231e800c2d2 (fixture_count 218, capture_groups_fp 3483); with it, fixture_count 222, capture_groups_fp 3515. Synthetic counts and scaling unchanged." }, "java-local-types": { - "fingerprint": "6ae163cd36c889454c8672b28944c765d3a3a5e8c49ea4db1cd6cc1787ede583", + "fingerprint": "41f1d8a9dcac9fad0889ae698b5a6710ffb43b264ad383a3fd0c172f2da2b596", + "_rebaselined_3789_overloads": "#3789: All six entries differ only in 53 method return-type captures; capture groups remain 644. The base 82966afa emitter reproduces the prior fingerprint exactly, and removing the return-type captures makes every entry identical. Corrects the stale group-count field left at 680 after #3486. Scaling budget remains 1.5.", "scaling_budget": 1.5, "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: the local-type stress corpus includes synthesized anonymous declarations, which now carry the presence-only @declaration.is-synthetic sidecar. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97; CI scaling 1.002 < 1.5.", "_rebaselined_2917_record_component_accessors": "#2917: the focused local-type fixture corpus contains local records, so their implicit component accessors add the same bounded scope/declaration captures as the general Java corpus. No local-type naming logic changed. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 3e22f368a4ee139be7cb91ff4fb77ddadf60c55efe8d66955ec81f366a46e460; scaling 1.032 < 1.5, capture_groups_fp 680. Re-measured on top of #2935's is-synthetic sidecar after merging origin/main: 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97 -> bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e; scaling 0.997 < 1.5, capture_groups_fp 680.", "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633.", - "capture_groups_fp": 680, + "capture_groups_fp": 644, "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 2 constructor-parameter type bindings are added and 38 duplicate qualified free-call matches are removed. Every removed free-call match retains a member-call match with the identical call anchor and name capture. Fixture entries 6; changed entries 6; capture groups 680 -> 644. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "typescript": { diff --git a/gitnexus/bench/tool-accuracy/run.ts b/gitnexus/bench/tool-accuracy/run.ts index dd8dd9e44..d40de7baa 100644 --- a/gitnexus/bench/tool-accuracy/run.ts +++ b/gitnexus/bench/tool-accuracy/run.ts @@ -125,6 +125,7 @@ try { pool, { LocalBackend }, { summarizeUnresolvedReceivers }, + { summarizeUnresolvedOverloads }, { taintModelVersion }, ] = await Promise.all([ import('../../src/core/ingestion/pipeline.js'), @@ -132,6 +133,7 @@ try { import('../../src/core/lbug/pool-adapter.js'), import('../../src/mcp/local/local-backend.js'), import('../../src/core/ingestion/scope-resolution/unresolved-receivers.js'), + import('../../src/core/ingestion/scope-resolution/unresolved-overloads.js'), import('../../src/core/ingestion/taint/typescript-model.js'), ]); adapter = await import('../../src/core/lbug/lbug-adapter.js'); @@ -174,6 +176,10 @@ try { indexedAt: '2026-10-06T00:00:00.000Z', scopeExtractionReceipt: 1, unresolvedReceiverMembers: summarizeUnresolvedReceivers(pipeline.resolutionOutcomes), + unresolvedOverloads: summarizeUnresolvedOverloads( + pipeline.resolutionOutcomes, + pipeline.resolutionOutcomes !== undefined, + ), pdg: { maxFunctionLines: 2000, maxEdgesPerFunction: 0, taintModelVersion }, stats: { files: pipeline.totalFileCount, diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 90d68b674..07dde400f 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -164,6 +164,7 @@ const PLATFORM_LOGIC = [ // N-API addon which has known platform-specific behavior (Windows // file-lock lag after close, macOS N-API destructor segfaults) const LBUG_NATIVE = [ + 'test/integration/java-overload-impact-persistence.test.ts', 'test/integration/dangling-owner-edges.test.ts', 'test/integration/impact-private-type-file-seed.test.ts', 'test/integration/impact-private-type-source.test.ts', @@ -274,6 +275,7 @@ const LBUG_NATIVE = [ // process spawning, which behaves differently across platforms (shell // quoting, path resolution, signal handling) const SPAWN_CLI = [ + 'test/integration/resolvers/java-javac-overloads.test.ts', // Real shard/blob/JSON roundtrip: path identities and child invocation vary by OS. 'test/unit/shard-timings-roundtrip.test.ts', 'test/integration/cli-e2e.test.ts', @@ -352,6 +354,7 @@ const WORKER_THREADS = [ 'test/integration/cfg/interproc-taint.test.ts', 'test/integration/function-local-identity.test.ts', 'test/integration/java-imported-return-types.test.ts', + 'test/integration/resolvers/java-overload-jls.test.ts', 'test/unit/community-processor.test.ts', 'test/integration/worker-pool.test.ts', 'test/integration/class-expression-owners.test.ts', diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index c436e5d23..ff9003ed0 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -53,12 +53,15 @@ export const javaExportChecker: ExportChecker = (node, _name) => { let isPublic = javaPublicModifierCache.get(parent); if (isPublic === undefined) { isPublic = false; - for (let i = 0; i < parent.childCount; i++) { - const child = parent.child(i); + // Java modifiers precede the declaration's other named children. + // Skip comments, but never scan a class body full of sibling methods. + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child?.type === 'line_comment' || child?.type === 'block_comment') continue; if (child?.type === 'modifiers' && child.text?.includes('public')) { isPublic = true; - break; } + break; } if (parent.type === 'method_declaration' || parent.type === 'constructor_declaration') { isPublic ||= parent.text?.trimStart().startsWith('public') ?? false; diff --git a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index a8c5e268e..3753744f5 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -16,11 +16,19 @@ import type { JavaSpringDiClassFact } from './spring-di.js'; import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; import type { JavaSpringNonHttpHandlerFact } from './spring-non-http-handlers.js'; +import { + clearJavaOverloadFacts, + getJavaOverloadFacts, + isJavaOverloadFileFacts, + setJavaOverloadFacts, + type JavaOverloadFileFacts, +} from './overload-argument-facts.js'; export type JavaClassAnnotationFact = ClassAnnotationFact; export interface JavaCaptureSideChannel { readonly kind: 'java'; + readonly overloadFacts?: JavaOverloadFileFacts; readonly packageFact: JvmPackageFact; readonly classAnnotations: readonly JavaClassAnnotationFact[]; readonly springAopFacts?: readonly JavaSpringAopFact[]; @@ -43,6 +51,7 @@ const springMessageProducerFacts = new Map 0 ? { springAopFacts: aopFacts } : {}), @@ -208,6 +220,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { data.kind !== 'java' || !Array.isArray(data.classAnnotations) ) { + setJavaOverloadFacts(parsed.filePath, undefined); setJavaClassAnnotationFacts(parsed.filePath, []); setJavaSpringAopFacts(parsed.filePath, []); setJavaSpringConfigConsumerFacts(parsed.filePath, []); @@ -219,6 +232,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } + setJavaOverloadFacts( + parsed.filePath, + isJavaOverloadFileFacts(data.overloadFacts) ? data.overloadFacts : undefined, + ); setJavaClassAnnotationFacts(parsed.filePath, data.classAnnotations); setJavaSpringAopFacts( parsed.filePath, diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index f47758b8e..751383059 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -63,6 +63,7 @@ import { } from './spring-non-http-handlers.js'; import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js'; import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js'; +import { captureJavaOverloadFacts, setJavaOverloadFacts } from './overload-argument-facts.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -143,6 +144,8 @@ export function emitJavaScopeCaptures( recordCacheHit(); } captureJavaPackageFact(filePath, tree.rootNode); + const accessorCaptures = synthesizeJavaRecordComponentAccessorCaptures(tree.rootNode); + setJavaOverloadFacts(filePath, captureJavaOverloadFacts(tree.rootNode, accessorCaptures)); const rawMatches = getJavaScopeQuery().matches(tree.rootNode); const out: CaptureMatch[] = []; @@ -333,6 +336,14 @@ export function emitJavaScopeCaptures( // directly on the method/constructor node. const fnNode = findFunctionNode(nodeMap[declTag]); if (fnNode !== null) { + const returnType = fnNode.childForFieldName('type'); + if (returnType !== null && fnNode.type === 'method_declaration') { + grouped['@declaration.return-type'] = syntheticCapture( + '@declaration.return-type', + fnNode, + returnType.text + (fnNode.childForFieldName('dimensions')?.text ?? ''), + ); + } const arity = computeJavaArityMetadata(fnNode); if (arity.parameterCount !== undefined) { grouped['@declaration.parameter-count'] = syntheticCapture( @@ -392,6 +403,30 @@ export function emitJavaScopeCaptures( 'object_creation_expression', ); if (callNode !== null) { + if (callNode.type === 'method_invocation') { + let initializer = callNode; + while (initializer.parent?.type === 'parenthesized_expression') + initializer = initializer.parent; + const declarator = initializer.parent; + const declaration = declarator?.parent; + const name = declarator?.childForFieldName('name'); + if ( + declarator?.type === 'variable_declarator' && + declaration?.type === 'local_variable_declaration' && + declaration.childForFieldName('type')?.text === 'var' && + declarator.childForFieldName('value')?.id === initializer.id && + name !== null && + name !== undefined + ) { + out.push({ + '@call-result-assignment.call': nodeToCapture( + '@call-result-assignment.call', + callNode, + ), + '@call-result-assignment.lhs': nodeToCapture('@call-result-assignment.lhs', name), + }); + } + } const argList = callNode.childForFieldName('arguments'); // Exclude interleaved comments — tree-sitter-java emits `block_comment` / // `line_comment` as named children of argument_list, which would inflate @@ -452,7 +487,7 @@ export function emitJavaScopeCaptures( ...synthesizeJavaInheritanceReferences(tree.rootNode), ...synthesizeJavaExplicitConstructorReferences(tree.rootNode), ...synthesizeJavaAnonymousClassDeclarations(tree.rootNode), - ...synthesizeJavaRecordComponentAccessorCaptures(tree.rootNode), + ...accessorCaptures, ...synthesizeLombokAccessorCaptures(tree.rootNode), ...synthesizeCallableFlowCaptures(tree.rootNode, JAVA_CALLABLE_CAPTURE_OPTIONS), ]; diff --git a/gitnexus/src/core/ingestion/languages/java/overload-argument-facts.ts b/gitnexus/src/core/ingestion/languages/java/overload-argument-facts.ts new file mode 100644 index 000000000..0f2605c96 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/overload-argument-facts.ts @@ -0,0 +1,799 @@ +import type { CaptureMatch, Range } from 'gitnexus-shared'; +import { synthesizeJavaTypeIdentity, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js'; + +/** Written types retain their declaration context; a caller's imports cannot + * reinterpret a parameter declared in another file. These are capture facts, + * not the receiver-oriented (container-unwrapped) TypeRef hints. */ +export type JavaArgumentFact = + | { readonly kind: 'typed'; readonly spelling: string; readonly at: Range } + | { readonly kind: 'null' } + | { + readonly kind: 'factory'; + readonly owner: string; + readonly name: 'asList' | 'singletonList'; + readonly arguments: readonly JavaArgumentFact[]; + readonly typeArguments: readonly string[]; + readonly at: Range; + readonly shadowed: boolean; + } + | { readonly kind: 'unknown' }; + +export interface JavaOverloadTypeFact { + readonly name: string; + readonly simpleName: string; + readonly kind: string; + readonly range: Range; + readonly visible: Range; + readonly bases: readonly string[]; + readonly typeParameters: readonly string[]; + readonly modifiers?: readonly string[]; +} + +export interface JavaOverloadMethodFact { + readonly name: string; + readonly owner: string; + readonly range: Range; + readonly parameterTypes: readonly string[]; + readonly modifiers: readonly string[]; + readonly typeParameters: readonly string[]; +} + +export interface JavaOverloadCallFact { + readonly name: string; + readonly range: Range; + readonly arguments: readonly JavaArgumentFact[]; + readonly receiver?: JavaArgumentFact; + readonly receiverSpelling?: string; + readonly receiverCall?: { readonly name: string; readonly range: Range }; + readonly receiverIsValue: boolean; + readonly staticContext: boolean; +} + +export interface JavaOverloadFileFacts { + readonly version: 1; + readonly packageName: string; + readonly imports: readonly { + readonly name: string; + readonly wildcard: boolean; + readonly static: boolean; + }[]; + readonly types: readonly JavaOverloadTypeFact[]; + readonly methods: readonly JavaOverloadMethodFact[]; + readonly calls: readonly JavaOverloadCallFact[]; +} + +const files = new Map(); +const callIndexes = new Map>(); +const UNKNOWN: JavaArgumentFact = { kind: 'unknown' }; +const typeNodes = new Set([ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + 'annotation_type_declaration', +]); +const callableNodes = new Set([ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', + 'lambda_expression', +]); +const lexicalScopes = new Set([ + 'block', + 'for_statement', + 'enhanced_for_statement', + 'catch_clause', + 'lambda_expression', + 'switch_block', +]); + +export const javaOverloadRangeKey = (range: Range): string => + `${range.startLine}:${range.startCol}:${range.endLine}:${range.endCol}`; + +export function setJavaOverloadFacts( + filePath: string, + facts: JavaOverloadFileFacts | undefined, +): void { + if (facts === undefined) files.delete(filePath); + else files.set(filePath, facts); + callIndexes.delete(filePath); +} + +export function clearJavaOverloadFacts(): void { + files.clear(); + callIndexes.clear(); +} + +export function getJavaOverloadFacts(filePath: string): JavaOverloadFileFacts | undefined { + return files.get(filePath); +} + +export function getJavaOverloadCall( + filePath: string, + range: Range, + name: string, +): JavaOverloadCallFact | undefined { + let index = callIndexes.get(filePath); + if (index === undefined) { + const facts = files.get(filePath); + if (facts === undefined) return undefined; + index = new Map( + facts.calls.map((call) => [`${javaOverloadRangeKey(call.range)}:${call.name}`, call]), + ); + callIndexes.set(filePath, index); + } + return index.get(`${javaOverloadRangeKey(range)}:${name}`); +} + +export function javaRangeContains(outer: Range, inner: Range): boolean { + return ( + (outer.startLine < inner.startLine || + (outer.startLine === inner.startLine && outer.startCol <= inner.startCol)) && + (outer.endLine > inner.endLine || + (outer.endLine === inner.endLine && outer.endCol >= inner.endCol)) + ); +} + +function rangeOf(node: SyntaxNode): Range { + return { + startLine: node.startPosition.row + 1, + startCol: node.startPosition.column, + endLine: node.endPosition.row + 1, + endCol: node.endPosition.column, + }; +} + +function enclosing( + node: SyntaxNode, + predicate: (node: SyntaxNode) => boolean, +): SyntaxNode | undefined { + let parent = node.parent; + while (parent !== null) { + if (predicate(parent)) return parent; + parent = parent.parent; + } + return undefined; +} + +function isType(node: SyntaxNode): boolean { + return ( + typeNodes.has(node.type) || + ((node.type === 'object_creation_expression' || node.type === 'enum_constant') && + node.namedChildren.some((child) => child.type === 'class_body')) + ); +} + +function typeIdentity(node: SyntaxNode): string | undefined { + return synthesizeJavaTypeIdentity(node)?.name ?? node.childForFieldName('name')?.text; +} + +function parameterTypes(node: SyntaxNode): string[] { + const parameters = node.childForFieldName('parameters')?.namedChildren ?? []; + const result: string[] = []; + let variadic = false; + for (const parameter of parameters) { + if (!['formal_parameter', 'spread_parameter'].includes(parameter.type)) continue; + const declarator = parameter.namedChildren.find( + (child) => child.type === 'variable_declarator', + ); + const name = parameter.childForFieldName('name') ?? declarator?.childForFieldName('name'); + const type = + parameter.childForFieldName('type') ?? + parameter.namedChildren.find( + (child) => + child.type !== 'modifiers' && + child.type !== 'variable_declarator' && + child.id !== name?.id, + ); + const dimensions = + parameter.childForFieldName('dimensions')?.text ?? + declarator?.childForFieldName('dimensions')?.text ?? + ''; + result.push(type === undefined || type === null ? '?' : type.text + dimensions); + variadic ||= parameter.type === 'spread_parameter'; + } + if (variadic) result.push('varargs'); + return result; +} + +function typeParameters(node: SyntaxNode): string[] { + const parameters = node.childForFieldName('type_parameters'); + return ( + parameters?.namedChildren.flatMap((parameter) => { + const name = parameter.namedChildren.find((child) => child.type === 'type_identifier'); + return name === undefined ? [] : [name.text]; + }) ?? [] + ); +} + +function modifiers(node: SyntaxNode): string[] { + return ( + node.namedChildren + .find((child) => child.type === 'modifiers') + ?.children.filter((child) => !child.isNamed) + .map((child) => child.text) ?? [] + ); +} + +interface Binding { + readonly type?: string; + readonly at: Range; + readonly scope: Range; + readonly depth: number; + readonly available: number; + readonly owner?: SyntaxNode; + readonly inheritable: boolean; +} + +/** One AST walk records declarations and lexical bindings. Calls are evaluated + * against those bindings after the walk, so fields are visible before their + * declaration while local declarations retain their source-order boundary. */ +export function captureJavaOverloadFacts( + root: SyntaxNode, + accessorCaptures: readonly CaptureMatch[] = synthesizeJavaRecordComponentAccessorCaptures(root), +): JavaOverloadFileFacts | undefined { + const types: JavaOverloadTypeFact[] = []; + const typeFactsByNode = new Map(); + const typeNodesByFact = new Map(); + const methods: JavaOverloadMethodFact[] = []; + // Reuse the scope synthesizer's validated component names and explicit + // accessor suppression so generated declarations have the same evidence as + // source-written methods without creating a second accessor authority. + const implicitAccessors = new Map( + accessorCaptures.flatMap((capture) => { + const declaration = capture['@declaration.method']; + const name = capture['@declaration.name']; + return declaration === undefined || name === undefined + ? [] + : [[javaOverloadRangeKey(declaration.range), name.text] as const]; + }), + ); + const callNodes: SyntaxNode[] = []; + const bindings = new Map(); + const localBindings = new Map>(); + const imports: { name: string; wildcard: boolean; static: boolean }[] = []; + let packageName = ''; + const stack = [{ node: root, depth: 0 }]; + const addBinding = ( + name: string | undefined, + type: string | undefined, + node: SyntaxNode, + scope: SyntaxNode, + depth: number, + available: number, + field = false, + ) => { + if (name === undefined) return; + const binding: Binding = { + type, + at: rangeOf(node), + scope: rangeOf(scope), + depth, + available, + owner: enclosing(node, isType), + inheritable: !field || node.parent === null || !modifiers(node.parent).includes('private'), + }; + if (field) { + const list = bindings.get(name) ?? []; + list.push(binding); + bindings.set(name, list); + } else { + const scoped = localBindings.get(scope.id) ?? new Map(); + const locals = scoped.get(name) ?? []; + locals.push(binding); + scoped.set(name, locals); + localBindings.set(scope.id, scoped); + } + }; + while (stack.length > 0) { + const { node, depth } = stack.pop()!; + if (node.type === 'package_declaration') { + packageName = + node.namedChildren.find( + (child) => child.type === 'scoped_identifier' || child.type === 'identifier', + )?.text ?? ''; + } else if (node.type === 'import_declaration') { + const name = node.namedChildren.find( + (child) => child.type === 'scoped_identifier' || child.type === 'identifier', + )?.text; + if (name !== undefined) + imports.push({ + name, + wildcard: node.namedChildren.some((child) => child.type === 'asterisk'), + static: node.children.some((child) => child.type === 'static'), + }); + } + if (isType(node)) { + const identity = typeIdentity(node); + const simpleName = + node.type === 'enum_constant' + ? identity + : (node.childForFieldName('name')?.text ?? identity); + if (identity !== undefined && simpleName !== undefined) { + const visibilityScope = + enclosing( + node, + (parent) => lexicalScopes.has(parent.type) || parent.type === 'class_body', + ) ?? root; + const visible = lexicalScopes.has(visibilityScope.type) + ? { + ...rangeOf(visibilityScope), + startLine: node.startPosition.row + 1, + startCol: node.startPosition.column, + } + : rangeOf(visibilityScope); + const bases: string[] = []; + for (const child of node.namedChildren) { + if (child.type === 'superclass') + bases.push(...child.namedChildren.map((part) => part.text)); + if (child.type === 'super_interfaces' || child.type === 'extends_interfaces') { + const list = child.namedChildren.find((part) => part.type === 'type_list'); + bases.push(...(list?.namedChildren ?? child.namedChildren).map((part) => part.text)); + } + } + if (node.type === 'object_creation_expression') { + const base = node.childForFieldName('type'); + if (base !== null) bases.push(base.text); + } else if (node.type === 'enum_constant') { + const host = enclosing(node, (parent) => parent.type === 'enum_declaration'); + const base = host?.childForFieldName('name'); + if (base !== undefined && base !== null) bases.push(base.text); + } + const flags = modifiers(node); + if ( + (node.parent?.type === 'interface_body' || + node.parent?.type === 'annotation_type_body') && + !flags.some((flag) => ['public', 'protected', 'private'].includes(flag)) + ) + flags.push('public'); + const fact: JavaOverloadTypeFact = { + name: identity, + simpleName, + kind: node.type, + range: rangeOf(node), + visible, + bases, + typeParameters: typeParameters(node), + modifiers: flags, + }; + types.push(fact); + typeFactsByNode.set(node.id, fact); + typeNodesByFact.set(fact, node); + } + } + const implicitAccessor = + node.type === 'formal_parameter' || node.type === 'spread_parameter' + ? implicitAccessors.get(javaOverloadRangeKey(rangeOf(node))) + : undefined; + if ( + (callableNodes.has(node.type) && node.type !== 'lambda_expression') || + implicitAccessor !== undefined + ) { + const owner = enclosing(node, isType); + const ownerIdentity = owner === undefined ? undefined : typeIdentity(owner); + const name = implicitAccessor ?? node.childForFieldName('name')?.text; + if (ownerIdentity !== undefined && name !== undefined) { + const flags = implicitAccessor === undefined ? modifiers(node) : ['public']; + if ( + owner?.type === 'interface_declaration' && + !flags.includes('private') && + !flags.includes('public') + ) + flags.push('public'); + methods.push({ + name, + owner: ownerIdentity, + range: rangeOf(node), + parameterTypes: implicitAccessor === undefined ? parameterTypes(node) : [], + modifiers: flags, + typeParameters: typeParameters(node), + }); + } + } + if (node.type === 'method_invocation') callNodes.push(node); + if ( + node.type === 'formal_parameter' || + node.type === 'spread_parameter' || + node.type === 'catch_formal_parameter' + ) { + const scope = enclosing( + node, + (parent) => callableNodes.has(parent.type) || parent.type === 'catch_clause', + ); + const declarator = node.namedChildren.find((child) => child.type === 'variable_declarator'); + const name = node.childForFieldName('name') ?? declarator?.childForFieldName('name'); + const type = + node.childForFieldName('type') ?? + node.namedChildren.find( + (child) => + child.type !== 'modifiers' && + child.type !== 'variable_declarator' && + child.id !== name?.id, + ); + const dimensions = + node.childForFieldName('dimensions')?.text ?? + declarator?.childForFieldName('dimensions')?.text ?? + ''; + if (scope !== undefined) + addBinding( + name?.text, + type === undefined || type === null + ? undefined + : type.text + dimensions + (node.type === 'spread_parameter' ? '[]' : ''), + node, + scope, + depth, + scope.startIndex, + ); + } else if ( + node.type === 'variable_declarator' && + (node.parent?.type === 'field_declaration' || + node.parent?.type === 'constant_declaration' || + node.parent?.type === 'local_variable_declaration') + ) { + const declaration = node.parent; + const field = declaration.type !== 'local_variable_declaration'; + const scope = enclosing(node, (parent) => + field + ? ['class_body', 'interface_body', 'enum_body'].includes(parent.type) + : lexicalScopes.has(parent.type), + ); + const type = declaration.childForFieldName('type'); + const dimensions = node.childForFieldName('dimensions')?.text ?? ''; + if (scope !== undefined) + addBinding( + node.childForFieldName('name')?.text, + type?.text === 'var' + ? undefined + : type?.text === undefined + ? undefined + : type.text + dimensions, + node, + scope, + depth, + field ? scope.startIndex : node.startIndex, + field, + ); + } else if (node.type === 'enhanced_for_statement') { + const type = node.childForFieldName('type')?.text; + const body = node.childForFieldName('body'); + if (body !== null) + addBinding( + node.childForFieldName('name')?.text, + type === undefined + ? undefined + : type + (node.childForFieldName('dimensions')?.text ?? ''), + node, + body, + depth + 1, + body.startIndex, + ); + } else if (node.type === 'lambda_expression') { + const parameters = node.childForFieldName('parameters'); + if (parameters?.type === 'identifier') + addBinding(parameters.text, undefined, parameters, node, depth + 1, node.startIndex); + else if (parameters?.type === 'inferred_parameters') { + for (const parameter of parameters.namedChildren) + addBinding(parameter.text, undefined, parameter, node, depth + 1, node.startIndex); + } + } + const children = node.namedChildren; + for (let i = children.length - 1; i >= 0; i--) + stack.push({ node: children[i]!, depth: depth + 1 }); + } + if (types.length === 0 && methods.length === 0 && callNodes.length === 0) return undefined; + + // Resolve only inheritance visible in this AST. A missing superclass or a + // generic substitution is an uncertainty barrier, never permission to bind + // an enclosing instance's same-named field instead (JLS 6.5.6.1, 8.3). + // Include traversal depth so a cached shallow proof cannot bypass the bound + // on a longer path. Caching unknown results also bounds repeated failures. + const fieldLookupCache = new Map(); + const fieldsFor = ( + owner: SyntaxNode, + name: string, + inherited: boolean, + seen = new Set(), + ): readonly Binding[] | undefined => { + if (seen.has(owner.id) || seen.size >= 64) return undefined; + const cacheKey = `${owner.id}:${name}:${inherited}:${seen.size}`; + if (fieldLookupCache.has(cacheKey)) return fieldLookupCache.get(cacheKey); + fieldLookupCache.set(cacheKey, undefined); + seen.add(owner.id); + const fact = typeFactsByNode.get(owner.id); + if (fact === undefined) return undefined; + const own = (bindings.get(name) ?? []).filter((binding) => binding.owner?.id === owner.id); + if (own.length > 0) { + if (inherited && fact.typeParameters.length > 0) return undefined; + const result = inherited ? own.filter((binding) => binding.inheritable) : own; + fieldLookupCache.set(cacheKey, result); + return result; + } + if (fact.kind !== 'class_declaration' && fact.kind !== 'interface_declaration') + return undefined; + const found = new Map(); + for (const spelling of fact.bases) { + let nameOfBase = spelling.split('<')[0]!.trim(); + if (packageName !== '' && nameOfBase.startsWith(`${packageName}.`)) + nameOfBase = nameOfBase.slice(packageName.length + 1); + const visible = types.filter( + (candidate) => + candidate.simpleName === nameOfBase && javaRangeContains(candidate.visible, fact.range), + ); + let nearest: JavaOverloadTypeFact | undefined; + for (const candidate of visible) { + if (nearest === undefined || javaRangeContains(nearest.visible, candidate.visible)) + nearest = candidate; + } + if (nearest === undefined) { + if (nameOfBase === 'java.lang.Object') continue; + return undefined; + } + if ( + visible.some( + (candidate) => + candidate !== nearest && + javaOverloadRangeKey(candidate.visible) === javaOverloadRangeKey(nearest!.visible), + ) + ) + return undefined; + const base = typeNodesByFact.get(nearest); + const inheritedFields = + base === undefined ? undefined : fieldsFor(base, name, true, new Set(seen)); + if (inheritedFields === undefined) return undefined; + for (const binding of inheritedFields) found.set(javaOverloadRangeKey(binding.at), binding); + } + const result = [...found.values()]; + fieldLookupCache.set(cacheKey, result); + return result; + }; + const bindingAt = (name: string, node: SyntaxNode, fieldOnly = false): Binding | undefined => { + const at = rangeOf(node); + const locals: Binding[] = []; + if (!fieldOnly) { + // Every recorded local scope is an AST ancestor of its uses. Restrict + // candidates before applying the existing shadowing and owner checks. + for (let scope: SyntaxNode | null = node; scope !== null; scope = scope.parent) + locals.push(...(localBindings.get(scope.id)?.get(name) ?? [])); + } + let owner = enclosing(node, isType); + while (owner !== undefined) { + if (!fieldOnly) { + let local: Binding | undefined; + for (const binding of locals) { + if ( + binding.owner?.id !== owner.id || + binding.available > node.startIndex || + !javaRangeContains(binding.scope, at) + ) + continue; + if ( + local === undefined || + binding.depth > local.depth || + (binding.depth === local.depth && binding.available > local.available) + ) + local = binding; + } + if (local !== undefined) return local; + } + const fields = fieldsFor(owner, name, false); + if (fields === undefined || fields.length > 1) return undefined; + if (fields.length === 1) return fields[0]; + if (fieldOnly || modifiers(owner).includes('static')) return undefined; + owner = enclosing(owner, isType); + } + return undefined; + }; + const expression = (node: SyntaxNode, depth = 0): JavaArgumentFact => { + if (depth > 12) return UNKNOWN; + const typed = (spelling: string, at = rangeOf(node)): JavaArgumentFact => ({ + kind: 'typed', + spelling, + at, + }); + switch (node.type) { + case 'decimal_integer_literal': + case 'hex_integer_literal': + case 'octal_integer_literal': + case 'binary_integer_literal': + return typed(/[lL]$/.test(node.text) ? 'long' : 'int'); + case 'decimal_floating_point_literal': + case 'hex_floating_point_literal': + return typed(/[fF]$/.test(node.text) ? 'float' : 'double'); + case 'string_literal': + return typed('java.lang.String'); + case 'character_literal': + return typed('char'); + case 'true': + case 'false': + return typed('boolean'); + case 'null_literal': + return { kind: 'null' }; + case 'identifier': { + const binding = bindingAt(node.text, node); + return binding?.type === undefined ? UNKNOWN : typed(binding.type, binding.at); + } + case 'field_access': { + if (node.childForFieldName('object')?.type !== 'this') return UNKNOWN; + const name = node.childForFieldName('field')?.text; + const binding = name === undefined ? undefined : bindingAt(name, node, true); + return binding?.type === undefined ? UNKNOWN : typed(binding.type, binding.at); + } + case 'parenthesized_expression': + return node.namedChildren[0] === undefined + ? UNKNOWN + : expression(node.namedChildren[0], depth + 1); + case 'cast_expression': + case 'object_creation_expression': { + const type = node.childForFieldName('type'); + return type === null ? UNKNOWN : typed(type.text); + } + case 'array_creation_expression': { + const type = node.childForFieldName('type'); + const dimensions = + node.namedChildren.filter((child) => child.type === 'dimensions_expr').length + + (node.namedChildren.find((child) => child.type === 'dimensions')?.text.match(/\[/g) + ?.length ?? 0); + return type === null || dimensions === 0 + ? UNKNOWN + : typed(type.text + '[]'.repeat(dimensions)); + } + case 'unary_expression': { + const operand = node.childForFieldName('operand') ?? node.namedChildren[0]; + if (operand === undefined || operand === null) return UNKNOWN; + const value = expression(operand, depth + 1); + if ( + value.kind !== 'typed' || + !['byte', 'short', 'char', 'int', 'long', 'float', 'double', 'boolean'].includes( + value.spelling, + ) + ) + return UNKNOWN; + return typed(['byte', 'short', 'char'].includes(value.spelling) ? 'int' : value.spelling); + } + case 'method_invocation': { + const name = node.childForFieldName('name')?.text; + const owner = node.childForFieldName('object'); + if ((name !== 'asList' && name !== 'singletonList') || owner === null) return UNKNOWN; + const args = + node + .childForFieldName('arguments') + ?.namedChildren.filter( + (child) => child.type !== 'line_comment' && child.type !== 'block_comment', + ) ?? []; + const firstName = owner.text.split('.')[0]!; + return { + kind: 'factory', + owner: owner.text, + name, + arguments: args.map((arg) => expression(arg, depth + 1)), + typeArguments: + node.childForFieldName('type_arguments')?.namedChildren.map((type) => type.text) ?? [], + at: rangeOf(node), + shadowed: bindingAt(firstName, owner) !== undefined, + }; + } + default: + return UNKNOWN; + } + }; + const calls = callNodes.map((node): JavaOverloadCallFact => { + const receiver = node.childForFieldName('object'); + let directReceiver = receiver; + while (directReceiver?.type === 'parenthesized_expression') + directReceiver = directReceiver.namedChildren[0] ?? null; + const receiverName = + directReceiver?.type === 'method_invocation' + ? directReceiver.childForFieldName('name')?.text + : undefined; + const callable = enclosing( + node, + (parent) => callableNodes.has(parent.type) && parent.type !== 'lambda_expression', + ); + return { + name: node.childForFieldName('name')?.text ?? '', + range: rangeOf(node), + arguments: (node.childForFieldName('arguments')?.namedChildren ?? []) + .filter((arg) => arg.type !== 'line_comment' && arg.type !== 'block_comment') + .map((arg) => expression(arg)), + ...(receiver === null + ? {} + : { receiver: expression(receiver), receiverSpelling: receiver.text }), + ...(receiverName === undefined || directReceiver === null + ? {} + : { receiverCall: { name: receiverName, range: rangeOf(directReceiver) } }), + receiverIsValue: + receiver !== null && + (receiver.type !== 'identifier' || bindingAt(receiver.text, receiver) !== undefined), + staticContext: callable !== undefined && modifiers(callable).includes('static'), + }; + }); + return { version: 1, packageName, imports, types, methods, calls }; +} + +/** Side-channel revival is fail-closed: malformed data never revives stale + * facts from a preceding file generation in a long-lived process. */ +export function isJavaOverloadFileFacts(value: unknown): value is JavaOverloadFileFacts { + if (value === null || typeof value !== 'object') return false; + const facts = value as JavaOverloadFileFacts; + const range = (r: Range): boolean => + r !== null && + typeof r === 'object' && + [r.startLine, r.startCol, r.endLine, r.endCol].every(Number.isSafeInteger); + const strings = (items: readonly string[]): boolean => + Array.isArray(items) && items.every((item) => typeof item === 'string'); + const argument = (arg: JavaArgumentFact, depth = 0): boolean => { + if (depth > 12 || arg === null || typeof arg !== 'object') return false; + if (arg.kind === 'null' || arg.kind === 'unknown') return true; + if (arg.kind === 'typed') return typeof arg.spelling === 'string' && range(arg.at); + return ( + arg.kind === 'factory' && + typeof arg.owner === 'string' && + ['asList', 'singletonList'].includes(arg.name) && + typeof arg.shadowed === 'boolean' && + range(arg.at) && + strings(arg.typeArguments) && + Array.isArray(arg.arguments) && + arg.arguments.every((item) => argument(item, depth + 1)) + ); + }; + return ( + facts.version === 1 && + typeof facts.packageName === 'string' && + Array.isArray(facts.imports) && + facts.imports.every( + (item) => + item !== null && + typeof item === 'object' && + typeof item.name === 'string' && + typeof item.wildcard === 'boolean' && + typeof item.static === 'boolean', + ) && + Array.isArray(facts.types) && + facts.types.every( + (type) => + type !== null && + typeof type === 'object' && + typeof type.name === 'string' && + typeof type.simpleName === 'string' && + typeof type.kind === 'string' && + range(type.range) && + range(type.visible) && + strings(type.bases) && + strings(type.typeParameters) && + (type.modifiers === undefined || strings(type.modifiers)), + ) && + Array.isArray(facts.methods) && + facts.methods.every( + (method) => + method !== null && + typeof method === 'object' && + typeof method.name === 'string' && + typeof method.owner === 'string' && + range(method.range) && + strings(method.parameterTypes) && + strings(method.modifiers) && + strings(method.typeParameters), + ) && + Array.isArray(facts.calls) && + facts.calls.every( + (call) => + call !== null && + typeof call === 'object' && + typeof call.name === 'string' && + range(call.range) && + typeof call.receiverIsValue === 'boolean' && + typeof call.staticContext === 'boolean' && + Array.isArray(call.arguments) && + call.arguments.every((arg) => argument(arg)) && + (call.receiver === undefined || argument(call.receiver)) && + (call.receiverSpelling === undefined || typeof call.receiverSpelling === 'string') && + (call.receiverCall === undefined || + (call.receiverCall !== null && + typeof call.receiverCall === 'object' && + typeof call.receiverCall.name === 'string' && + range(call.receiverCall.range))), + ) + ); +} diff --git a/gitnexus/src/core/ingestion/languages/java/overload-resolution.ts b/gitnexus/src/core/ingestion/languages/java/overload-resolution.ts new file mode 100644 index 000000000..9b8bb5612 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/overload-resolution.ts @@ -0,0 +1,1244 @@ +import type { Range, ReferenceSite, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SemanticModel } from '../../model/semantic-model.js'; +import type { ReceiverMemberResolution } from '../../scope-resolution/contract/scope-resolver.js'; +import { + findEnclosingClassDef, + isClassLike, + lookupNameClaim, +} from '../../scope-resolution/scope/walkers.js'; +import { getJavaPackageFact } from './package-facts.js'; +import { + getJavaOverloadCall, + getJavaOverloadFacts, + javaRangeContains, + type JavaArgumentFact, + type JavaOverloadCallFact, + type JavaOverloadFileFacts, + type JavaOverloadMethodFact, + type JavaOverloadTypeFact, +} from './overload-argument-facts.js'; +import { + boxedJavaType, + JAVA_PRIMITIVES, + javaInvocationConversion, + javaSubtype, + KNOWN_JAVA_PLATFORM_TYPES, + MAX_JAVA_TYPE_DEPTH, + sameJavaType, + selectJavaOverload, + splitJavaTypeArguments, + UNKNOWN_JAVA_TYPE, + type JavaOverloadCandidate, + type JavaPrimitive, + type JavaReferenceType, + type JavaType, + type JavaTypeRelations, + type JavaVerdict, +} from './overload-types.js'; + +interface TypeContext { + readonly filePath: string; + readonly at: Range; +} +interface FileFactIndex { + readonly methods: Map; + readonly ownedMethods: Map>; + readonly typeParameters: Map; +} +interface State { + readonly model: SemanticModel; + readonly scopes: ScopeResolutionIndexes; + readonly types: Map; + readonly methodFacts: Map; + readonly files: WeakMap; + readonly typeFacts: Map; + readonly hierarchyCompleteness: Map; + readonly relations: JavaTypeRelations; +} +const states = new WeakMap(); +const UNKNOWN_RANGE: Range = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 }; +const objectMethods = new Set([ + 'equals', + 'hashCode', + 'toString', + 'getClass', + 'clone', + 'finalize', + 'notify', + 'notifyAll', + 'wait', +]); + +function stateFor(scopes: ScopeResolutionIndexes, model: SemanticModel): State { + const existing = states.get(scopes); + if (existing !== undefined && existing.model === model) return existing; + const activeSubtype = new Set(); + const subtypeVerdicts = new Map(); + const state: State = { + scopes, + model, + types: new Map(), + methodFacts: new Map(), + files: new WeakMap(), + typeFacts: new Map(), + hierarchyCompleteness: new Map(), + relations: { + sourceSubtype: (from, to) => { + const key = JSON.stringify([from, to]); + if (activeSubtype.has(key) || activeSubtype.size >= 64) return 'unknown'; + const cacheKey = `${key}\0${activeSubtype.size}`; + const cached = subtypeVerdicts.get(cacheKey); + if (cached !== undefined) return cached; + activeSubtype.add(key); + try { + const verdict = sourceSubtype(from, to, state); + subtypeVerdicts.set(cacheKey, verdict); + return verdict; + } finally { + activeSubtype.delete(key); + } + }, + }, + }; + states.set(scopes, state); + return state; +} + +function packageName(filePath: string): string | undefined { + const fact = getJavaPackageFact(filePath); + return fact?.status === 'known' ? fact.packageName : getJavaOverloadFacts(filePath)?.packageName; +} + +function typeFact(definition: SymbolDefinition, state: State): JavaOverloadTypeFact | undefined { + if (state.typeFacts.has(definition.nodeId)) return state.typeFacts.get(definition.nodeId); + const facts = getJavaOverloadFacts(definition.filePath)?.types ?? []; + const candidates = facts.filter( + (type) => definition.nodeId.endsWith(`:${type.name}`) || definition.qualifiedName === type.name, + ); + const byPosition = + definition.declarationRange === undefined + ? undefined + : candidates.find((type) => javaRangeContains(type.range, definition.declarationRange!)); + const fact = byPosition ?? (candidates.length === 1 ? candidates[0] : undefined); + state.typeFacts.set(definition.nodeId, fact); + return fact; +} + +function methodName(definition: SymbolDefinition): string { + return definition.nodeId + .slice(definition.nodeId.lastIndexOf(':') + 1) + .split('#')[0]! + .split('.') + .at(-1)!; +} + +function fileFactIndex(facts: JavaOverloadFileFacts, state: State): FileFactIndex { + const cached = state.files.get(facts); + if (cached !== undefined) return cached; + const index: FileFactIndex = { + methods: new Map(), + ownedMethods: new Map(), + typeParameters: new Map(), + }; + for (const method of facts.methods) { + const named = index.methods.get(method.name) ?? []; + named.push(method); + index.methods.set(method.name, named); + const owner = + index.ownedMethods.get(method.owner) ?? new Map(); + const owned = owner.get(method.name) ?? []; + owned.push(method); + owner.set(method.name, owned); + index.ownedMethods.set(method.owner, owner); + } + for (const declaration of [...facts.methods, ...facts.types]) { + for (const name of declaration.typeParameters) { + const ranges = index.typeParameters.get(name) ?? { starts: [], widest: [] }; + ranges.starts.push(declaration.range); + index.typeParameters.set(name, ranges); + } + } + for (const ranges of index.typeParameters.values()) { + ranges.starts.sort((a, b) => a.startLine - b.startLine || a.startCol - b.startCol); + let widest = ranges.starts[0]!; + for (const range of ranges.starts) { + if ( + range.endLine > widest.endLine || + (range.endLine === widest.endLine && range.endCol > widest.endCol) + ) + widest = range; + ranges.widest.push(widest); + } + } + state.files.set(facts, index); + return index; +} + +function hasTypeParameter(index: FileFactIndex, name: string, at: Range): boolean { + const ranges = index.typeParameters.get(name); + if (ranges === undefined) return false; + // Find declarations starting before this context. The greatest end in that + // prefix answers containment, including nested and same-named parameters. + let low = 0, + high = ranges.starts.length; + while (low < high) { + const mid = (low + high) >>> 1; + const start = ranges.starts[mid]!; + if ( + start.startLine < at.startLine || + (start.startLine === at.startLine && start.startCol <= at.startCol) + ) + low = mid + 1; + else high = mid; + } + return low > 0 && javaRangeContains(ranges.widest[low - 1]!, at); +} + +function methodFact( + definition: SymbolDefinition, + state: State, +): JavaOverloadMethodFact | undefined { + if (state.methodFacts.has(definition.nodeId)) return state.methodFacts.get(definition.nodeId); + const owner = + definition.ownerId === undefined ? undefined : state.scopes.defs.get(definition.ownerId); + const ownerFact = owner === undefined ? undefined : typeFact(owner, state); + const facts = getJavaOverloadFacts(definition.filePath); + const index = facts === undefined ? undefined : fileFactIndex(facts, state); + const candidates = + (ownerFact === undefined + ? index?.methods.get(methodName(definition)) + : index?.ownedMethods.get(ownerFact.name)?.get(methodName(definition))) ?? []; + const byPosition = + definition.declarationRange === undefined + ? undefined + : candidates.find((method) => javaRangeContains(method.range, definition.declarationRange!)); + const exact = candidates.filter( + (method) => method.parameterTypes.join('\0') === (definition.parameterTypes ?? []).join('\0'), + ); + const fact = + byPosition ?? + (exact.length === 1 ? exact[0] : candidates.length === 1 ? candidates[0] : undefined); + state.methodFacts.set(definition.nodeId, fact); + return fact; +} + +function sourceQualifiedName(definition: SymbolDefinition): string | undefined { + const pkg = packageName(definition.filePath); + if (pkg === undefined) return undefined; + const name = + definition.qualifiedName ?? definition.nodeId.slice(definition.nodeId.lastIndexOf(':') + 1); + return pkg === '' || name.startsWith(`${pkg}.`) + ? name.replaceAll('$', '.') + : `${pkg}.${name.replaceAll('$', '.')}`; +} + +function sourceReference(definition: SymbolDefinition): JavaReferenceType { + return { kind: 'reference', name: definition.nodeId, sourceId: definition.nodeId, arguments: [] }; +} + +function sourceDefinitions(name: string, state: State): readonly SymbolDefinition[] { + // Scope and graph identities are distinct. Use the finalized name index, + // whose definitions share the inheritance and owner lookup key space. + const qualifiedNames = new Set([ + name, + ...state.model.types.lookupClassByName(name).flatMap((definition) => { + const qualified = definition.qualifiedName; + if (qualified === undefined) return []; + const pkg = packageName(definition.filePath); + // Model names include the package; nested scope names retain only owners. + return pkg && qualified.startsWith(`${pkg}.`) + ? [qualified, qualified.slice(pkg.length + 1)] + : [qualified]; + }), + ]); + return [ + ...new Set( + [...qualifiedNames].flatMap((qualified) => state.scopes.qualifiedNames.get(qualified)), + ), + ].flatMap((id) => { + const definition = state.scopes.defs.get(id); + return definition !== undefined && isClassLike(definition.type) ? [definition] : []; + }); +} + +function qualifiedReference(name: string, state: State): JavaType { + const matches = sourceDefinitions(name.split('.').at(-1)!, state).filter( + (definition) => sourceQualifiedName(definition) === name, + ); + const unique = [ + ...new Map(matches.map((definition) => [definition.nodeId, definition])).values(), + ]; + if (unique.length > 1) return UNKNOWN_JAVA_TYPE; + if (unique.length === 1) return sourceReference(unique[0]!); + return { kind: 'reference', name, arguments: [] }; +} + +function resolveTypeName(name: string, context: TypeContext, state: State): JavaType { + const facts = getJavaOverloadFacts(context.filePath); + if (facts === undefined) + return name.includes('.') ? qualifiedReference(name, state) : UNKNOWN_JAVA_TYPE; + if (hasTypeParameter(fileFactIndex(facts, state), name, context.at)) return UNKNOWN_JAVA_TYPE; + const first = name.split('.')[0]!; + const lexical = facts.types.filter( + (type) => type.simpleName === first && javaRangeContains(type.visible, context.at), + ); + let nearest: JavaOverloadTypeFact | undefined; + for (const type of lexical) + if (nearest === undefined || javaRangeContains(nearest.visible, type.visible)) nearest = type; + if (nearest !== undefined) { + if (name.includes('.')) return UNKNOWN_JAVA_TYPE; // qualified nested lookup needs a proven identity + const matches = [ + ...sourceDefinitions(nearest.name, state), + ...sourceDefinitions(nearest.simpleName, state), + ].filter( + (definition) => + definition.filePath === context.filePath && + (definition.nodeId.endsWith(`:${nearest!.name}`) || + definition.qualifiedName === nearest!.name), + ); + const unique = [ + ...new Map(matches.map((definition) => [definition.nodeId, definition])).values(), + ]; + return unique.length === 1 ? sourceReference(unique[0]!) : UNKNOWN_JAVA_TYPE; + } + if (name.includes('.')) return qualifiedReference(name, state); + // Static imports can name types, values or methods. Require a source type + // identity before resolving one; an unproven import must not become a JDK type. + if ( + facts.imports.some( + (item) => item.static && (item.wildcard || item.name.split('.').at(-1) === name), + ) + ) { + const explicit = facts.imports.filter( + (item) => item.static && !item.wildcard && item.name.split('.').at(-1) === name, + ); + if (explicit.length === 1) { + const imported = qualifiedReference(explicit[0]!.name, state); + if (imported.kind === 'reference' && imported.sourceId !== undefined) return imported; + } + return UNKNOWN_JAVA_TYPE; + } + for (const enclosing of facts.types.filter((type) => javaRangeContains(type.range, context.at))) { + for (const owner of sourceDefinitions(enclosing.name, state).filter( + (definition) => definition.filePath === context.filePath, + )) { + for (const ancestorId of state.scopes.methodDispatch.mroFor(owner.nodeId)) { + const ancestor = state.scopes.defs.get(ancestorId); + const ancestorFact = ancestor === undefined ? undefined : typeFact(ancestor, state); + if ( + ancestor !== undefined && + ancestorFact !== undefined && + getJavaOverloadFacts(ancestor.filePath)?.types.some( + (type) => + type.simpleName === name && + type.name !== ancestorFact.name && + javaRangeContains(ancestorFact.range, type.range), + ) + ) + return UNKNOWN_JAVA_TYPE; + } + } + } + const explicit = facts.imports.filter( + (item) => !item.static && !item.wildcard && item.name.split('.').at(-1) === name, + ); + if (explicit.length > 0) + return explicit.length === 1 ? qualifiedReference(explicit[0]!.name, state) : UNKNOWN_JAVA_TYPE; + const inPackage = facts.packageName === '' ? name : `${facts.packageName}.${name}`; + const local = sourceDefinitions(name, state).filter( + (definition) => sourceQualifiedName(definition) === inPackage, + ); + if (local.length > 0) { + const unique = [ + ...new Map(local.map((definition) => [definition.nodeId, definition])).values(), + ]; + return unique.length === 1 ? sourceReference(unique[0]!) : UNKNOWN_JAVA_TYPE; + } + const platform = `java.lang.${name}`; + const imported: JavaType[] = KNOWN_JAVA_PLATFORM_TYPES.has(platform) + ? [qualifiedReference(platform, state)] + : []; + let unknownWildcard = false; + for (const item of facts.imports) { + if (item.static || !item.wildcard) continue; + const qualified = `${item.name}.${name}`; + const candidates = sourceDefinitions(name, state).filter( + (definition) => sourceQualifiedName(definition) === qualified, + ); + if (candidates.length > 0) imported.push(...candidates.map(sourceReference)); + else if (KNOWN_JAVA_PLATFORM_TYPES.has(qualified)) + imported.push(qualifiedReference(qualified, state)); + else if (!['java.lang', 'java.util', 'java.io'].includes(item.name)) unknownWildcard = true; + } + if (unknownWildcard) return UNKNOWN_JAVA_TYPE; + const unique = [...new Map(imported.map((type) => [JSON.stringify(type), type])).values()]; + if (unique.length === 1) return unique[0]!; + // An unqualified external declaration without a source/import witness is not + // promoted to a platform class merely because its spelling looks familiar. + return UNKNOWN_JAVA_TYPE; +} + +function parseType(spelling: string, context: TypeContext, state: State, depth = 0): JavaType { + if (depth >= MAX_JAVA_TYPE_DEPTH || spelling.length > 16_384) return UNKNOWN_JAVA_TYPE; + const key = `${context.filePath}:${context.at.startLine}:${context.at.startCol}:${depth}:${spelling}`; + const cached = state.types.get(key); + if (cached !== undefined) return cached; + let text = spelling + .trim() + .replace(/@[\w.]+(?:\([^()]*\))?\s*/g, '') + .replaceAll(/\s+/g, ''); + let dimensions = 0; + while (text.endsWith('[]')) { + dimensions++; + if (depth + dimensions >= MAX_JAVA_TYPE_DEPTH) return UNKNOWN_JAVA_TYPE; + text = text.slice(0, -2); + } + let type: JavaType; + if (JAVA_PRIMITIVES.has(text as JavaPrimitive)) + type = { kind: 'primitive', name: text as JavaPrimitive }; + else { + const open = text.indexOf('<'); + const name = open < 0 ? text : text.slice(0, open); + if ( + !/^[\p{L}_$][\p{L}\p{N}_$]*(?:\.[\p{L}_$][\p{L}\p{N}_$]*)*$/u.test(name) || + (open >= 0 && !text.endsWith('>')) + ) + type = UNKNOWN_JAVA_TYPE; + else { + type = resolveTypeName(name, context, state); + if (open >= 0) { + const args = splitJavaTypeArguments(text.slice(open + 1, -1)); + type = + args === undefined || type.kind !== 'reference' + ? UNKNOWN_JAVA_TYPE + : { + ...type, + arguments: args.map((argument) => + parseType(argument, context, state, depth + dimensions + 1), + ), + }; + } + } + } + while (dimensions-- > 0) type = { kind: 'array', component: type }; + state.types.set(key, type); + return type; +} + +function sourceSubtype(from: JavaReferenceType, to: JavaReferenceType, state: State): JavaVerdict { + if (from.sourceId === undefined) + return KNOWN_JAVA_PLATFORM_TYPES.has(from.name) && to.sourceId !== undefined ? 'no' : 'unknown'; + const definition = state.scopes.defs.get(from.sourceId); + if (definition === undefined) return 'unknown'; + if ( + to.sourceId !== undefined && + state.scopes.methodDispatch.mroFor(from.sourceId).includes(to.sourceId) + ) { + return from.arguments.length === 0 && to.arguments.length === 0 ? 'yes' : 'unknown'; + } + const fact = typeFact(definition, state); + if (fact === undefined) return 'unknown'; + if (from.arguments.length > 0 || fact.typeParameters.length > 0) return 'unknown'; + // Enum's implicit Enum superclass and Record's superclass are not source + // heritage edges. Do not prove disjointness from an empty explicit base list. + if (fact.kind === 'enum_declaration' || fact.kind === 'record_declaration') return 'unknown'; + let uncertain = false; + for (const base of fact.bases) { + const result = javaSubtype( + parseType(base, { filePath: definition.filePath, at: fact.range }, state), + to, + state.relations, + ); + if (result === 'yes') return 'yes'; + if (result === 'unknown') uncertain = true; + } + return uncertain ? 'unknown' : 'no'; +} + +function normalizedCandidate( + definition: SymbolDefinition, + state: State, +): JavaOverloadCandidate { + const fact = methodFact(definition, state); + const raw = fact?.parameterTypes ?? definition.parameterTypes ?? []; + const variadic = raw.at(-1) === 'varargs'; + const formalTypes = variadic ? raw.slice(0, -1) : raw; + // Synthetic scope definitions can carry arity without formal type spellings. + // Missing or inconsistent metadata cannot prove a zero-parameter signature + // (or remove an unknown declaration from an inherited overload family). + const missingParameterTypes = + fact === undefined && + definition.parameterCount !== undefined && + definition.parameterCount !== formalTypes.length; + const parameters = missingParameterTypes + ? Array.from({ length: definition.parameterCount! }, () => UNKNOWN_JAVA_TYPE) + : formalTypes.map((type) => + parseType( + type, + { + filePath: definition.filePath, + at: fact?.range ?? definition.declarationRange ?? UNKNOWN_RANGE, + }, + state, + ), + ); + if (variadic && parameters.length > 0) + parameters[parameters.length - 1] = { kind: 'array', component: parameters.at(-1)! }; + return { + definition, + parameters, + variadic, + unsupportedGeneric: + missingParameterTypes || + (fact?.typeParameters.length ?? 0) > 0 || + (fact === undefined && (definition.parameterCount ?? parameters.length) !== 0), + }; +} + +function argumentType(fact: JavaArgumentFact, filePath: string, state: State): JavaType { + return fact.kind === 'typed' + ? parseType(fact.spelling, { filePath, at: fact.at }, state) + : fact.kind === 'null' + ? { kind: 'null' } + : UNKNOWN_JAVA_TYPE; +} + +function factoryConversion( + fact: Extract, + target: JavaType, + filePath: string, + site: ReferenceSite, + state: State, +): JavaVerdict { + if ( + fact.shadowed || + fact.arguments.length === 0 || + (fact.name === 'singletonList' && fact.arguments.length !== 1) || + fact.typeArguments.length > 1 + ) + return 'unknown'; + const rootName = fact.owner.split('.')[0]!; + const claim = lookupNameClaim(site.inScope, rootName, state.scopes); + if (claim.bindings.some((binding) => !isClassLike(binding.def.type))) return 'unknown'; + const enclosing = findEnclosingClassDef(site.inScope, state.scopes); + if ( + enclosing !== undefined && + [enclosing.nodeId, ...state.scopes.methodDispatch.mroFor(enclosing.nodeId)].some( + (owner) => state.model.fields.lookupAllByOwner(owner, rootName).length > 0, + ) + ) + return 'unknown'; + const owner = resolveTypeName(fact.owner, { filePath, at: fact.at }, state); + const expectedOwner = fact.name === 'asList' ? 'java.util.Arrays' : 'java.util.Collections'; + if (owner.kind !== 'reference' || owner.sourceId !== undefined || owner.name !== expectedOwner) + return 'unknown'; + let elements = fact.arguments.map((argument) => argumentType(argument, filePath, state)); + if (elements.some((element) => element.kind === 'unknown' || element.kind === 'null')) + return 'unknown'; + const explicit = + fact.typeArguments[0] === undefined + ? undefined + : parseType(fact.typeArguments[0], { filePath, at: fact.at }, state); + if (explicit?.kind === 'primitive' || explicit?.kind === 'unknown') return 'unknown'; + if (fact.name === 'asList' && elements.length === 1 && elements[0]?.kind === 'array') { + const component = elements[0].component; + // Fixed-array applicability precedes varargs. Target typing must not turn + // asList(String[]) into List unless explicit T requires it. + if (explicit === undefined && component.kind !== 'primitive') elements = [component]; + else if ( + explicit !== undefined && + javaSubtype(elements[0], { kind: 'array', component: explicit }, state.relations) === 'yes' + ) + elements = [component]; + } + const boxed = elements.map(boxedJavaType); + if (boxed.some((element) => sameJavaType(element, boxed[0]!) !== 'yes')) return 'unknown'; + if (target.kind === 'unknown') return 'unknown'; + if (target.kind !== 'reference') return 'no'; + const elementTarget = + explicit ?? + (['java.util.List', 'java.util.Collection', 'java.lang.Iterable'].includes(target.name) && + target.sourceId === undefined && + target.arguments.length === 1 + ? target.arguments[0] + : boxed[0]); + if (elementTarget === undefined) return 'unknown'; + const converted = elements.map((element) => + javaInvocationConversion(element, elementTarget, 'loose', state.relations), + ); + if (converted.includes('no')) return 'no'; + if (converted.includes('unknown')) return 'unknown'; + return javaSubtype( + { kind: 'reference', name: 'java.util.List', arguments: [elementTarget] }, + target, + state.relations, + ); +} + +function enclosingTopLevel(definition: SymbolDefinition, state: State): string | undefined { + const fact = typeFact(definition, state); + if (fact === undefined) return undefined; + let outermost = fact; + for (const candidate of getJavaOverloadFacts(definition.filePath)?.types ?? []) { + if (javaRangeContains(candidate.range, outermost.range)) outermost = candidate; + } + const pkg = packageName(definition.filePath); + return pkg === undefined ? undefined : `${pkg}:${outermost.name}`; +} + +function isTypeQualifier( + call: JavaOverloadCallFact, + caller: SymbolDefinition | undefined, + state: State, +): boolean { + const name = call.receiverSpelling; + if ( + caller === undefined || + call.receiverIsValue || + name === undefined || + name === 'this' || + name === 'super' + ) + return false; + if ( + [caller.nodeId, ...state.scopes.methodDispatch.mroFor(caller.nodeId)].some( + (ownerId) => state.model.fields.lookupAllByOwner(ownerId, name).length > 0, + ) + ) + return false; + return ( + resolveTypeName(name, { filePath: caller.filePath, at: call.range }, state).kind === 'reference' + ); +} + +function inheritsPackageMember( + receiver: SymbolDefinition, + declaringOwner: SymbolDefinition, + state: State, + seen = new Set(), +): JavaVerdict { + if (receiver.nodeId === declaringOwner.nodeId) return 'yes'; + if (seen.has(receiver.nodeId) || seen.size >= 64) return 'unknown'; + seen.add(receiver.nodeId); + const ownerPackage = packageName(declaringOwner.filePath); + const receiverPackage = packageName(receiver.filePath); + if (ownerPackage === undefined || receiverPackage === undefined) return 'unknown'; + // Once a class outside the declaration's package fails to inherit this + // member, a later subclass cannot regain it by returning to that package. + if (ownerPackage !== receiverPackage) return 'no'; + const fact = typeFact(receiver, state); + if (fact === undefined) return 'unknown'; + let uncertain = false; + for (const spelling of fact.bases) { + const base = parseType(spelling, { filePath: receiver.filePath, at: fact.range }, state); + if (base.kind !== 'reference' || base.sourceId === undefined) { + uncertain = true; + continue; + } + const definition = state.scopes.defs.get(base.sourceId); + const inherited = + definition === undefined + ? 'unknown' + : inheritsPackageMember(definition, declaringOwner, state, new Set(seen)); + if (inherited === 'yes') return 'yes'; + if (inherited === 'unknown') uncertain = true; + } + return uncertain ? 'unknown' : 'no'; +} + +function eligible( + definition: SymbolDefinition, + receiver: SymbolDefinition, + site: ReferenceSite, + call: JavaOverloadCallFact | undefined, + state: State, +): JavaVerdict { + const fact = methodFact(definition, state); + if (fact === undefined) return definition.isSynthetic ? 'yes' : 'unknown'; + const owner = + definition.ownerId === undefined ? undefined : state.scopes.defs.get(definition.ownerId); + if (owner === undefined) return 'unknown'; + const inherited = owner.nodeId !== receiver.nodeId; + const isStatic = fact.modifiers.includes('static'); + const ownerKind = typeFact(owner, state)?.kind; + if ( + inherited && + (fact.modifiers.includes('private') || (ownerKind === 'interface_declaration' && isStatic)) + ) + return 'no'; + if (fact.modifiers.includes('public')) return 'yes'; + const caller = findEnclosingClassDef(site.inScope, state.scopes); + const callerPackage = caller === undefined ? undefined : packageName(caller.filePath); + const ownerPackage = packageName(owner.filePath); + const receiverPackage = packageName(receiver.filePath); + if (fact.modifiers.includes('private')) { + const callerNest = caller === undefined ? undefined : enclosingTopLevel(caller, state); + const ownerNest = enclosingTopLevel(owner, state); + return callerNest === undefined || ownerNest === undefined + ? 'unknown' + : callerNest === ownerNest + ? 'yes' + : 'no'; + } + if (!fact.modifiers.includes('protected')) { + if (inherited && receiverPackage !== ownerPackage) return 'no'; + if (inherited) { + const membership = inheritsPackageMember(receiver, owner, state); + if (membership !== 'yes') return membership; + } + return callerPackage === undefined || ownerPackage === undefined + ? 'unknown' + : callerPackage === ownerPackage + ? 'yes' + : 'no'; + } + if (callerPackage !== undefined && callerPackage === ownerPackage) return 'yes'; + if (caller === undefined) return 'unknown'; + // JLS 6.6.2.1 permits access within any enclosing subclass body, but + // an instance receiver must be that qualifying subclass or its subtype. + const receiverMro = state.scopes.methodDispatch.mroFor(receiver.nodeId); + for (const scopeId of [site.inScope, ...state.scopes.scopeTree.getAncestors(site.inScope)]) { + const scope = state.scopes.scopeTree.getScope(scopeId); + if (scope?.kind !== 'Class') continue; + const enclosing = scope.ownedDefs.find((def) => isClassLike(def.type)); + if ( + enclosing === undefined || + !state.scopes.methodDispatch.mroFor(enclosing.nodeId).includes(owner.nodeId) + ) + continue; + if ( + isStatic || + call?.receiverSpelling === 'super' || + receiver.nodeId === enclosing.nodeId || + receiverMro.includes(enclosing.nodeId) + ) + return 'yes'; + } + return 'no'; +} + +function coversObjectSignature(owner: SymbolDefinition, name: string, state: State): boolean { + if (!['equals', 'hashCode', 'toString', 'clone', 'finalize'].includes(name)) return false; + return [owner.nodeId, ...state.scopes.methodDispatch.mroFor(owner.nodeId)].some((ownerId) => + state.model.methods.lookupAllByOwner(ownerId, name).some((method) => { + const fact = methodFact(method, state); + if ( + fact === undefined || + fact.modifiers.includes('static') || + fact.modifiers.includes('private') + ) + return false; + const parameters = normalizedCandidate(method, state).parameters; + return name === 'equals' + ? parameters.length === 1 && + sameJavaType(parameters[0]!, { + kind: 'reference', + name: 'java.lang.Object', + arguments: [], + }) === 'yes' + : parameters.length === 0; + }), + ); +} + +function hierarchyComplete( + owner: SymbolDefinition, + memberName: string, + state: State, + seen = new Set(), + objectCovered = coversObjectSignature(owner, memberName, state), +): boolean { + if (seen.has(owner.nodeId) || seen.size >= 64) return false; + // Reuse shared ancestors without letting a shallower proof bypass the + // traversal bound on another path. Failed/cyclic inventories are cached too. + const key = `${owner.nodeId}\0${memberName}\0${objectCovered}\0${seen.size}`; + const cached = state.hierarchyCompleteness.get(key); + if (cached !== undefined) return cached; + state.hierarchyCompleteness.set(key, false); + seen.add(owner.nodeId); + const fact = typeFact(owner, state); + if (fact === undefined) return false; + if (objectMethods.has(memberName) && !objectCovered) return false; + for (const raw of fact.bases) { + const base = parseType(raw, { filePath: owner.filePath, at: fact.range }, state); + if (base.kind !== 'reference') return false; + if (base.sourceId === undefined) { + if (base.name === 'java.lang.Object' && (!objectMethods.has(memberName) || objectCovered)) + continue; + // Runnable declares only run(). An indexed zero-argument override + // represents that signature; its other member names come from Object. + if (base.name === 'java.lang.Runnable' && !objectMethods.has(memberName)) { + if ( + memberName !== 'run' || + state.model.methods + .lookupAllByOwner(owner.nodeId, memberName) + .some((method) => methodFact(method, state)?.parameterTypes.length === 0) + ) + continue; + } + return false; + } + const definition = state.scopes.defs.get(base.sourceId); + if ( + definition === undefined || + !hierarchyComplete(definition, memberName, state, new Set(seen), objectCovered) + ) + return false; + } + state.hierarchyCompleteness.set(key, true); + return true; +} + +function receiverInventory( + owner: SymbolDefinition, + name: string, + site: ReferenceSite, + call: JavaOverloadCallFact | undefined, + state: State, +) { + const declarations: SymbolDefinition[] = []; + const originalDeclarationIds = new Set(); + const normalized: JavaOverloadCandidate[] = []; + let complete = hierarchyComplete(owner, name, state); + for (const ownerId of [owner.nodeId, ...state.scopes.methodDispatch.mroFor(owner.nodeId)]) { + for (const declaration of state.model.methods.lookupAllByOwner(ownerId, name)) { + if (originalDeclarationIds.has(declaration.nodeId)) continue; + originalDeclarationIds.add(declaration.nodeId); + const verdict = eligible(declaration, owner, site, call, state); + if (verdict === 'no') continue; + declarations.push(declaration); + if (verdict === 'unknown') complete = false; + const candidate = normalizedCandidate(declaration, state); + // A more-derived override removes only its override-equivalent ancestor, + // never the other overloads on that ancestor. + const overridden = normalized.some( + (prior) => + prior.parameters.length === candidate.parameters.length && + prior.parameters.every( + (parameter, i) => sameJavaType(parameter, candidate.parameters[i]!) === 'yes', + ) && + prior.definition.ownerId !== undefined && + declaration.ownerId !== undefined && + state.scopes.methodDispatch + .mroFor(prior.definition.ownerId) + .includes(declaration.ownerId), + ); + if (!overridden) normalized.push(candidate); + } + } + return { + declarations, + normalized, + complete, + originalDeclarationCount: originalDeclarationIds.size, + }; +} + +function selectCallOverload( + candidates: readonly JavaOverloadCandidate[], + call: JavaOverloadCallFact, + filePath: string, + site: ReferenceSite, + state: State, +) { + return selectJavaOverload( + candidates, + call.arguments.length, + (index, target, phase) => { + const argument = call.arguments[index]!; + return argument.kind === 'factory' + ? factoryConversion(argument, target, filePath, site, state) + : javaInvocationConversion( + argumentType(argument, filePath, state), + target, + phase, + state.relations, + ); + }, + state.relations, + ); +} + +/** Java claims full overload families before shared owner/rank pruning. A + * complete original singleton with opaque arguments may retain the historical + * unique-member inference; no pruned singleton or incomplete inventory may. */ +export function resolveJavaReceiverMember( + ownerDef: SymbolDefinition, + memberName: string, + site: ReferenceSite, + scopes: ScopeResolutionIndexes, + model: SemanticModel, +): ReceiverMemberResolution | undefined { + if (site.kind !== 'call' || site.callForm === 'constructor') return undefined; + const state = stateFor(scopes, model); + const filePath = scopes.scopeTree.getScope(site.inScope)?.filePath; + const call = + filePath === undefined ? undefined : getJavaOverloadCall(filePath, site.atRange, site.name); + let owner = ownerDef; + let runnableReceiver = false; + if (call?.receiver?.kind === 'typed' && filePath !== undefined) { + const declared = argumentType(call.receiver, filePath, state); + if (declared.kind === 'reference' && declared.sourceId !== undefined) + owner = scopes.defs.get(declared.sourceId) ?? owner; + else if (declared.kind === 'reference' && declared.sourceId === undefined) { + runnableReceiver = + declared.name === 'java.lang.Runnable' && + memberName === 'run' && + typeFact(owner, state)?.bases.some((base) => { + const type = parseType( + base, + { filePath: owner.filePath, at: typeFact(owner, state)!.range }, + state, + ); + return ( + type.kind === 'reference' && + type.sourceId === undefined && + type.name === 'java.lang.Runnable' + ); + }) === true; + if (!runnableReceiver) { + const candidateIds = [owner.nodeId, ...scopes.methodDispatch.mroFor(owner.nodeId)].flatMap( + (id) => + model.methods.lookupAllByOwner(id, memberName).map((candidate) => candidate.nodeId), + ); + return candidateIds.length === 0 + ? undefined + : { + kind: 'unknown', + candidateIds, + candidateSetComplete: false, + detail: 'Declared receiver identity does not match the indexed owner', + }; + } + } + } + const caller = findEnclosingClassDef(site.inScope, scopes); + const inventory = receiverInventory(owner, memberName, site, call, state); + if (inventory.declarations.length === 0) return undefined; + const candidateIds = inventory.declarations.map((candidate) => candidate.nodeId); + if (!inventory.complete) + return { + kind: 'unknown', + candidateIds, + candidateSetComplete: false, + detail: 'Incomplete Java inherited member inventory or accessibility evidence', + }; + if (filePath === undefined || call === undefined) { + return inventory.originalDeclarationCount === 1 + ? undefined + : { + kind: 'unknown', + candidateIds, + candidateSetComplete: true, + detail: 'Java argument capture facts are unavailable', + }; + } + const decision = selectCallOverload( + runnableReceiver + ? inventory.normalized.filter( + (candidate) => candidate.parameters.length === 0 && !candidate.variadic, + ) + : inventory.normalized, + call, + filePath, + site, + state, + ); + if (decision.kind === 'resolved') { + const selected = methodFact(decision.definition, state); + // JLS 15.12.3 checks invocation form AFTER choosing the most-specific + // method. Removing instance methods earlier could invent a less-specific + // static target for a program javac rejects. + if ( + selected !== undefined && + !selected.modifiers.includes('static') && + ((call.staticContext && call.receiverSpelling === undefined) || + isTypeQualifier(call, caller, state)) + ) { + return { + kind: 'inapplicable', + candidateIds, + candidateSetComplete: true, + detail: 'Selected instance method requires an instance receiver', + }; + } + return { kind: 'resolved', definition: decision.definition }; + } + if (decision.kind === 'unknown' && inventory.originalDeclarationCount === 1) return undefined; + return { + kind: decision.kind, + candidateIds, + candidateSetComplete: true, + ...(decision.kind === 'unknown' + ? { detail: 'Unsupported Java argument, generic constraint, or subtype evidence' } + : {}), + }; +} + +function staticImportOwnerAccess( + owner: SymbolDefinition, + filePath: string, + state: State, +): JavaVerdict { + const fact = typeFact(owner, state); + if (fact === undefined) return 'unknown'; + if (owner.isSynthetic) return 'no'; + const samePackage = packageName(owner.filePath) === packageName(filePath); + // Imports are outside every class body. Check the named type and all its + // enclosing types; nestmate/subclass privileges do not apply here. + for (const enclosing of getJavaOverloadFacts(owner.filePath)?.types ?? []) { + if (!javaRangeContains(enclosing.range, fact.range)) continue; + if (enclosing.modifiers === undefined) return 'unknown'; + if (enclosing.modifiers.includes('private')) return 'no'; + if (!enclosing.modifiers.includes('public') && !samePackage) return 'no'; + } + return 'yes'; +} + +export function resolveJavaUnqualifiedCall( + site: ReferenceSite, + scopes: ScopeResolutionIndexes, + model: SemanticModel, +): ReceiverMemberResolution | undefined { + if (site.kind !== 'call' || site.callForm === 'constructor') return undefined; + let scopeId: string | null = site.inScope; + const visited = new Set(); + while (scopeId !== null && !visited.has(scopeId)) { + visited.add(scopeId); + const scope = scopes.scopeTree.getScope(scopeId); + if (scope === undefined) break; + if (scope.kind === 'Class') { + const owner = scope.ownedDefs.find((definition) => isClassLike(definition.type)); + if ( + owner !== undefined && + [owner.nodeId, ...scopes.methodDispatch.mroFor(owner.nodeId)].some( + (id) => model.methods.lookupAllByOwner(id, site.name).length > 0, + ) + ) { + return resolveJavaReceiverMember(owner, site.name, site, scopes, model); + } + } + scopeId = scope.parent; + } + const filePath = scopes.scopeTree.getScope(site.inScope)?.filePath; + const imported = + filePath === undefined + ? [] + : (getJavaOverloadFacts(filePath)?.imports.filter( + (item) => item.static && (item.wildcard || item.name.split('.').at(-1) === site.name), + ) ?? []); + if (imported.length > 0) { + const state = stateFor(scopes, model); + const call = getJavaOverloadCall(filePath!, site.atRange, site.name); + const claim = lookupNameClaim(site.inScope, site.name, scopes); + const candidates = new Map< + string, + { candidate: JavaOverloadCandidate; explicit: boolean } + >(); + let complete = true; + for (const item of imported) { + const ownerName = item.wildcard ? item.name : item.name.slice(0, item.name.lastIndexOf('.')); + const type = qualifiedReference(ownerName, state); + const owner = + type.kind === 'reference' && type.sourceId !== undefined + ? scopes.defs.get(type.sourceId) + : undefined; + if (owner === undefined) { + complete = false; + continue; + } + const ownerAccess = staticImportOwnerAccess(owner, filePath!, state); + if (ownerAccess !== 'yes') { + if (ownerAccess === 'unknown') complete = false; + continue; + } + const inventory = receiverInventory(owner, site.name, site, call, state); + complete &&= inventory.complete; + for (const candidate of inventory.normalized) { + const fact = methodFact(candidate.definition, state); + if (fact === undefined) { + complete = false; + continue; + } + if (!fact.modifiers.includes('static') || fact.modifiers.includes('private')) continue; + // Static-import access is checked at the compilation unit, outside + // a subclass body (JLS 7.5.3), so protected does not grant cross-package access. + if ( + !fact.modifiers.includes('public') && + packageName(candidate.definition.filePath) !== packageName(filePath!) + ) + continue; + const previous = candidates.get(candidate.definition.nodeId); + candidates.set(candidate.definition.nodeId, { + candidate, + explicit: !item.wildcard || previous?.explicit === true, + }); + } + } + const all = [...candidates.values()]; + const visible = all.filter( + ({ candidate, explicit }) => + explicit || + !all.some( + (other) => + other.explicit && + other.candidate.parameters.length === candidate.parameters.length && + other.candidate.parameters.every( + (parameter, index) => sameJavaType(parameter, candidate.parameters[index]!) === 'yes', + ), + ), + ); + const candidateIds = [ + ...new Set([ + ...all.map(({ candidate }) => candidate.definition.nodeId), + ...claim.bindings + .filter((binding) => binding.def.type === 'Method' || binding.def.type === 'Function') + .map((binding) => binding.def.nodeId), + ]), + ]; + if (complete && call !== undefined) { + const decision = selectCallOverload( + visible.map(({ candidate }) => candidate), + call, + filePath!, + site, + state, + ); + if (decision.kind === 'resolved') + return { kind: 'resolved', definition: decision.definition }; + if (decision.kind === 'unknown' && all.length === 1) return undefined; + return { kind: decision.kind, candidateIds, candidateSetComplete: true }; + } + return { + kind: 'unknown', + candidateIds, + candidateSetComplete: false, + detail: 'Incomplete static-import overload inventory or argument evidence', + }; + } + return undefined; +} + +export function resolveJavaReceiverCallSite( + site: ReferenceSite, + scopes: ScopeResolutionIndexes, +): { readonly name: string; readonly range: Range } | undefined { + const filePath = scopes.scopeTree.getScope(site.inScope)?.filePath; + return filePath === undefined + ? undefined + : getJavaOverloadCall(filePath, site.atRange, site.name)?.receiverCall; +} + +/** Ground only a whole formal owner type variable from explicit source + * heritage. This preserves generic interface fan-out without treating a + * concrete implementation's competing overload as the selected signature. + * Nested substitutions and generic method inference remain unknown. */ +function signatureForImplementation( + primary: SymbolDefinition, + owner: SymbolDefinition, + state: State, +): JavaOverloadCandidate { + const signature = normalizedCandidate(primary, state); + const method = methodFact(primary, state); + const primaryOwner = + primary.ownerId === undefined ? undefined : state.scopes.defs.get(primary.ownerId); + const declaration = primaryOwner === undefined ? undefined : typeFact(primaryOwner, state); + if ( + method === undefined || + method.typeParameters.length > 0 || + declaration === undefined || + declaration.typeParameters.length === 0 + ) + return signature; + const witnesses: (readonly JavaType[])[] = []; + for (const id of [owner.nodeId, ...state.scopes.methodDispatch.mroFor(owner.nodeId)]) { + const implementation = state.scopes.defs.get(id); + const fact = implementation === undefined ? undefined : typeFact(implementation, state); + if (implementation === undefined || fact === undefined) continue; + for (const spelling of fact.bases) { + const base = parseType( + spelling, + { filePath: implementation.filePath, at: fact.range }, + state, + ); + if ( + base.kind === 'reference' && + base.sourceId === primary.ownerId && + base.arguments.length === declaration.typeParameters.length + ) + witnesses.push(base.arguments); + } + } + const arguments_ = witnesses[0]; + if ( + arguments_ === undefined || + !witnesses.every((witness) => + witness.every((type, i) => sameJavaType(type, arguments_[i]!) === 'yes'), + ) + ) + return signature; + return { + ...signature, + parameters: signature.parameters.map((parameter, index) => { + const position = declaration.typeParameters.indexOf( + method.parameterTypes[index]?.trim() ?? '', + ); + return position < 0 + ? parameter + : signature.variadic && index === signature.parameters.length - 1 + ? { kind: 'array', component: arguments_[position]! } + : arguments_[position]!; + }), + }; +} + +export function resolveJavaSelectedMethodImplementation( + primary: SymbolDefinition, + owner: SymbolDefinition, + scopes: ScopeResolutionIndexes, + model: SemanticModel, +): ReceiverMemberResolution | undefined { + const state = stateFor(scopes, model); + const primaryFact = methodFact(primary, state); + if ( + primaryFact?.modifiers.some( + (modifier) => modifier === 'static' || modifier === 'private' || modifier === 'final', + ) + ) + return undefined; + const signature = signatureForImplementation(primary, owner, state); + const candidates: SymbolDefinition[] = []; + for (const ownerId of [owner.nodeId, ...scopes.methodDispatch.mroFor(owner.nodeId)]) { + for (const candidate of model.methods.lookupAllByOwner(ownerId, methodName(primary))) { + if (candidate.nodeId === primary.nodeId) continue; + const fact = methodFact(candidate, state); + const declaredOwner = + candidate.ownerId === undefined ? undefined : scopes.defs.get(candidate.ownerId); + if ( + fact?.modifiers.includes('abstract') || + (declaredOwner !== undefined && + typeFact(declaredOwner, state)?.kind === 'interface_declaration' && + !fact?.modifiers.includes('default')) + ) + continue; + const other = normalizedCandidate(candidate, state); + if (other.parameters.length !== signature.parameters.length) continue; + const comparisons = other.parameters.map((parameter, i) => + sameJavaType(parameter, signature.parameters[i]!), + ); + if (comparisons.includes('no')) continue; + if (!comparisons.includes('unknown') && candidates.length === 0) + return { kind: 'resolved', definition: candidate }; + candidates.push(candidate); + } + } + return candidates.length === 0 + ? undefined + : { + kind: 'unknown', + candidateIds: candidates.map((candidate) => candidate.nodeId), + candidateSetComplete: true, + detail: 'Generic override signature could not be established', + }; +} diff --git a/gitnexus/src/core/ingestion/languages/java/overload-types.ts b/gitnexus/src/core/ingestion/languages/java/overload-types.ts new file mode 100644 index 000000000..eea2cb544 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/overload-types.ts @@ -0,0 +1,345 @@ +/** Java method-invocation conversions (JLS 5.3) and the phase ordering and + * specificity rules of JLS 15.12.2. Unknown evidence is never a conversion. */ +export type JavaVerdict = 'yes' | 'no' | 'unknown'; +export type JavaPrimitive = + | 'boolean' + | 'byte' + | 'short' + | 'char' + | 'int' + | 'long' + | 'float' + | 'double'; +export type JavaType = + | { readonly kind: 'primitive'; readonly name: JavaPrimitive } + | { + readonly kind: 'reference'; + readonly name: string; + readonly arguments: readonly JavaType[]; + readonly sourceId?: string; + } + | { readonly kind: 'array'; readonly component: JavaType } + | { readonly kind: 'null' } + | { readonly kind: 'unknown' }; + +export type JavaReferenceType = Extract; +export type JavaInvocationPhase = 'strict' | 'loose' | 'variable'; +export interface JavaTypeRelations { + readonly sourceSubtype: (from: JavaReferenceType, to: JavaReferenceType) => JavaVerdict; +} + +export interface JavaOverloadCandidate { + readonly definition: T; + /** A variadic declaration's final entry is its ARRAY type. */ + readonly parameters: readonly JavaType[]; + readonly variadic: boolean; + readonly unsupportedGeneric?: boolean; +} + +export type JavaOverloadDecision = + | { readonly kind: 'resolved'; readonly definition: T; readonly phase: JavaInvocationPhase } + | { readonly kind: 'ambiguous' | 'unknown' | 'inapplicable'; readonly candidates: readonly T[] }; + +export const UNKNOWN_JAVA_TYPE: JavaType = { kind: 'unknown' }; +export const MAX_JAVA_TYPE_DEPTH = 32; +export const JAVA_PRIMITIVES = new Set([ + 'boolean', + 'byte', + 'short', + 'char', + 'int', + 'long', + 'float', + 'double', +]); + +const widening: Readonly> = { + boolean: [], + byte: ['short', 'int', 'long', 'float', 'double'], + short: ['int', 'long', 'float', 'double'], + char: ['int', 'long', 'float', 'double'], + int: ['long', 'float', 'double'], + long: ['float', 'double'], + float: ['double'], + double: [], +}; +const boxes: Readonly> = { + boolean: 'java.lang.Boolean', + byte: 'java.lang.Byte', + short: 'java.lang.Short', + char: 'java.lang.Character', + int: 'java.lang.Integer', + long: 'java.lang.Long', + float: 'java.lang.Float', + double: 'java.lang.Double', +}; +const unboxes = new Map( + Object.entries(boxes).map(([primitive, box]) => [box, primitive as JavaPrimitive]), +); + +/** These summaries are only used after a provider proves qualified JDK type + * identity. Collection edges preserve their invariant element arguments. */ +const jdkParents: Readonly> = { + 'java.lang.Object': [], + 'java.lang.String': ['java.lang.Object', 'java.lang.CharSequence', 'java.io.Serializable'], + 'java.lang.Number': ['java.lang.Object', 'java.io.Serializable'], + 'java.lang.Byte': ['java.lang.Number'], + 'java.lang.Short': ['java.lang.Number'], + 'java.lang.Integer': ['java.lang.Number'], + 'java.lang.Long': ['java.lang.Number'], + 'java.lang.Float': ['java.lang.Number'], + 'java.lang.Double': ['java.lang.Number'], + 'java.lang.Boolean': ['java.lang.Object', 'java.io.Serializable'], + 'java.lang.Character': ['java.lang.Object', 'java.io.Serializable'], + 'java.lang.CharSequence': ['java.lang.Object'], + 'java.lang.Cloneable': ['java.lang.Object'], + 'java.io.Serializable': ['java.lang.Object'], + 'java.lang.Iterable': ['java.lang.Object'], + 'java.lang.Runnable': ['java.lang.Object'], + 'java.util.Collection': ['java.lang.Iterable'], + 'java.util.List': ['java.util.Collection'], + 'java.util.Set': ['java.util.Collection'], + 'java.util.ArrayList': ['java.util.List', 'java.lang.Cloneable', 'java.io.Serializable'], + 'java.util.LinkedList': ['java.util.List', 'java.lang.Cloneable', 'java.io.Serializable'], + 'java.util.HashSet': ['java.util.Set', 'java.lang.Cloneable', 'java.io.Serializable'], + 'java.util.Map': ['java.lang.Object'], + 'java.util.HashMap': ['java.util.Map', 'java.lang.Cloneable', 'java.io.Serializable'], +}; +const parameterizedJdk = new Set([ + 'java.lang.Iterable', + 'java.util.Collection', + 'java.util.List', + 'java.util.Set', + 'java.util.ArrayList', + 'java.util.LinkedList', + 'java.util.HashSet', + 'java.util.Map', + 'java.util.HashMap', +]); +const finalJdk = new Set(['java.lang.String', ...Object.values(boxes)]); +export const KNOWN_JAVA_PLATFORM_TYPES = new Set([ + ...Object.keys(jdkParents), + 'java.util.Arrays', + 'java.util.Collections', +]); + +function all(verdicts: readonly JavaVerdict[]): JavaVerdict { + return verdicts.includes('no') ? 'no' : verdicts.includes('unknown') ? 'unknown' : 'yes'; +} + +export function sameJavaType(left: JavaType, right: JavaType, depth = 0): JavaVerdict { + if (depth >= MAX_JAVA_TYPE_DEPTH) return 'unknown'; + if (left.kind === 'unknown' || right.kind === 'unknown') return 'unknown'; + if (left.kind !== right.kind) return 'no'; + if (left.kind === 'null') return 'yes'; + if (left.kind === 'primitive' && right.kind === 'primitive') + return left.name === right.name ? 'yes' : 'no'; + if (left.kind === 'array' && right.kind === 'array') + return sameJavaType(left.component, right.component, depth + 1); + if (left.kind === 'reference' && right.kind === 'reference') { + if (left.name !== right.name || left.sourceId !== right.sourceId) return 'no'; + if (left.arguments.length !== right.arguments.length) return 'unknown'; + return all( + left.arguments.map((argument, i) => sameJavaType(argument, right.arguments[i]!, depth + 1)), + ); + } + return 'no'; +} + +export function javaSubtype( + from: JavaType, + to: JavaType, + relations: JavaTypeRelations, + depth = 0, +): JavaVerdict { + if (depth >= MAX_JAVA_TYPE_DEPTH) return 'unknown'; + if (from.kind === 'unknown' || to.kind === 'unknown') return 'unknown'; + if (sameJavaType(from, to, depth) === 'yes') return 'yes'; + if (from.kind === 'null') return to.kind === 'reference' || to.kind === 'array' ? 'yes' : 'no'; + if (from.kind === 'primitive' || to.kind === 'primitive') { + return from.kind === 'primitive' && + to.kind === 'primitive' && + widening[from.name].includes(to.name) + ? 'yes' + : 'no'; + } + if (from.kind === 'array') { + if (to.kind === 'reference') + return ['java.lang.Object', 'java.lang.Cloneable', 'java.io.Serializable'].includes( + to.name, + ) && to.sourceId === undefined + ? 'yes' + : 'no'; + if (to.kind !== 'array') return 'no'; + if (from.component.kind === 'primitive' || to.component.kind === 'primitive') + return sameJavaType(from.component, to.component, depth + 1); + return javaSubtype(from.component, to.component, relations, depth + 1); + } + if (from.kind !== 'reference' || to.kind !== 'reference') return 'no'; + if (to.name === 'java.lang.Object' && to.sourceId === undefined) return 'yes'; + if (from.name === to.name && from.sourceId === to.sourceId) { + // Raw/unchecked conversions are deliberately not approximated by erasure. + if (from.arguments.length !== to.arguments.length) return 'unknown'; + return all( + from.arguments.map((argument, i) => sameJavaType(argument, to.arguments[i]!, depth + 1)), + ); + } + if (to.sourceId === undefined && finalJdk.has(to.name)) return 'no'; + if (from.sourceId !== undefined || to.sourceId !== undefined) + return relations.sourceSubtype(from, to); + const parents = Object.hasOwn(jdkParents, from.name) ? jdkParents[from.name] : undefined; + if (parents === undefined) return 'unknown'; + let uncertain = false; + for (const parent of parents) { + const result = javaSubtype( + { + kind: 'reference', + name: parent, + arguments: parameterizedJdk.has(parent) ? from.arguments : [], + }, + to, + relations, + depth + 1, + ); + if (result === 'yes') return 'yes'; + if (result === 'unknown') uncertain = true; + } + return uncertain || !Object.hasOwn(jdkParents, to.name) ? 'unknown' : 'no'; +} + +export function javaInvocationConversion( + from: JavaType, + to: JavaType, + phase: JavaInvocationPhase, + relations: JavaTypeRelations, +): JavaVerdict { + const strict = javaSubtype(from, to, relations); + if (phase === 'strict' || strict !== 'no') return strict; + if (from.kind === 'primitive' && to.kind === 'reference') { + return javaSubtype({ kind: 'reference', name: boxes[from.name], arguments: [] }, to, relations); + } + if (from.kind === 'reference' && to.kind === 'primitive') { + const primitive = from.sourceId === undefined ? unboxes.get(from.name) : undefined; + return primitive === undefined + ? 'no' + : javaSubtype({ kind: 'primitive', name: primitive }, to, relations); + } + return 'no'; +} + +export function boxedJavaType(type: JavaType): JavaType { + return type.kind === 'primitive' + ? { kind: 'reference', name: boxes[type.name], arguments: [] } + : type; +} + +function effectiveParameters( + candidate: JavaOverloadCandidate, + phase: JavaInvocationPhase, + arity: number, +): readonly JavaType[] | undefined { + if (phase !== 'variable') + return candidate.parameters.length === arity ? candidate.parameters : undefined; + if (!candidate.variadic || arity < candidate.parameters.length - 1) return undefined; + const last = candidate.parameters.at(-1); + if (last?.kind !== 'array') return undefined; + return [ + ...candidate.parameters.slice(0, -1), + ...Array.from({ length: arity - candidate.parameters.length + 1 }, () => last.component), + ]; +} + +function moreSpecific( + left: JavaOverloadCandidate, + right: JavaOverloadCandidate, + phase: JavaInvocationPhase, + arity: number, + relations: JavaTypeRelations, +): JavaVerdict { + const compareArity = + phase === 'variable' ? Math.max(arity, left.parameters.length, right.parameters.length) : arity; + const a = effectiveParameters(left, phase, compareArity); + const b = effectiveParameters(right, phase, compareArity); + if (a === undefined || b === undefined) return 'unknown'; + return all(a.map((type, i) => javaSubtype(type, b[i]!, relations))); +} + +/** The provider evaluates poly expressions against the candidate's parameter; + * the selector must not replace that callback with one frozen argument type. */ +export function selectJavaOverload( + candidates: readonly JavaOverloadCandidate[], + arity: number, + argumentConversion: (index: number, target: JavaType, phase: JavaInvocationPhase) => JavaVerdict, + relations: JavaTypeRelations, +): JavaOverloadDecision { + for (const phase of ['strict', 'loose', 'variable'] as const) { + const applicable: JavaOverloadCandidate[] = []; + const uncertain: JavaOverloadCandidate[] = []; + for (const candidate of candidates) { + const parameters = effectiveParameters(candidate, phase, arity); + if (parameters === undefined) continue; + const verdict = all( + parameters.map((parameter, i) => argumentConversion(i, parameter, phase)), + ); + if (verdict === 'no') continue; + if (verdict === 'unknown' || candidate.unsupportedGeneric === true) uncertain.push(candidate); + else applicable.push(candidate); + } + // An unknown candidate in an earlier phase can beat any later-phase + // declaration. Even a currently unique applicable method is not a proof. + if (uncertain.length > 0) + return { + kind: 'unknown', + candidates: [...applicable, ...uncertain].map((candidate) => candidate.definition), + }; + if (applicable.length === 0) continue; + if (applicable.length === 1) + return { kind: 'resolved', definition: applicable[0]!.definition, phase }; + const winners = applicable.filter((candidate) => + applicable.every( + (other) => + candidate === other || moreSpecific(candidate, other, phase, arity, relations) === 'yes', + ), + ); + if (winners.length === 1) + return { kind: 'resolved', definition: winners[0]!.definition, phase }; + if (winners.length > 1) { + // JLS 15.12.2.5 can choose a preferred declaration among inherited + // override-equivalent methods. Abstract/default and return-type facts + // are outside this bounded selector; this is not proven ambiguity. + return { kind: 'unknown', candidates: applicable.map((candidate) => candidate.definition) }; + } + const unknown = applicable.some((candidate) => + applicable.some( + (other) => + candidate !== other && + moreSpecific(candidate, other, phase, arity, relations) === 'unknown', + ), + ); + return { + kind: unknown ? 'unknown' : 'ambiguous', + candidates: applicable.map((candidate) => candidate.definition), + }; + } + return { kind: 'inapplicable', candidates: candidates.map((candidate) => candidate.definition) }; +} + +/** Split only balanced top-level arguments; unsupported wildcards/type uses + * remain unknown in the provider rather than losing generic information. */ +export function splitJavaTypeArguments(text: string): string[] | undefined { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>') { + if (--depth < 0) return undefined; + } else if (text[i] === ',' && depth === 0) { + parts.push(text.slice(start, i).trim()); + start = i + 1; + } + } + if (depth !== 0) return undefined; + parts.push(text.slice(start).trim()); + return parts.every(Boolean) ? parts : undefined; +} diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 3879206ee..d23c3cd5c 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -42,6 +42,12 @@ import { clearJavaClassAnnotationFacts, } from './capture-side-channel.js'; import { clearJavaPackageFacts } from './package-facts.js'; +import { + resolveJavaReceiverMember, + resolveJavaSelectedMethodImplementation, + resolveJavaUnqualifiedCall, + resolveJavaReceiverCallSite, +} from './overload-resolution.js'; const javaScopeResolver: ScopeResolver = { language: SupportedLanguages.Java, @@ -75,6 +81,11 @@ const javaScopeResolver: ScopeResolver = { populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), applyCaptureSideChannel: applyJavaCaptureSideChannel, + resolveReceiverMember: resolveJavaReceiverMember, + resolveUnqualifiedCall: resolveJavaUnqualifiedCall, + resolveSelectedMethodImplementation: resolveJavaSelectedMethodImplementation, + receiverMemberLookupCoversAllCalls: true, + receiverCallSite: resolveJavaReceiverCallSite, isSuperReceiver: (text) => text.trim() === 'super', diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 88ab0ff30..d61865bbb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -319,7 +319,13 @@ export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible'; export type ReceiverMemberResolution = | { readonly kind: 'resolved'; readonly definition: SymbolDefinition } - | { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] }; + | { + readonly kind: 'ambiguous' | 'unknown' | 'inapplicable'; + readonly candidateIds: readonly string[]; + /** False when candidate enumeration itself could not be completed. */ + readonly candidateSetComplete?: boolean; + readonly detail?: string; + }; export interface ImportResolutionContext { readonly parsedFiles: readonly ParsedFile[]; @@ -1407,7 +1413,10 @@ export interface ScopeResolver { readonly name: string; readonly arity?: number; readonly argumentTypes?: readonly string[]; - readonly atRange: { readonly startLine: number; readonly startCol: number }; + readonly atRange: { + readonly startLine: number; + readonly startCol: number; + }; }, callerParsed: ParsedFile, scopes: ScopeResolutionIndexes, @@ -1663,6 +1672,38 @@ export interface ScopeResolver { model: SemanticModel, ) => ReceiverMemberResolution | undefined; + /** Opt this member lookup into compound, super and chained call receivers. + * Existing providers retain their historical simple-receiver paths. */ + readonly receiverMemberLookupCoversAllCalls?: boolean; + + /** Source identity of a call whose result is this site's immediate receiver. + * Captured from the AST, never inferred by comparing expression strings. + * Enables typing from the selected compile-time declaration; an unresolved + * producer must not fall back to a name-based return-type guess. */ + readonly receiverCallSite?: ( + callsite: ReferenceSite, + scopes: ScopeResolutionIndexes, + ) => { readonly name: string; readonly range: ReferenceSite['atRange'] } | undefined; + + /** Resolve an unqualified call before implicit-receiver owner pruning. + * Undefined declines handling; every returned result is authoritative and + * must not resume first-match or global-name fallback. */ + readonly resolveUnqualifiedCall?: ( + callsite: ReferenceSite, + scopes: ScopeResolutionIndexes, + model: SemanticModel, + ) => ReceiverMemberResolution | undefined; + + /** Find an implementation of an already selected compile-time signature. + * This is override matching, not overload applicability at a runtime subtype. + * Undefined means no matching implementation; unknown retains a boundary. */ + readonly resolveSelectedMethodImplementation?: ( + selected: SymbolDefinition, + owner: SymbolDefinition, + scopes: ScopeResolutionIndexes, + model: SemanticModel, + ) => ReceiverMemberResolution | undefined; + /** Suppress all receiver dispatch when a captured type cannot prove its * runtime binding. Runs before compound and simple receiver lookup; the * caller records a receiver-unresolved outcome. */ diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts index d6d384b0b..0d91e78e7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts @@ -835,7 +835,7 @@ export function buildGraphTargetIndex( graph: KnowledgeGraph, ): ReadonlyMap { const out = new Map(); - const { byAnchor, callableByPosition } = buildGraphCallableIndexes(graph); + const { byAnchor, callableByPosition, callableNames } = buildGraphCallableIndexes(graph); for (const def of scopes.defs.byId.values()) { const callableDef = isCallable(def) || providerTarget?.(def) === true; if (!callableDef) { @@ -858,7 +858,7 @@ export function buildGraphTargetIndex( // A closure binding IS its callable node: same file, same line, same // name. An aliasing local is not. So look the node up by position and // admit only an exact hit. - const positionalKey = valueBindingPositionKey(def); + const positionalKey = valueBindingPositionKey(def, callableNames); const positionalId = positionalKey === undefined ? undefined : callableByPosition.get(positionalKey); // `''` marks an ambiguous position (two callables claiming one @@ -894,12 +894,20 @@ const withoutSigil = (name: string): string => name.replace(/^[$@]+/, ''); * in `buildGraphCallableIndexes`. Definition lines come from the def id and are * 1-based; graph `startLine` is 0-based, which is the `+ 1` there. */ -function valueBindingPositionKey(def: SymbolDefinition): string | undefined { +function valueBindingPositionKey( + def: SymbolDefinition, + callableNames: ReadonlySet, +): string | undefined { if (!VALUE_BINDING_DEF_TYPES.has(def.type)) return undefined; - const line = definitionIdPosition(def.nodeId, def.filePath)?.line; const name = simpleQualifiedName(def); - if (line === undefined || name === undefined) return undefined; - return `${def.filePath}\0${line}\0${withoutSigil(name)}`; + if (name === undefined) return undefined; + const normalizedName = withoutSigil(name); + // Reject ordinary values before parsing IDs or allocating positional keys. + // A name hit still requires the exact file/line/name join. + if (!callableNames.has(normalizedName)) return undefined; + const line = definitionIdPosition(def.nodeId, def.filePath)?.line; + if (line === undefined) return undefined; + return `${def.filePath}\0${line}\0${normalizedName}`; } /** @@ -1085,6 +1093,7 @@ function declarationSignatureCompatible( interface GraphCallableIndexes { /** Callable graph nodes by `file\0label\0line\0name` — the definition anchor. */ readonly byAnchor: ReadonlyMap; + readonly callableNames: ReadonlySet; /** * Callable graph nodes by `file\0line\0name` — the same anchor WITHOUT the * label, because a value binding's def type never matches its callable node's @@ -1100,6 +1109,7 @@ interface GraphCallableIndexes { function buildGraphCallableIndexes(graph: KnowledgeGraph): GraphCallableIndexes { const byAnchor = new Map(); const callableByPosition = new Map(); + const callableNames = new Set(); for (const node of graph.iterNodes()) { if (node.label !== 'Function' && node.label !== 'Method' && node.label !== 'Constructor') { continue; @@ -1115,7 +1125,9 @@ function buildGraphCallableIndexes(graph: KnowledgeGraph): GraphCallableIndexes continue; } const oneBasedLine = zeroBasedLine + 1; - const positionKey = `${filePath}\0${oneBasedLine}\0${withoutSigil(name)}`; + const normalizedName = withoutSigil(name); + callableNames.add(normalizedName); + const positionKey = `${filePath}\0${oneBasedLine}\0${normalizedName}`; const existing = callableByPosition.get(positionKey); // First wins would be order-dependent; mark the collision instead so an // ambiguous position admits nothing rather than something arbitrary. @@ -1125,7 +1137,7 @@ function buildGraphCallableIndexes(graph: KnowledgeGraph): GraphCallableIndexes if (bucket === undefined) byAnchor.set(key, [node.id]); else bucket.push(node.id); } - return { byAnchor, callableByPosition }; + return { byAnchor, callableByPosition, callableNames }; } function definitionAnchorKey(def: SymbolDefinition): string | undefined { diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index c3982d4c9..25f9748c3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -32,6 +32,12 @@ import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { ScopeResolver } from '../contract/scope-resolver.js'; +import { + callSuppressionIdentities, + providerCallOutcome, + selectedCallKey, + type CallOutcomeContext, +} from './provider-call-outcome.js'; import type { ResolutionOutcomeRecorder, ResolutionSuppressionReason, @@ -65,7 +71,9 @@ export function emitFreeCallFallback( scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, - _referenceIndex: { readonly bySourceScope: ReadonlyMap }, + _referenceIndex: { + readonly bySourceScope: ReadonlyMap; + }, handledSites: Set, model: SemanticModel, workspaceIndex: WorkspaceResolutionIndex, @@ -98,7 +106,10 @@ export function emitFreeCallFallback( readonly name: string; readonly arity?: number; readonly argumentTypes?: readonly string[]; - readonly atRange: { readonly startLine: number; readonly startCol: number }; + readonly atRange: { + readonly startLine: number; + readonly startCol: number; + }; }, callerParsed: ParsedFile, scopes: ScopeResolutionIndexes, @@ -107,6 +118,9 @@ export function emitFreeCallFallback( /** Module-qualified free-call resolver (#2730) — see * `ScopeResolver.resolveQualifiedFreeCall`. */ readonly resolveQualifiedFreeCall?: ScopeResolver['resolveQualifiedFreeCall']; + readonly resolveUnqualifiedCall?: ScopeResolver['resolveUnqualifiedCall']; + readonly selectedCalls?: Map; + readonly onSelectedCall?: (key: string, definition: SymbolDefinition | null) => void; readonly conversionRankFn?: ConversionRankFn; readonly conversionOnlyArgTypePrefixes?: readonly string[]; /** Optional per-language constraint hook threaded into @@ -139,6 +153,17 @@ export function emitFreeCallFallback( ): number { let emitted = 0; const seen = new Set(); + const outcomeContext: CallOutcomeContext = { + graph, + scopes, + nodeLookup, + model, + }; + const recordSuppressed = (input: Parameters[1]): void => + recordSuppressedOutcome(options.recordResolutionOutcome, { + ...input, + identityContext: outcomeContext, + }); // Build an O(1) simple-name -> callable defs index over scopes.defs once // per pass so pickUniqueGlobalCallable doesn't re-scan defs.byId.values() @@ -241,7 +266,10 @@ export function emitFreeCallFallback( targetDefIds: [], basis: 'free-call', }); - const lookupOptions = { position: site.atRange, purpose: 'value' as const }; + const lookupOptions = { + position: site.atRange, + purpose: 'value' as const, + }; const nameClaim = lookupNameClaim(site.inScope, site.name, scopes, lookupOptions); const strictClaim = hasExplicitNameClaim(nameClaim, site.name); const blocksCall = @@ -264,7 +292,7 @@ export function emitFreeCallFallback( basis: 'lexical-name-claim', }); if (options.resolveAdlCandidates !== undefined) { - recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSuppressed({ recordSourceBinding: options.recordSourceBinding, site, phase: 'free-call-fallback', @@ -284,6 +312,7 @@ export function emitFreeCallFallback( // to the Class node itself (implicit constructor). Legacy emits // the same two targets; see test expectations. let fnDef: SymbolDefinition | undefined; + let fnDefFromProvider = false; let writtenDefinition: SymbolDefinition | undefined; let sourceCandidates: readonly SymbolDefinition[] | undefined; // Guess flag starts here so the constructor unique-class pick can mark @@ -294,6 +323,41 @@ export function emitFreeCallFallback( // Language visibility vetoes the constructed TYPE (Class/Struct), not a // Constructor child — Go refuses any `qualifiedName` containing `.`. let globalFallbackVetoTarget: SymbolDefinition | undefined; + if (site.callForm !== 'constructor' && site.rawQualifiedName === undefined) { + const resolution = options.resolveUnqualifiedCall?.(site, scopes, model); + if (resolution !== undefined && resolution.kind !== 'resolved') { + options.selectedCalls?.set(selectedCallKey(parsed.filePath, site.atRange), null); + options.onSelectedCall?.(selectedCallKey(parsed.filePath, site.atRange), null); + options.recordSourceBinding?.(site, { + status: + resolution.kind === 'ambiguous' + ? 'ambiguous' + : resolution.kind === 'inapplicable' + ? 'blocked' + : 'unresolved', + targetDefIds: resolution.candidateIds, + basis: + resolution.kind === 'ambiguous' + ? 'member-lookup-ambiguous' + : `overload-${resolution.kind}`, + }); + options.recordResolutionOutcome?.( + providerCallOutcome( + resolution, + site, + parsed.filePath, + 'free-call-fallback', + outcomeContext, + ), + ); + handledSites.add(siteKey(parsed.filePath, site)); + continue; + } + if (resolution?.kind === 'resolved') { + fnDef = resolution.definition; + fnDefFromProvider = true; + } + } if (site.callForm === 'constructor') { // `ns.Type{}` names its type through a namespace import: a type the // language will not let that qualifier reach is not found, rather than @@ -448,7 +512,7 @@ export function emitFreeCallFallback( fnDef = eligibleBindingCandidates[0]?.def; sourceCandidates = eligibleBindingCandidates.map((candidate) => candidate.def); if (fnDef === undefined && bindingCandidates.length > 0) { - recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSuppressed({ recordSourceBinding: options.recordSourceBinding, site, phase: 'free-call-fallback', @@ -524,7 +588,7 @@ export function emitFreeCallFallback( // Cross-file candidates are shadowing; keep first-match. const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath); if (sameFile) { - recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSuppressed({ recordSourceBinding: options.recordSourceBinding, site, phase: 'free-call-fallback', @@ -562,7 +626,10 @@ export function emitFreeCallFallback( name: site.name, arity: site.arity, argumentTypes: site.argumentTypes, - atRange: { startLine: site.atRange.startLine, startCol: site.atRange.startCol }, + atRange: { + startLine: site.atRange.startLine, + startCol: site.atRange.startCol, + }, }, parsed, scopes, @@ -571,7 +638,7 @@ export function emitFreeCallFallback( const key = siteKey(parsed.filePath, site); if (adlSuppressed && ordinary.length === 0) { - recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSuppressed({ recordSourceBinding: options.recordSourceBinding, site, phase: 'free-call-fallback', @@ -616,7 +683,7 @@ export function emitFreeCallFallback( } else { // These candidates share the selected lexical tier. Different // files do not establish precedence between imported overloads. - recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSuppressed({ recordSourceBinding: options.recordSourceBinding, site, phase: 'free-call-fallback', @@ -659,7 +726,7 @@ export function emitFreeCallFallback( handledSites.add(key); continue; } else if (narrowed.length > 1) { - recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSuppressed({ recordSourceBinding: options.recordSourceBinding, site, phase: 'free-call-fallback', @@ -804,7 +871,7 @@ export function emitFreeCallFallback( continue; } if (fnDef.isDeleted === true) { - recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSuppressed({ recordSourceBinding: options.recordSourceBinding, site, phase: 'free-call-fallback', @@ -889,9 +956,22 @@ export function emitFreeCallFallback( nodeLookup, site.atRange, ); - if (callerGraphId === undefined) continue; const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup); - if (tgtGraphId === undefined) continue; + options.selectedCalls?.set(selectedCallKey(parsed.filePath, site.atRange), fnDef); + options.onSelectedCall?.(selectedCallKey(parsed.filePath, site.atRange), fnDef); + if (fnDefFromProvider) { + handledSites.add(siteKey(parsed.filePath, site)); + options.recordResolutionOutcome?.( + providerCallOutcome( + { kind: 'resolved', definition: fnDef }, + site, + parsed.filePath, + 'free-call-fallback', + outcomeContext, + ), + ); + } + if (callerGraphId === undefined || tgtGraphId === undefined) continue; // Always mark the site as handled — even when the dedup-collapse // means we don't add a new edge — so `emit-references` skips its // potentially-wrong fallback for the same site. @@ -1000,7 +1080,9 @@ export function constructionSiteReason( function siteKey( filePath: string, - site: { readonly atRange: { readonly startLine: number; readonly startCol: number } }, + site: { + readonly atRange: { readonly startLine: number; readonly startCol: number }; + }, ): string { return `${filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; } @@ -1042,6 +1124,7 @@ function recordSuppressedOutcome( readonly candidates: readonly SymbolDefinition[]; readonly site?: ReferenceSite; readonly recordSourceBinding?: SourceBindingRecorder; + readonly identityContext?: CallOutcomeContext; }, ): void { if (input.site !== undefined) @@ -1058,6 +1141,15 @@ function recordSuppressedOutcome( range: input.range, reason: input.reason, candidateIds: input.candidates.map((d) => d.nodeId), + ...(input.site !== undefined && input.identityContext !== undefined + ? callSuppressionIdentities( + input.candidates.map((d) => d.nodeId), + input.site, + input.identityContext, + true, + input.candidates, + ) + : { siteKind: input.site?.kind }), }); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/provider-call-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/provider-call-outcome.ts new file mode 100644 index 000000000..33284e3b2 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/provider-call-outcome.ts @@ -0,0 +1,170 @@ +import type { ReferenceSite, SymbolDefinition } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SemanticModel } from '../../model/semantic-model.js'; +import type { ReceiverMemberResolution } from '../contract/scope-resolver.js'; +import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import type { ResolutionOutcome } from '../resolution-outcome.js'; + +/** A nested call and its consumer can share a start position. Keep the entire + * AST call range so compile-time return types cannot alias those calls. */ +export function selectedCallKey(filePath: string, range: ReferenceSite['atRange']): string { + return `${filePath}:${range.startLine}:${range.startCol}:${range.endLine}:${range.endCol}`; +} + +export function callResultBindingKey(scopeId: string, name: string): string { + return `${scopeId}\0${name}`; +} + +export interface CallOutcomeContext { + readonly graph: KnowledgeGraph; + readonly scopes: ScopeResolutionIndexes; + readonly nodeLookup: GraphNodeLookup; + readonly model: SemanticModel; +} + +/** Preserve scope IDs for diagnostics while bridging to queryable graph IDs. + * A failed bridge is missing evidence, never a measured empty candidate set. */ +export function callSuppressionIdentities( + candidateIds: readonly string[], + site: ReferenceSite, + context: CallOutcomeContext, + inventoryComplete = true, + definitions: readonly SymbolDefinition[] = [], +): Pick< + Extract, + 'candidateGraphIds' | 'candidateOwnerIds' | 'callerId' | 'candidateSetComplete' | 'siteKind' +> { + const { graph, scopes, nodeLookup, model } = context; + const ids = [...new Set(candidateIds)]; + const candidateGraphIds = new Set(); + const candidateOwnerIds = new Set(); + let candidateSetComplete = inventoryComplete; + let suppliedById: Map | undefined; + let byId: Map | undefined; + for (const id of ids) { + let definition = scopes.defs.get(id); + if (definition === undefined) { + if (suppliedById === undefined) { + suppliedById = new Map(); + for (const item of definitions) { + if (!suppliedById.has(item.nodeId)) suppliedById.set(item.nodeId, item); + } + } + definition = suppliedById.get(id); + } + if (definition === undefined) { + byId ??= new Map( + [ + ...model.methods.lookupMethodByName(site.name), + ...model.symbols.lookupCallableByName(site.name), + ].map((item) => [item.nodeId, item]), + ); + definition = byId.get(id); + } + const graphId = + definition === undefined + ? graph.getNode(id)?.id + : resolveDefGraphId(definition.filePath, definition, nodeLookup); + if (graphId === undefined || graph.getNode(graphId) === undefined) { + candidateSetComplete = false; + continue; + } + candidateGraphIds.add(graphId); + const ownerId = definition?.ownerId; + if (ownerId !== undefined) { + const owner = scopes.defs.get(ownerId); + const ownerGraphId = + owner === undefined + ? graph.getNode(ownerId)?.id + : resolveDefGraphId(owner.filePath, owner, nodeLookup); + if (ownerGraphId === undefined || graph.getNode(ownerGraphId) === undefined) + candidateSetComplete = false; + else candidateOwnerIds.add(ownerGraphId); + } else if ( + definition === undefined || + definition.type === 'Method' || + definition.type === 'Constructor' + ) { + candidateSetComplete = false; + } + } + if (candidateGraphIds.size !== ids.length) candidateSetComplete = false; + const callerId = resolveCallerGraphId( + site.callerScope ?? site.inScope, + scopes, + nodeLookup, + site.atRange, + ); + if (callerId === undefined || graph.getNode(callerId) === undefined) candidateSetComplete = false; + return { + candidateGraphIds: [...candidateGraphIds], + candidateOwnerIds: [...candidateOwnerIds], + callerId, + candidateSetComplete, + siteKind: site.kind, + }; +} + +export function providerCallOutcome( + result: ReceiverMemberResolution, + site: ReferenceSite, + filePath: string, + phase: string, + context: CallOutcomeContext, +): ResolutionOutcome { + const location = { phase, filePath, name: site.name, range: site.atRange }; + let suppression: Exclude; + if (result.kind === 'resolved') { + const targetId = resolveDefGraphId( + result.definition.filePath, + result.definition, + context.nodeLookup, + ); + const callerId = resolveCallerGraphId( + site.callerScope ?? site.inScope, + context.scopes, + context.nodeLookup, + site.atRange, + ); + if ( + targetId !== undefined && + callerId !== undefined && + context.graph.getNode(targetId) !== undefined && + context.graph.getNode(callerId) !== undefined + ) { + return { + kind: 'resolved', + targetId: result.definition.nodeId, + ...location, + }; + } + suppression = { + kind: 'unknown', + candidateIds: [result.definition.nodeId], + candidateSetComplete: false, + detail: 'The selected call could not be represented by graph identities.', + }; + } else { + suppression = result; + } + return { + kind: 'suppressed', + reason: + suppression.kind === 'ambiguous' + ? 'member-lookup-ambiguous' + : suppression.kind === 'unknown' + ? 'overload-unknown' + : 'overload-inapplicable', + candidateIds: suppression.candidateIds, + detail: suppression.detail, + ...location, + ...callSuppressionIdentities( + suppression.candidateIds, + site, + context, + suppression.candidateSetComplete !== false, + ), + }; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 5ebd0774d..44971f2f2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -61,6 +61,7 @@ */ import type { + CallResultAssignmentSite, ParsedFile, ReferenceSite, ScopeId, @@ -70,7 +71,7 @@ import type { import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; -import type { ScopeResolver } from '../contract/scope-resolver.js'; +import type { ReceiverMemberResolution, ScopeResolver } from '../contract/scope-resolver.js'; import { LINKABLE_LABELS, type GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import { createNamespaceTargetCache } from '../scope/namespace-target-cache.js'; @@ -128,6 +129,13 @@ import type { ResolutionSuppressionReason, } from '../resolution-outcome.js'; import { classifyReceiverShape } from '../resolution-outcome.js'; +import { + callSuppressionIdentities, + callResultBindingKey, + providerCallOutcome, + selectedCallKey, + type CallOutcomeContext, +} from './provider-call-outcome.js'; import type { ReceiverOrigin } from '../resolution-outcome.js'; import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; @@ -206,6 +214,9 @@ type ReceiverBoundProviderSubset = Pick< | 'namespaceSkipsEnclosingClasses' | 'createReceiverNamespaceResolver' | 'resolveReceiverMember' + | 'receiverMemberLookupCoversAllCalls' + | 'receiverCallSite' + | 'resolveSelectedMethodImplementation' | 'suppressReceiverLookup' | 'isModuleExportAssignment' | 'resolveThisViaEnclosingClass' @@ -660,6 +671,13 @@ export function emitReceiverBoundCalls( readonly allFilePaths?: ReadonlySet; readonly recordResolutionOutcome?: ResolutionOutcomeRecorder; readonly recordSourceBinding?: SourceBindingRecorder; + /** Compile-time primaries, separate from runtime fanout in calleeIdSink. */ + readonly selectedCalls?: Map; + /** Apply only assignments fed by this newly selected compile-time primary. */ + readonly onSelectedCall?: (key: string, definition: SymbolDefinition | null) => void; + /** Full source identity to the legacy handled-site key, finalized after replay. */ + readonly pendingCallReceivers?: Map; + readonly callResultProducers?: ReadonlyMap; /** Resolved-callee-id capture sink (#2227 U2). Threaded in under `--pdg` * OR for callable-flow's direct-target index (#2437, position-filtered); * `undefined` ⇒ zero overhead, byte-identity (R4). Per-file capture @@ -699,6 +717,40 @@ export function emitReceiverBoundCalls( // them resolve the same site to the same target. NEVER pre-seed // from the reference index — see Contract Invariant I5. const seen = new Set(); + const outcomeContext: CallOutcomeContext = { + graph, + scopes, + nodeLookup, + model, + }; + const suppressProviderResult = ( + result: ReceiverMemberResolution | undefined, + site: ReferenceSite, + filePath: string, + ): boolean => { + if (result === undefined || result.kind === 'resolved') return false; + if (site.kind === 'call') { + const key = selectedCallKey(filePath, site.atRange); + options.selectedCalls?.set(key, null); + options.onSelectedCall?.(key, null); + options.pendingCallReceivers?.delete(key); + } + options.recordSourceBinding?.(site, { + status: + result.kind === 'ambiguous' + ? 'ambiguous' + : result.kind === 'inapplicable' + ? 'blocked' + : 'unresolved', + targetDefIds: result.candidateIds, + basis: result.kind === 'ambiguous' ? 'member-lookup-ambiguous' : `overload-${result.kind}`, + }); + options.recordResolutionOutcome?.( + providerCallOutcome(result, site, filePath, 'receiver-bound-calls', outcomeContext), + ); + handledSites.add(`${filePath}:${site.atRange.startLine}:${site.atRange.startCol}`); + return true; + }; // Active only while a source site is being resolved; nested class/chain // lookups report a name guess at the actual decision that made it. let onNameFallback: (() => void) | undefined; @@ -1205,7 +1257,17 @@ export function emitReceiverBoundCalls( * cases call this unconditionally, and the overwhelming majority of * receivers are concrete classes that return at the first line. */ receiverTypeSpelling: string | undefined, + callerFilePath: string, ): number => { + // A provider may select an interface declaration from a concrete receiver. + // Runtime fan-out follows that selected declaration's owner. + const concreteReceiver = ownerDef.type === 'Interface' ? undefined : ownerDef; + const selectedOwner = + primaryMemberDef.ownerId === undefined + ? undefined + : scopes.defs.get(primaryMemberDef.ownerId); + if (concreteReceiver !== undefined && selectedOwner?.type === 'Interface') + ownerDef = selectedOwner; if (ownerDef.type !== 'Interface') return 0; if (subtypesBySupertypeDefId.get(ownerDef.nodeId) === undefined) return 0; const receiverTypeArguments = @@ -1240,6 +1302,10 @@ export function emitReceiverBoundCalls( readonly typeArguments: readonly string[] | undefined; }; const targetByMemberId = new Map(); + const unresolvedDispatchCandidates = new Set(); + const unresolvedDispatchOwners = new Set(); + let hasUnresolvedDispatch = false; + let dispatchCandidatesComplete = true; const bestIncomingCount = new Map([[ownerDef.nodeId, 0]]); const queue: DispatchTraversal[] = [ { @@ -1260,6 +1326,15 @@ export function emitReceiverBoundCalls( const superGraphId = current.typeArguments === undefined ? undefined : classGraphIdByDefId.get(current.typeId); for (const subDef of subtypesBySupertypeDefId.get(current.typeId) ?? []) { + // Preserve a proven concrete receiver branch when the provider selects + // an interface signature; sibling implementations are not its targets. + if ( + concreteReceiver !== undefined && + subDef.nodeId !== concreteReceiver.nodeId && + !scopes.methodDispatch.mroFor(concreteReceiver.nodeId).includes(subDef.nodeId) && + !scopes.methodDispatch.mroFor(subDef.nodeId).includes(concreteReceiver.nodeId) + ) + continue; const previousIncomingCount = bestIncomingCount.get(subDef.nodeId); if ( previousIncomingCount !== undefined && @@ -1305,11 +1380,54 @@ export function emitReceiverBoundCalls( bestIncomingCount.set(subDef.nodeId, current.ancestorImplementationCount); - const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider); + const signatureResolution = provider.resolveSelectedMethodImplementation?.( + primaryMemberDef, + subDef, + scopes, + model, + ); + const implMember = + provider.resolveSelectedMethodImplementation === undefined + ? pickOverload(subDef.nodeId, memberName, site, model, provider) + : signatureResolution?.kind === 'resolved' + ? signatureResolution.definition + : undefined; + const implementationGraphId = + signatureResolution?.kind === 'resolved' + ? resolveDefGraphId( + signatureResolution.definition.filePath, + signatureResolution.definition, + nodeLookup, + ) + : undefined; + const implementationUnmapped = + signatureResolution?.kind === 'resolved' && + (implementationGraphId === undefined || + graph.getNode(implementationGraphId) === undefined); + if ( + signatureResolution?.kind === 'unknown' || + signatureResolution?.kind === 'ambiguous' || + implementationUnmapped + ) { + hasUnresolvedDispatch = true; + if (signatureResolution?.kind === 'resolved') { + unresolvedDispatchCandidates.add(signatureResolution.definition.nodeId); + dispatchCandidatesComplete = false; + } else if (signatureResolution !== undefined) { + for (const id of signatureResolution.candidateIds) unresolvedDispatchCandidates.add(id); + if (signatureResolution.candidateSetComplete === false) + dispatchCandidatesComplete = false; + } + const ownerGraphId = resolveDefGraphId(subDef.filePath, subDef, nodeLookup); + if (ownerGraphId === undefined || graph.getNode(ownerGraphId) === undefined) + dispatchCandidatesComplete = false; + else unresolvedDispatchOwners.add(ownerGraphId); + } let descendantImplementationCount = current.ancestorImplementationCount; if ( implMember !== undefined && implMember !== OVERLOAD_AMBIGUOUS && + !implementationUnmapped && implMember.isDeleted !== true && implMember.nodeId !== primaryMemberDef.nodeId && !isDeclarationOnly(implMember) && @@ -1340,6 +1458,29 @@ export function emitReceiverBoundCalls( } } + if (hasUnresolvedDispatch) { + const outcome = providerCallOutcome( + { + kind: 'unknown', + candidateIds: [...unresolvedDispatchCandidates], + candidateSetComplete: dispatchCandidatesComplete, + detail: 'An implementation of the selected signature could not be established.', + }, + site, + callerFilePath, + 'receiver-dispatch', + outcomeContext, + ); + if (outcome.kind === 'suppressed') { + options.recordResolutionOutcome?.({ + ...outcome, + candidateOwnerIds: [ + ...new Set([...(outcome.candidateOwnerIds ?? []), ...unresolvedDispatchOwners]), + ], + }); + } + } + const targets = [...targetByMemberId.values()] .sort( (left, right) => @@ -1470,9 +1611,34 @@ export function emitReceiverBoundCalls( // calls can share only the key (`ns.make().m()` starts where `ns.make()` // does), so one's resolution must not hide the other's drop (#3498). const siteIdentitiesByKey = new Map>(); - for (const site of parsed.referenceSites) { + // Producer dependencies are local to the call's file. A source range is + // attempted once initially, then only when a producer it awaited resolves. + // This handles reverse capture order without rescanning the workspace. + const workQueue = options.selectedCalls === undefined ? undefined : [...parsed.referenceSites]; + const sites = workQueue ?? parsed.referenceSites; + const waitingByProducer = new Map>(); + const waitForProducer = (producerKey: string, site: ReferenceSite): void => { + if (options.selectedCalls === undefined || options.selectedCalls.has(producerKey)) return; + let waiting = waitingByProducer.get(producerKey); + if (waiting === undefined) waitingByProducer.set(producerKey, (waiting = new Map())); + waiting.set(selectedCallKey(parsed.filePath, site.atRange), site); + }; + const releaseConsumers = (producerKey: string): void => { + const waiting = waitingByProducer.get(producerKey); + if (waiting === undefined) return; + waitingByProducer.delete(producerKey); + for (const site of waiting.values()) workQueue?.push(site); + }; + for (const site of sites) { if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue; if (site.explicitReceiver === undefined) continue; + // Primaries and terminal suppressions are stable across dependency + // replay. Revisit only consumers whose producer was not known yet. + if ( + site.kind === 'call' && + options.selectedCalls?.has(selectedCallKey(parsed.filePath, site.atRange)) + ) + continue; // A receiver-specific decision supersedes the provisional registry // answer even when it deliberately declines to emit an edge. options.recordSourceBinding?.(site, { @@ -1481,6 +1647,7 @@ export function emitReceiverBoundCalls( basis: 'receiver', }); let sourceBindingGrounded = true; + let providerSelectedMember = false; onNameFallback = options.recordSourceBinding === undefined ? undefined @@ -1488,6 +1655,24 @@ export function emitReceiverBoundCalls( sourceBindingGrounded = false; }; const recordMember = (definition: SymbolDefinition): void => { + if (site.kind === 'call') { + const key = selectedCallKey(parsed.filePath, site.atRange); + options.selectedCalls?.set(key, definition); + options.onSelectedCall?.(key, definition); + options.pendingCallReceivers?.delete(key); + releaseConsumers(key); + } + if (providerSelectedMember && site.kind === 'call') { + options.recordResolutionOutcome?.( + providerCallOutcome( + { kind: 'resolved', definition }, + site, + parsed.filePath, + 'receiver-bound-calls', + outcomeContext, + ), + ); + } options.recordSourceBinding?.( site, sourceBindingGrounded @@ -1525,11 +1710,71 @@ export function emitReceiverBoundCalls( decodeReceiverChain(site.receiverChain)?.baseReceiverName ?? receiverName.split(/[.([\s]/, 1)[0]; + if ( + site.kind === 'call' && + baseName !== undefined && + options.callResultProducers !== undefined + ) { + const claim = lookupNameClaim(site.inScope, baseName, scopes, { + position: site.atRange, + purpose: 'value', + }); + let currentScopeId: ScopeId | null = site.inScope; + let unresolvedAssignment = false; + let selectedProducer: SymbolDefinition | null | undefined; + let producerKey: string | undefined; + while (currentScopeId !== null) { + const currentScope = scopes.scopeTree.getScope(currentScopeId); + if (currentScope === undefined) break; + const assignment = options.callResultProducers.get( + callResultBindingKey(currentScopeId, baseName), + ); + if ( + assignment !== undefined && + (assignment.callSite.startLine < site.atRange.startLine || + (assignment.callSite.startLine === site.atRange.startLine && + assignment.callSite.startCol <= site.atRange.startCol)) + ) { + producerKey = selectedCallKey(parsed.filePath, assignment.callSite); + selectedProducer = options.selectedCalls?.get(producerKey); + unresolvedAssignment = + selectedProducer == null || + !index.declaredReturnTypeByCallableId.has(selectedProducer.nodeId); + break; + } + // A nearer declaration owns the receiver even when it has no type. + if (claim.scope?.id === currentScopeId || currentScope.typeBindings.has(baseName)) break; + currentScopeId = currentScope.parent; + } + if (unresolvedAssignment) { + if (producerKey !== undefined) waitForProducer(producerKey, site); + options.pendingCallReceivers?.set( + selectedCallKey(parsed.filePath, site.atRange), + siteKey, + ); + options.recordResolutionOutcome?.({ + kind: 'suppressed', + reason: 'receiver-unresolved', + candidateIds: [], + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + siteKind: 'call', + receiverOrigin: selectedProducer == null ? 'unknown' : 'in-program', + receiverShape: classifyReceiverShape(decodeReceiverChain(site.receiverChain)), + }); + continue; + } + } + if (provider.suppressReceiverLookup !== undefined) { const baseTypeRef = baseName === undefined ? undefined - : findReceiverTypeBinding(site.inScope, baseName, scopes, { position: site.atRange }); + : findReceiverTypeBinding(site.inScope, baseName, scopes, { + position: site.atRange, + }); if (baseTypeRef !== undefined && provider.suppressReceiverLookup(baseTypeRef)) { options.recordResolutionOutcome?.({ kind: 'suppressed', @@ -1595,9 +1840,19 @@ export function emitReceiverBoundCalls( extendsOnly !== undefined ? extendsOnly(enclosingClass.nodeId) : scopes.methodDispatch.mroFor(enclosingClass.nodeId); - let memberDef: SymbolDefinition | undefined; + const baseDef = ancestors.length > 0 ? scopes.defs.get(ancestors[0]!) : undefined; + const languageResolution = + provider.receiverMemberLookupCoversAllCalls === true && + site.kind === 'call' && + baseDef !== undefined + ? provider.resolveReceiverMember?.(baseDef, memberName, site, scopes, model) + : undefined; + if (suppressProviderResult(languageResolution, site, parsed.filePath)) continue; + providerSelectedMember = languageResolution?.kind === 'resolved'; + let memberDef = + languageResolution?.kind === 'resolved' ? languageResolution.definition : undefined; let ambiguousOwnerId: string | undefined; - for (const ownerId of ancestors) { + for (const ownerId of memberDef === undefined ? ancestors : []) { const picked = site.kind === 'call' ? pickOverload(ownerId, memberName, site, model, provider) @@ -1621,6 +1876,7 @@ export function emitReceiverBoundCalls( model, provider, options.recordSourceBinding, + outcomeContext, ); handledSites.add(siteKey); continue; @@ -1700,20 +1956,80 @@ export function emitReceiverBoundCalls( receiverName.includes('(') || site.receiverChain !== undefined ) { - const resolved = resolveCompoundReceiverTyped( - receiverName, - site.inScope, - scopes, - index, - // Group A: the receiver IS this site's expression, so the site's - // captured chain describes it and the structural fold applies. - { ...fileCompoundOpts, receiverChain: site.receiverChain }, - ); + const producer = + site.kind === 'call' ? provider.receiverCallSite?.(site, scopes) : undefined; + const selected = + producer === undefined + ? undefined + : options.selectedCalls?.get(selectedCallKey(parsed.filePath, producer.range)); + const returned = + selected == null ? undefined : index.declaredReturnTypeByCallableId.get(selected.nodeId); + const returnedClass = + returned === undefined + ? undefined + : resolveClassBindingForName( + returned.declaredAtScope, + returned.declaredSpelling ?? returned.rawName, + scopes, + provider.stripTypePreservingDecoration, + { + purpose: 'type', + position: returned.lookupPosition, + qualifiedTypeSpellings: provider.resolveQualifiedTypeSpellings === true, + }, + ); + const resolved = + producer === undefined + ? resolveCompoundReceiverTyped(receiverName, site.inScope, scopes, index, { + ...fileCompoundOpts, + receiverChain: site.receiverChain, + }) + : returnedClass === undefined + ? undefined + : { + def: returnedClass, + declaredSpelling: returned?.declaredSpelling ?? returned?.rawName, + }; + if (producer !== undefined && resolved === undefined) { + // Defer without claiming the shared start-position key: the inner + // free call may still need that key during this pass. Finalization + // blocks generic fallback after the producer queue is exhausted. + waitForProducer(selectedCallKey(parsed.filePath, producer.range), site); + options.pendingCallReceivers?.set( + selectedCallKey(parsed.filePath, site.atRange), + siteKey, + ); + options.recordResolutionOutcome?.({ + kind: 'suppressed', + reason: 'receiver-unresolved', + candidateIds: [], + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + siteKind: 'call', + receiverOrigin: + returned !== undefined && options.isBuiltInName?.(returned.rawName) === true + ? 'external' + : selected == null + ? 'unknown' + : 'in-program', + receiverShape: classifyReceiverShape(decodeReceiverChain(site.receiverChain)), + }); + continue; + } const currentClass = resolved?.def; compoundReceiverUnresolved = currentClass === undefined; if (resolved !== undefined && currentClass !== undefined) { + const languageResolution = + provider.receiverMemberLookupCoversAllCalls === true && site.kind === 'call' + ? provider.resolveReceiverMember?.(currentClass, memberName, site, scopes, model) + : undefined; + if (suppressProviderResult(languageResolution, site, parsed.filePath)) continue; + providerSelectedMember = languageResolution?.kind === 'resolved'; const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; - let memberDef: SymbolDefinition | undefined; + let memberDef = + languageResolution?.kind === 'resolved' ? languageResolution.definition : undefined; let ambiguousOwnerId: string | undefined; // Static-only filter (#1756 / U3): same shape as Case 4's // overload-aware chain walk (skip-and-walk-on). When @@ -1727,7 +2043,7 @@ export function emitReceiverBoundCalls( // reference index has no compound-receiver entry for // shapes like `Logger.create("a")`), so there's no wrong // target to suppress. - for (const ownerId of chain) { + for (const ownerId of memberDef === undefined ? chain : []) { const picked = site.kind === 'call' ? pickFirstNonStaticOnly(ownerId, memberName, site, model, provider) @@ -1752,6 +2068,7 @@ export function emitReceiverBoundCalls( model, provider, options.recordSourceBinding, + outcomeContext, ); handledSites.add(siteKey); continue; @@ -1829,6 +2146,7 @@ export function emitReceiverBoundCalls( 0.85, calleeCapture, resolved.declaredSpelling, + parsed.filePath, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -1866,24 +2184,8 @@ export function emitReceiverBoundCalls( scopes, model, ); - if (languageResolution?.kind === 'ambiguous') { - options.recordSourceBinding?.(site, { - status: 'ambiguous', - targetDefIds: languageResolution.candidateIds, - basis: 'member-lookup-ambiguous', - }); - options.recordResolutionOutcome?.({ - kind: 'suppressed', - phase: 'receiver-bound-calls', - filePath: parsed.filePath, - name: site.name, - range: site.atRange, - reason: 'member-lookup-ambiguous', - candidateIds: languageResolution.candidateIds, - }); - handledSites.add(siteKey); - continue; - } + if (suppressProviderResult(languageResolution, site, parsed.filePath)) continue; + providerSelectedMember = languageResolution?.kind === 'resolved'; if (languageResolution?.kind === 'resolved') { const memberDef = languageResolution.definition; if ( @@ -2256,24 +2558,8 @@ export function emitReceiverBoundCalls( scopes, model, ); - if (languageResolution?.kind === 'ambiguous') { - options.recordSourceBinding?.(site, { - status: 'ambiguous', - targetDefIds: languageResolution.candidateIds, - basis: 'member-lookup-ambiguous', - }); - options.recordResolutionOutcome?.({ - kind: 'suppressed', - phase: 'receiver-bound-calls', - filePath: parsed.filePath, - name: site.name, - range: site.atRange, - reason: 'member-lookup-ambiguous', - candidateIds: languageResolution.candidateIds, - }); - handledSites.add(siteKey); - continue; - } + if (suppressProviderResult(languageResolution, site, parsed.filePath)) continue; + providerSelectedMember = languageResolution?.kind === 'resolved'; const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; let memberDef: SymbolDefinition | undefined = languageResolution?.kind === 'resolved' ? languageResolution.definition : undefined; @@ -2314,6 +2600,7 @@ export function emitReceiverBoundCalls( model, provider, options.recordSourceBinding, + outcomeContext, ); handledSites.add(siteKey); continue; @@ -2429,10 +2716,21 @@ export function emitReceiverBoundCalls( ? chainDef3 : lookupNamespaceMember(targetFile3, className); if (classDef3 !== undefined) { + const languageResolution = + provider.receiverMemberLookupCoversAllCalls === true && site.kind === 'call' + ? provider.resolveReceiverMember?.(classDef3, memberName, site, scopes, model) + : undefined; + if (suppressProviderResult(languageResolution, site, parsed.filePath)) { + found3 = true; + break; + } + providerSelectedMember = languageResolution?.kind === 'resolved'; const picked = - site.kind === 'call' - ? pickOverload(classDef3.nodeId, memberName, site, model, provider) - : findOwnedMember(classDef3.nodeId, memberName, model); + languageResolution?.kind === 'resolved' + ? languageResolution.definition + : site.kind === 'call' + ? pickOverload(classDef3.nodeId, memberName, site, model, provider) + : findOwnedMember(classDef3.nodeId, memberName, model); if (picked === OVERLOAD_AMBIGUOUS) { recordReceiverOverloadSuppression( options.recordResolutionOutcome, @@ -2443,6 +2741,7 @@ export function emitReceiverBoundCalls( model, provider, options.recordSourceBinding, + outcomeContext, ); handledSites.add(siteKey); found3 = true; @@ -2537,8 +2836,15 @@ export function emitReceiverBoundCalls( } const ownerDef = resolved?.def; if (resolved !== undefined && ownerDef !== undefined) { + const languageResolution = + provider.receiverMemberLookupCoversAllCalls === true && site.kind === 'call' + ? provider.resolveReceiverMember?.(ownerDef, memberName, site, scopes, model) + : undefined; + if (suppressProviderResult(languageResolution, site, parsed.filePath)) continue; + providerSelectedMember = languageResolution?.kind === 'resolved'; const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; - let memberDef: SymbolDefinition | undefined; + let memberDef = + languageResolution?.kind === 'resolved' ? languageResolution.definition : undefined; let ambiguousOwnerId: string | undefined; // Static-only filter (#1756 / U3): mirrors Case 0's // overload-aware chain walk. When @@ -2548,7 +2854,7 @@ export function emitReceiverBoundCalls( // by chain-typebinding receivers, not pre-emitted by // `emitReferencesViaLookup` for compound shapes, so no // handled-site marker is needed for chain-only-static). - for (const ownerId of chain) { + for (const ownerId of memberDef === undefined ? chain : []) { const picked = site.kind === 'call' ? pickFirstNonStaticOnly(ownerId, memberName, site, model, provider) @@ -2573,6 +2879,7 @@ export function emitReceiverBoundCalls( model, provider, options.recordSourceBinding, + outcomeContext, ); handledSites.add(siteKey); continue; @@ -2649,6 +2956,7 @@ export function emitReceiverBoundCalls( 0.85, calleeCapture, resolved.declaredSpelling, + parsed.filePath, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -2774,65 +3082,11 @@ export function emitReceiverBoundCalls( scopes, model, ); - if (languageResolution?.kind === 'ambiguous') { - options.recordSourceBinding?.(site, { - status: 'ambiguous', - targetDefIds: languageResolution.candidateIds, - basis: 'member-lookup-ambiguous', - }); - options.recordResolutionOutcome?.({ - kind: 'suppressed', - phase: 'receiver-bound-calls', - filePath: parsed.filePath, - name: site.name, - range: site.atRange, - reason: 'member-lookup-ambiguous', - candidateIds: languageResolution.candidateIds, - }); - handledSites.add(siteKey); - continue; - } - if (languageResolution?.kind === 'resolved') { - const memberDef = languageResolution.definition; - if ( - suppressDeletedCallTarget( - options.recordResolutionOutcome, - parsed.filePath, - site, - memberDef, - options.recordSourceBinding, - ) - ) { - handledSites.add(siteKey); - continue; - } - const reason = - site.kind === 'write' || site.kind === 'read' - ? site.kind - : memberDef.filePath !== parsed.filePath - ? 'import-resolved' - : 'global'; - const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; - recordMember(memberDef); - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - reason, - seen, - confidence, - collapse, - calleeCapture, - ); - if (ok) emitted++; - handledSites.add(siteKey); - continue; - } - + if (suppressProviderResult(languageResolution, site, parsed.filePath)) continue; + providerSelectedMember = languageResolution?.kind === 'resolved'; const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; - let memberDef: SymbolDefinition | undefined; + let memberDef = + languageResolution?.kind === 'resolved' ? languageResolution.definition : undefined; let ambiguous = false; let ambiguousOwnerId: string | undefined; // Track whether the chain walk filtered out any static-only @@ -2862,7 +3116,7 @@ export function emitReceiverBoundCalls( // such a pair unambiguously to the instance method because // companion members are not legal instance-dispatch // candidates. - for (const ownerId of chain) { + for (const ownerId of memberDef === undefined ? chain : []) { const picked = pickFirstNonStaticOnly(ownerId, memberName, site, model, provider); if (picked === OVERLOAD_AMBIGUOUS) { ambiguous = true; @@ -2897,6 +3151,7 @@ export function emitReceiverBoundCalls( model, provider, options.recordSourceBinding, + outcomeContext, ); handledSites.add(siteKey); continue; @@ -2964,6 +3219,7 @@ export function emitReceiverBoundCalls( confidence, calleeCapture, typeApplication ?? typeRef.rawName, + parsed.filePath, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -3131,6 +3387,7 @@ export function emitReceiverBoundCalls( range: site.atRange, reason: 'member-lookup-ambiguous', candidateIds: [...ambiguousCandidateIds], + ...callSuppressionIdentities([...ambiguousCandidateIds], site, outcomeContext), }); } @@ -3234,6 +3491,7 @@ export function emitReceiverBoundCalls( model, provider, options.recordSourceBinding, + outcomeContext, ); handledSites.add(siteKey); continue; @@ -3408,13 +3666,20 @@ export function emitReceiverBoundCalls( }, ); if (receiverClass !== undefined) { + const languageResolution = + provider.receiverMemberLookupCoversAllCalls === true && site.kind === 'call' + ? provider.resolveReceiverMember?.(receiverClass, memberName, site, scopes, model) + : undefined; + if (suppressProviderResult(languageResolution, site, parsed.filePath)) continue; + providerSelectedMember = languageResolution?.kind === 'resolved'; const chain = [ receiverClass.nodeId, ...scopes.methodDispatch.mroFor(receiverClass.nodeId), ]; - let memberDef: SymbolDefinition | undefined; + let memberDef = + languageResolution?.kind === 'resolved' ? languageResolution.definition : undefined; let ambiguousOwnerId: string | undefined; - for (const ownerId of chain) { + for (const ownerId of memberDef === undefined ? chain : []) { const picked = pickFirstNonStaticOnly(ownerId, memberName, site, model, provider); if (picked === OVERLOAD_AMBIGUOUS) { ambiguousOwnerId = ownerId; @@ -3436,6 +3701,7 @@ export function emitReceiverBoundCalls( model, provider, options.recordSourceBinding, + outcomeContext, ); handledSites.add(siteKey); continue; @@ -3490,6 +3756,7 @@ export function emitReceiverBoundCalls( confidence, calleeCapture, fieldDeclaredType?.name, + parsed.filePath, ); handledSites.add(siteKey); continue; @@ -3814,7 +4081,8 @@ function recordReceiverOverloadSuppression( memberName: string, model: SemanticModel, provider: ReceiverBoundProviderSubset, - recordSourceBinding?: SourceBindingRecorder, + recordSourceBinding: SourceBindingRecorder | undefined, + identityContext: CallOutcomeContext, ): void { if (record === undefined && recordSourceBinding === undefined) return; const overloads = model.methods.lookupAllByOwner(ownerId, memberName); @@ -3845,6 +4113,13 @@ function recordReceiverOverloadSuppression( range: site.atRange, reason, candidateIds: candidates.map((d) => d.nodeId), + ...callSuppressionIdentities( + candidates.map((d) => d.nodeId), + site, + identityContext, + true, + candidates, + ), }); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 1ca5d7347..d61ef190b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -24,6 +24,7 @@ */ import type { + CallResultAssignmentSite, ParsedFile, ReferenceSite, RegistryProviders, @@ -124,9 +125,51 @@ import { import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import { narrowOverloadCandidates } from '../passes/overload-narrowing.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; -import { withdrawAnsweredReceiverDrops } from '../resolution-outcome.js'; +import { answeredSiteKey, withdrawAnsweredReceiverDrops } from '../resolution-outcome.js'; import { isDebugHeapEnabled, logHeapProbe } from '../../utils/heap-probe.js'; import { parseTruthyEnv } from '../../utils/env.js'; +import { callResultBindingKey, selectedCallKey } from '../passes/provider-call-outcome.js'; + +function setCallResultBinding(scope: Scope, name: string, returned: TypeRef | undefined): boolean { + const bindings = scope.typeBindings as Map; + if (returned === undefined) return bindings.delete(name); + const binding: TypeRef = { + rawName: returned.rawName, + ...(returned.declaredSpelling !== undefined + ? { declaredSpelling: returned.declaredSpelling } + : {}), + declaredAtScope: returned.declaredAtScope, + ...(returned.lookupPosition !== undefined ? { lookupPosition: returned.lookupPosition } : {}), + source: 'assignment-inferred', + }; + const previous = bindings.get(name); + if ( + previous?.rawName === binding.rawName && + previous.declaredSpelling === binding.declaredSpelling && + previous.declaredAtScope === binding.declaredAtScope && + previous.source === binding.source && + previous.lookupPosition?.startLine === binding.lookupPosition?.startLine && + previous.lookupPosition?.startCol === binding.lookupPosition?.startCol + ) + return false; + bindings.set(name, binding); + return true; +} + +function applySelectedCallResultBinding( + assignment: CallResultAssignmentSite, + indexes: ScopeResolutionIndexes, + workspaceIndex: ReturnType, + selected: SymbolDefinition | null | undefined, +): boolean { + const scope = indexes.scopeTree.getScope(assignment.inScope); + if (scope === undefined) return false; + const returned = + selected == null + ? undefined + : workspaceIndex.declaredReturnTypeByCallableId.get(selected.nodeId); + return setCallResultBinding(scope, assignment.lhs, returned); +} /** * Join extraction-time assignment identity to Phase-4's exact resolved callee. @@ -143,8 +186,25 @@ export function applyPreciseCallResultBindings( nodeLookup: ReturnType, model: SemanticModel, requireOwnedHoistedReturn = false, + selectedCalls?: ReadonlyMap, ): number { let updated = 0; + if (selectedCalls !== undefined) { + for (const parsed of parsedFiles) { + for (const assignment of parsed.callResultAssignmentSites ?? []) { + if ( + applySelectedCallResultBinding( + assignment, + indexes, + workspaceIndex, + selectedCalls.get(selectedCallKey(parsed.filePath, assignment.callSite)), + ) + ) + updated++; + } + } + return updated; + } const returnTypeByGraphId = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { @@ -167,13 +227,14 @@ export function applyPreciseCallResultBindings( } } for (const assignment of parsed.callResultAssignmentSites ?? []) { + const scope = indexes.scopeTree.getScope(assignment.inScope); + if (scope === undefined) continue; const targets = resolvedByPosition.get( calleeIdPosKey(assignment.callSite.startLine, assignment.callSite.startCol), ); - if (targets === undefined || targets.size !== 1) continue; - const targetId = targets.values().next().value as string | undefined; - if (targetId === undefined) continue; - const returned = returnTypeByGraphId.get(targetId); + const targetId = + targets?.size === 1 ? (targets.values().next().value as string | undefined) : undefined; + const returned = targetId === undefined ? undefined : returnTypeByGraphId.get(targetId); if (returned === undefined) continue; if (requireOwnedHoistedReturn) { const site = callsByPosition?.get( @@ -225,18 +286,14 @@ export function applyPreciseCallResultBindings( ) continue; } - const returnType = returned.type; - const scope = indexes.scopeTree.getScope(assignment.inScope); - if (scope === undefined) continue; - (scope.typeBindings as Map).set(assignment.lhs, { - rawName: returnType.rawName, - ...(returnType.declaredSpelling !== undefined - ? { declaredSpelling: returnType.declaredSpelling } - : {}), - declaredAtScope: assignment.inScope, - source: 'assignment-inferred', - }); - updated++; + if ( + setCallResultBinding(scope, assignment.lhs, { + ...returned.type, + declaredAtScope: assignment.inScope, + lookupPosition: undefined, + }) + ) + updated++; } } return updated; @@ -540,7 +597,10 @@ interface RunScopeResolutionInput { * `ParsedFile[]` to mirror the pipeline shape. */ readonly model: MutableSemanticModel; - readonly files: readonly { readonly path: string; readonly content: string }[]; + readonly files: readonly { + readonly path: string; + readonly content: string; + }[]; readonly onWarn?: (message: string) => void; /** * Optional pre-parsed-Tree lookup keyed by file path: a cache hit lets the @@ -1203,6 +1263,51 @@ export function runScopeResolution( // VALIDATE_SEMANTIC_MODEL=1. validateBindingsImmutability(indexes, onWarn); + const selectedCalls = + provider.receiverCallSite === undefined + ? undefined + : new Map(); + const pendingCallReceivers = selectedCalls === undefined ? undefined : new Map(); + const callResultProducers = + selectedCalls === undefined ? undefined : new Map(); + const assignmentsByProducer = new Map(); + if (callResultProducers !== undefined) { + for (const parsed of parsedFiles) { + for (const assignment of parsed.callResultAssignmentSites ?? []) { + callResultProducers.set( + callResultBindingKey(assignment.inScope, assignment.lhs), + assignment, + ); + const key = selectedCallKey(parsed.filePath, assignment.callSite); + let assignments = assignmentsByProducer.get(key); + if (assignments === undefined) assignmentsByProducer.set(key, (assignments = [])); + assignments.push(assignment); + } + } + } + const onSelectedCall = + selectedCalls === undefined + ? undefined + : (key: string, definition: SymbolDefinition | null): void => { + for (const assignment of assignmentsByProducer.get(key) ?? []) { + applySelectedCallResultBinding(assignment, indexes, workspaceIndex, definition); + } + }; + if (!callableFlowOnly && selectedCalls !== undefined) { + // Clear guessed assignment returns before Registry.lookup can cache a + // wrong downstream member. Exact primaries restore them after emission. + applyPreciseCallResultBindings( + parsedFiles, + indexes, + workspaceIndex, + createCalleeIdAccumulator(), + postHeritageNodeLookup, + readonlyModel, + provider.hoistedTypeBindingsRequireOwner === true, + selectedCalls, + ); + } + // ── Phase 3: resolve references via Registry.lookup ──────────────────── input.onProgress?.('resolving references', files.length, files.length); const registryProviders: RegistryProviders = { @@ -1373,7 +1478,10 @@ export function runScopeResolution( // propagation. Populated below at every CALLS emit path before dedup; the CFG // join still consumes it only inside the `input.pdg` block. const calleeIdAccumulator: CalleeIdAccumulator | undefined = - input.pdg === true || deferredIndirectSites.size > 0 || callResultAssignmentSites.size > 0 + input.pdg === true || + deferredIndirectSites.size > 0 || + callResultAssignmentSites.size > 0 || + provider.receiverCallSite !== undefined ? createCalleeIdAccumulator( input.pdg === true ? undefined @@ -1401,6 +1509,10 @@ export function runScopeResolution( { recordResolutionOutcome, recordSourceBinding, + selectedCalls, + onSelectedCall, + pendingCallReceivers, + callResultProducers, allFilePaths, calleeIdSink: calleeIdAccumulator, // The pass's sources of positive EXTERNAL evidence for a dropped @@ -1476,6 +1588,9 @@ export function runScopeResolution( resolveAdlCandidates: provider.resolveAdlCandidates, resolveOrdinaryCallables: provider.resolveOrdinaryCallables, resolveQualifiedFreeCall: provider.resolveQualifiedFreeCall, + resolveUnqualifiedCall: provider.resolveUnqualifiedCall, + selectedCalls, + onSelectedCall, conversionRankFn: provider.conversionRankFn, conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes, constraintCompatibility: provider.constraintCompatibility, @@ -1487,7 +1602,7 @@ export function runScopeResolution( ); probeEmit('free-calls-end', `handled=${handledSites.size} emitted=${freeCallExtras}`); const replayedCallResultBindings = - callableFlowOnly || calleeIdAccumulator === undefined + callableFlowOnly || calleeIdAccumulator === undefined || selectedCalls !== undefined ? 0 : applyPreciseCallResultBindings( emitParsedFiles, @@ -1497,8 +1612,9 @@ export function runScopeResolution( postHeritageNodeLookup, readonlyModel, provider.hoistedTypeBindingsRequireOwner === true, + selectedCalls, ); - if (replayedCallResultBindings > 0) { + if (replayedCallResultBindings > 0 || selectedCalls !== undefined) { const handledBeforeReplay = new Set(handledSites); const replayedReceiverBound = emitReceiverBoundCalls( graph, @@ -1512,6 +1628,11 @@ export function runScopeResolution( { calleeIdSink: calleeIdAccumulator, recordSourceBinding, + recordResolutionOutcome: selectedCalls === undefined ? undefined : recordResolutionOutcome, + selectedCalls, + onSelectedCall, + pendingCallReceivers, + callResultProducers, isBuiltInName: provider.languageProvider.isBuiltInName, externalReceiverNames: provider.languageProvider.externalReceiverNames, isExternalModuleSpecifier: provider.languageProvider.isExternalModuleSpecifier, @@ -1537,19 +1658,42 @@ export function runScopeResolution( for (const key of handledSites) { if (!handledBeforeReplay.has(key)) resolvedOnReplay.add(key); } - if (resolvedOnReplay.size > 0) { - for (let i = resolutionOutcomes.length - 1; i >= 0; i--) { - const outcome = resolutionOutcomes[i]; - if ( - outcome.kind === 'suppressed' && - outcome.reason === 'receiver-unresolved' && - resolvedOnReplay.has(callableFlowSiteKey(outcome.filePath, outcome.range)) - ) { - resolutionOutcomes.splice(i, 1); + if (resolvedOnReplay.size > 0 || selectedCalls !== undefined) { + // Replay can retry an unresolved site. Keep its latest diagnostic once, + // and compact in one pass so withdrawing many drops stays linear. + const receiverDropPositions = new Map(); + let kept = 0; + for (const outcome of resolutionOutcomes) { + if (outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved') { + const resolved = + selectedCalls === undefined + ? resolvedOnReplay.has(callableFlowSiteKey(outcome.filePath, outcome.range)) + : selectedCalls.get(selectedCallKey(outcome.filePath, outcome.range)) != null; + if (resolved) continue; + if (outcome.siteKind !== undefined) { + const key = answeredSiteKey( + outcome.filePath, + outcome.range, + outcome.siteKind, + outcome.name, + ); + const previous = receiverDropPositions.get(key); + if (previous !== undefined) { + resolutionOutcomes[previous] = outcome; + continue; + } + receiverDropPositions.set(key, kept); + } } + resolutionOutcomes[kept++] = outcome; } + resolutionOutcomes.length = kept; } } + for (const [key, handledKey] of pendingCallReceivers ?? []) { + selectedCalls?.set(key, null); + handledSites.add(handledKey); + } // Receiver/free-call passes have finished reading this set. Extend it for // the remaining passes instead of retaining three whole-program copies. const referenceSkipSites = handledSites; @@ -1667,7 +1811,12 @@ export function runScopeResolution( // propagation. `graph.addRelationship` remains first-write-wins, so precise // edges already emitted for a site retain ownership. const propertyDispatch = callableFlowOnly - ? { usesEmitted: 0, callsEmitted: 0, skippedKeys: 0, skippedKeyNames: [] as readonly string[] } + ? { + usesEmitted: 0, + callsEmitted: 0, + skippedKeys: 0, + skippedKeyNames: [] as readonly string[], + } : emitPropertyDispatchCalls( graph, indexes, @@ -2176,7 +2325,11 @@ export function runScopeResolution( logHeapProbe('sr-end', `lang=${provider.language} parsedFiles=${parsedFiles.length}`); for (const outcome of resolutionOutcomes) input.recordResolutionOutcome?.(outcome); - input.onResolved?.({ parsedFiles, indexes, referenceBindings: referenceBindings! }); + input.onResolved?.({ + parsedFiles, + indexes, + referenceBindings: referenceBindings!, + }); return { filesProcessed: parsedFiles.length, diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index 9ccbcdf53..0e310dc41 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -9,6 +9,8 @@ export type ResolutionSuppressionReason = | 'selected-callable-deleted' | 'overload-ambiguous' | 'overload-ambiguous-normalization' + | 'overload-unknown' + | 'overload-inapplicable' | 'free-call-instance-ownership' /** #2701 — the receiver is rebound by its own scope and has no type * there (a JS/TS ordinary `function`'s `this`), so no enclosing type @@ -41,6 +43,15 @@ export type ResolutionOutcome = * qualified namespace resolver returns only an `ambiguous` sentinel. */ readonly candidateIds: readonly string[]; + /** Public graph identities, kept separate from scope definition IDs. */ + readonly candidateGraphIds?: readonly string[]; + /** Public graph identities of the candidates' declaring types. */ + readonly candidateOwnerIds?: readonly string[]; + /** Public graph identity of the enclosing callable or initializer. */ + readonly callerId?: string; + /** Candidate inventory and every required graph identity were proven. */ + readonly candidateSetComplete?: boolean; + readonly detail?: string; readonly phase: string; readonly filePath: string; readonly name: string; diff --git a/gitnexus/src/core/ingestion/scope-resolution/unresolved-overloads.ts b/gitnexus/src/core/ingestion/scope-resolution/unresolved-overloads.ts new file mode 100644 index 000000000..193fd7482 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/unresolved-overloads.ts @@ -0,0 +1,310 @@ +import type { Range } from 'gitnexus-shared'; +import type { ResolutionOutcome } from './resolution-outcome.js'; + +/** Bound persisted diagnostics independently of repository size. */ +export const MAX_UNRESOLVED_OVERLOAD_SITES = 2_000; +export const MAX_OVERLOAD_IDENTITIES_PER_SITE = 256; +export const MAX_OVERLOAD_SUMMARY_BYTES = 2 * 1024 * 1024; + +export interface UnresolvedOverloadSite { + readonly id: string; + readonly filePath: string; + readonly name: string; + readonly range: Range; + /** Public graph identities, never scope-resolution definition IDs. */ + readonly candidateIds: readonly string[]; + readonly candidateOwnerIds: readonly string[]; + readonly callerIds: readonly string[]; + readonly reasons: readonly string[]; +} + +export interface UnresolvedOverloadSummary { + readonly schemaVersion: 1; + /** False for incomplete attribution, missing input, or a capped summary. */ + readonly complete: boolean; + /** Unique unresolved call sites before the persistence cap. */ + readonly totalSites: number; + readonly omittedSites: number; + /** Kept once per site; queries union matching identities, not per-target counts. */ + readonly sites: readonly UnresolvedOverloadSite[]; +} + +export interface OverloadBoundary { + readonly notes: readonly string[]; + readonly sites: number; +} + +const overloadReasons = new Set([ + 'overload-ambiguous', + 'overload-ambiguous-normalization', + 'conversion-rank-tied', + 'member-lookup-ambiguous', + 'overload-unknown', +]); + +type Suppressed = Extract; + +function siteId(outcome: { filePath: string; range: Range }): string { + const { range } = outcome; + return JSON.stringify([ + outcome.filePath, + range.startLine, + range.startCol, + range.endLine, + range.endCol, + ]); +} + +const ordered = (items: Iterable): string[] => [...new Set(items)].sort(); + +/** + * Summarize final decisions, including an explicit receipt for a clean run. + * A replay may resolve an earlier suppression. Reconcile within its phase: + * resolving a primary call does not resolve an unknown implementation dispatch. + */ +export function summarizeUnresolvedOverloads( + outcomes: readonly ResolutionOutcome[], + inputComplete = true, +): UnresolvedOverloadSummary { + const pending = new Map>(); + let complete = inputComplete; + for (const outcome of outcomes) { + if (outcome.kind !== 'resolved' && outcome.kind !== 'suppressed') continue; + const id = siteId(outcome); + if (outcome.kind === 'resolved' || outcome.reason === 'overload-inapplicable') { + const phases = pending.get(id); + phases?.delete(outcome.phase); + if (phases?.size === 0) pending.delete(id); + continue; + } + if (!overloadReasons.has(outcome.reason)) continue; + if (outcome.siteKind !== undefined && outcome.siteKind !== 'call') continue; + // Old member-lookup records also described property reads and writes. + // Without their kind we cannot count them as call sites or certify coverage. + if (outcome.reason === 'member-lookup-ambiguous' && outcome.siteKind === undefined) { + complete = false; + continue; + } + let phases = pending.get(id); + if (phases === undefined) pending.set(id, (phases = new Map())); + phases.set(outcome.phase, outcome); + } + + const sites: UnresolvedOverloadSite[] = []; + let bytes = 256; // Header and punctuation, conservatively above their maximum. + for (const id of [...pending.keys()].sort()) { + const phases = [...pending.get(id)!.values()]; + const first = phases[0]; + const candidates = ordered(phases.flatMap((outcome) => outcome.candidateGraphIds ?? [])); + const owners = ordered(phases.flatMap((outcome) => outcome.candidateOwnerIds ?? [])); + const callers = ordered( + phases.flatMap((outcome) => (outcome.callerId ? [outcome.callerId] : [])), + ); + if ( + phases.some((outcome) => outcome.candidateSetComplete !== true) || + candidates.length === 0 || + callers.length !== 1 || + phases.some((outcome) => !outcome.callerId) + ) { + complete = false; + } + if ( + candidates.length > MAX_OVERLOAD_IDENTITIES_PER_SITE || + owners.length > MAX_OVERLOAD_IDENTITIES_PER_SITE || + callers.length > MAX_OVERLOAD_IDENTITIES_PER_SITE + ) { + complete = false; + } + const site: UnresolvedOverloadSite = { + id, + filePath: first.filePath, + name: ordered(phases.map((outcome) => outcome.name))[0], + range: first.range, + candidateIds: candidates.slice(0, MAX_OVERLOAD_IDENTITIES_PER_SITE), + candidateOwnerIds: owners.slice(0, MAX_OVERLOAD_IDENTITIES_PER_SITE), + callerIds: callers.slice(0, MAX_OVERLOAD_IDENTITIES_PER_SITE), + reasons: ordered(phases.map((outcome) => outcome.reason)), + }; + const siteBytes = Buffer.byteLength(JSON.stringify(site), 'utf8') + 1; + if ( + sites.length >= MAX_UNRESOLVED_OVERLOAD_SITES || + bytes + siteBytes > MAX_OVERLOAD_SUMMARY_BYTES + ) { + complete = false; + continue; + } + sites.push(site); + bytes += siteBytes; + } + return { + schemaVersion: 1, + complete, + totalSites: pending.size, + omittedSites: pending.size - sites.length, + sites, + }; +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function integer(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function identities(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length <= MAX_OVERLOAD_IDENTITIES_PER_SITE && + value.every((id) => typeof id === 'string' && id.length > 0) && + new Set(value).size === value.length + ); +} + +function isRange(value: unknown): value is Range { + return ( + record(value) && + ['startLine', 'startCol', 'endLine', 'endCol'].every( + (key) => Object.hasOwn(value, key) && integer(value[key]), + ) && + (value.endLine as number) >= (value.startLine as number) && + (value.endLine !== value.startLine || (value.endCol as number) >= (value.startCol as number)) + ); +} + +/** Metadata comes from JSON: validate the receipt, bounds and every measured count. */ +function readSummary(value: unknown): UnresolvedOverloadSummary | undefined { + if ( + !record(value) || + !['schemaVersion', 'complete', 'totalSites', 'omittedSites', 'sites'].every((key) => + Object.hasOwn(value, key), + ) || + value.schemaVersion !== 1 || + typeof value.complete !== 'boolean' || + !integer(value.totalSites) || + !integer(value.omittedSites) || + !Array.isArray(value.sites) || + value.sites.length > MAX_UNRESOLVED_OVERLOAD_SITES || + value.totalSites !== value.sites.length + value.omittedSites || + (value.complete && value.omittedSites !== 0) + ) { + return undefined; + } + const seen = new Set(); + const sites: UnresolvedOverloadSite[] = []; + for (const site of value.sites) { + if ( + !record(site) || + ![ + 'id', + 'filePath', + 'name', + 'range', + 'candidateIds', + 'candidateOwnerIds', + 'callerIds', + 'reasons', + ].every((key) => Object.hasOwn(site, key)) || + typeof site.id !== 'string' || + typeof site.filePath !== 'string' || + typeof site.name !== 'string' || + !isRange(site.range) || + !identities(site.candidateIds) || + !identities(site.candidateOwnerIds) || + !identities(site.callerIds) || + !identities(site.reasons) || + site.reasons.length === 0 || + site.reasons.some((reason) => !overloadReasons.has(reason)) || + (value.complete && (site.candidateIds.length === 0 || site.callerIds.length !== 1)) + ) { + return undefined; + } + if (site.id !== siteId({ filePath: site.filePath, range: site.range }) || seen.has(site.id)) { + return undefined; + } + seen.add(site.id); + sites.push({ + id: site.id, + filePath: site.filePath, + name: site.name, + range: site.range, + candidateIds: site.candidateIds, + candidateOwnerIds: site.candidateOwnerIds, + callerIds: site.callerIds, + reasons: site.reasons, + }); + } + try { + if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_OVERLOAD_SUMMARY_BYTES) { + return undefined; + } + } catch { + return undefined; + } + return { + schemaVersion: 1, + complete: value.complete, + totalSites: value.totalSites, + omittedSites: value.omittedSites, + sites, + }; +} + +/** + * Match a whole expanded traversal in one bounded pass. A site touching several + * visited candidates is counted once. Owner IDs handle type queries without + * attributing every method in the owning file to every class in that file. + */ +export function probeOverloadBoundaries( + value: unknown, + symbolIds: ReadonlySet, + direction: 'upstream' | 'downstream' | 'both', + includeSite: (site: UnresolvedOverloadSite) => boolean = () => true, + traversalComplete = true, +): OverloadBoundary { + if (symbolIds.size === 0) return { notes: [], sites: 0 }; + const summary = readSummary(value); + if (summary === undefined) { + return { + notes: [ + 'Overload-resolution completeness was not recorded or its metadata is invalid. ' + + 'Run analyze --force with the current analyzer before trusting zero callers; actual impact may be higher.', + ], + sites: 0, + }; + } + let sites = 0; + let eligibleSites = 0; + for (const site of summary.sites) { + if (!includeSite(site)) continue; + eligibleSites++; + const incoming = + direction !== 'downstream' && + (site.candidateIds.some((id) => symbolIds.has(id)) || + site.candidateOwnerIds.some((id) => symbolIds.has(id))); + const outgoing = direction !== 'upstream' && site.callerIds.some((id) => symbolIds.has(id)); + if (incoming || outgoing) sites++; + } + const notes: string[] = []; + if (sites > 0) { + notes.push( + `${sites} call ${sites === 1 ? 'site has' : 'sites have'} unresolved overload or implementation ` + + 'targets in this query. Those call edges were not recorded, so actual impact may be higher.', + ); + } + if (!summary.complete) { + notes.push( + 'Overload-resolution coverage is partial: candidate/caller identities or retained call-site ' + + 'details are incomplete. The reported unresolved-site count is a lower bound; ' + + 'absence from this summary does not establish complete coverage.', + ); + } + if (!traversalComplete && eligibleSites > 0) { + notes.push( + 'The impact traversal was incomplete, so unresolved overload sites outside the examined ' + + 'frontier may also affect this result.', + ); + } + return { notes, sites }; +} diff --git a/gitnexus/src/core/ingestion/workers/callable-id.ts b/gitnexus/src/core/ingestion/workers/callable-id.ts index 7672dd8c8..39db8f81d 100644 --- a/gitnexus/src/core/ingestion/workers/callable-id.ts +++ b/gitnexus/src/core/ingestion/workers/callable-id.ts @@ -24,6 +24,12 @@ import type { SyntaxNode } from '../utils/ast-helpers.js'; import { definitionIdPosition } from '../scope-resolution/utils/definition-id.js'; const LOCAL_IDENTITY_SUFFIX = /@\d+:\d+$/; +// Parsed-file definitions are finalized before worker graph extraction. Index +// once per immutable array; weak keys release the index with its parse result. +const definitionsByName = new WeakMap< + readonly SymbolDefinition[], + Map +>(); function simpleDefinitionName(def: SymbolDefinition): string | undefined { const qualifiedName = def.qualifiedName; @@ -67,17 +73,26 @@ export function boundCallableStartPosition( ): { readonly row: number; readonly column: number } { if (localDefs === undefined) return definitionNode.startPosition; + let index = definitionsByName.get(localDefs); + if (index === undefined) { + index = new Map(); + for (const def of localDefs) { + for (const name of new Set([simpleDefinitionName(def), def.qualifiedName])) { + if (name === undefined) continue; + const key = `${def.type}\0${name}`; + const matches = index.get(key) ?? []; + matches.push(def); + index.set(key, matches); + } + } + definitionsByName.set(localDefs, index); + } + const origin = nameNode?.startPosition ?? definitionNode.startPosition; let best: { row: number; column: number; distance: number } | undefined; let tied = false; - for (const def of localDefs) { - if ( - def.type !== nodeLabel || - (simpleDefinitionName(def) !== nodeName && def.qualifiedName !== nodeName) - ) { - continue; - } + for (const def of index.get(`${nodeLabel}\0${nodeName}`) ?? []) { const position = definitionIdPosition(def.nodeId, def.filePath); if (position === undefined) continue; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index bec58c4b6..289946659 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -57,6 +57,7 @@ import { } from './ingestion/scope-resolution/unresolved-receivers.js'; import { summarizeUndecidedSatisfaction } from './ingestion/scope-resolution/undecided-satisfaction.js'; import { summarizeScopeExtractionFailures } from './ingestion/scope-resolution/scope-extraction-failures.js'; +import { summarizeUnresolvedOverloads } from './ingestion/scope-resolution/unresolved-overloads.js'; import type { KnowledgeGraph } from './graph/types.js'; import { reconcileGraphNodeIdentities } from './incremental/write-reconciliation.js'; import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js'; @@ -5362,6 +5363,12 @@ async function runFullAnalysisInner( // Git-only: non-git repos never take the incremental path. schemaFingerprint: hasGitDir(repoPath) ? SCHEMA_FINGERPRINT : undefined, unresolvedReceiverMembers: summarizeUnresolvedReceivers(resolutionOutcomes), + // Resolution runs again on warm parse-cache hits. Replace the receipt, + // including complete-empty, so corrected/deleted source clears old gaps. + unresolvedOverloads: summarizeUnresolvedOverloads( + resolutionOutcomes, + pipelineResult.resolutionOutcomes !== undefined, + ), nameFallbackEdges: nameFallbackSummary, scopeExtractionFailures: summarizeScopeExtractionFailures( pipelineResult.scopeExtractionFailures, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index c8dea8657..989292246 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -172,6 +172,10 @@ import { lookupUnresolvedCallCount, } from '../../core/ingestion/scope-resolution/unresolved-receivers.js'; import type { UnresolvedReceiverSummary } from '../../core/ingestion/scope-resolution/unresolved-receivers.js'; +import { + probeOverloadBoundaries, + type OverloadBoundary, +} from '../../core/ingestion/scope-resolution/unresolved-overloads.js'; import type { UndecidedSatisfactionSummary } from '../../core/ingestion/scope-resolution/undecided-satisfaction.js'; import { scopeExtractionFailureTotal } from '../../core/ingestion/scope-resolution/scope-extraction-failures.js'; import { lookupCount } from '../../core/ingestion/scope-resolution/summary-maps.js'; @@ -830,6 +834,12 @@ export interface EpistemicCauses { * `unresolvedReceiverMembers` summary — the same number the prose note quotes. */ readonly receiverTyping: number; + /** + * Unique call sites whose overload or implementation targets were unresolved. + * Counted once across all queried candidates. A partial/unverified receipt + * can report zero with a boundary note; zero alone does not prove completeness. + */ + readonly overloadResolution?: number; /** * Symbols on or beyond a dispatch boundary that the traversal could not * attribute statically: implementations plus interface-level consumers. @@ -914,6 +924,33 @@ export interface EpistemicCauses { readonly callableValueReferences: number; } +type EpistemicBoundary = { + epistemic: 'exact' | 'lower-bound'; + boundaries?: string[]; + causes?: EpistemicCauses; +}; + +function mergeOverloadBoundary( + existing: EpistemicBoundary, + overloads: OverloadBoundary, +): EpistemicBoundary { + if (overloads.notes.length === 0) return existing; + return { + epistemic: 'lower-bound', + boundaries: [...(existing.boundaries ?? []), ...overloads.notes], + causes: { + scopeExtractionFiles: 0, + receiverTyping: 0, + dispatchBoundary: 0, + externalBoundary: 0, + undecidedSatisfaction: 0, + callableValueReferences: 0, + ...existing.causes, + overloadResolution: overloads.sites, + }, + }; +} + function epistemicFrom(dropped: { notes: readonly string[]; sites: number; @@ -8366,6 +8403,7 @@ export class LocalBackend { symType: string, symName: string, direction?: 'upstream' | 'downstream', + includeOverloads = true, ): Promise<{ epistemic: 'exact' | 'lower-bound'; boundaries?: string[]; @@ -8391,6 +8429,11 @@ export class LocalBackend { meta = undefined; } const receiverDrops = unresolvedReceiverBoundaries(meta?.unresolvedReceiverMembers, symName); + // Context has both incoming and outgoing references. Impact defers this + // probe until its expanded frontier is known, avoiding root double-counts. + const overloadDrops = includeOverloads + ? probeOverloadBoundaries(meta?.unresolvedOverloads, new Set([symId]), direction ?? 'both') + : { notes: [], sites: 0 }; const scopeExtractionDrops = scopeExtractionBoundaries( meta?.scopeExtractionFailures, meta?.scopeExtractionReceipt, @@ -8483,7 +8526,9 @@ export class LocalBackend { }); } } - if (boundary.size === 0) return epistemicFrom(droppedBoundaries); + if (boundary.size === 0) { + return mergeOverloadBoundary(epistemicFrom(droppedBoundaries), overloadDrops); + } const ifaceIds = Array.from(boundary.keys()); // Count per interface id with scalar equality. A parameterized @@ -8549,24 +8594,29 @@ export class LocalBackend { ); } } - if (boundaries.length === 0) return epistemicFrom(droppedBoundaries); - return { - epistemic: 'lower-bound', - boundaries: [...droppedBoundaries.notes, ...boundaries], - causes: { - scopeExtractionFiles: droppedBoundaries.scopeExtraction, - receiverTyping: droppedBoundaries.sites, - dispatchBoundary: droppedBoundaries.dispatch + dispatchBoundarySymbols, - externalBoundary: droppedBoundaries.external, - undecidedSatisfaction: droppedBoundaries.undecided, - callableValueReferences: droppedBoundaries.callableValueReferences, + if (boundaries.length === 0) { + return mergeOverloadBoundary(epistemicFrom(droppedBoundaries), overloadDrops); + } + return mergeOverloadBoundary( + { + epistemic: 'lower-bound', + boundaries: [...droppedBoundaries.notes, ...boundaries], + causes: { + scopeExtractionFiles: droppedBoundaries.scopeExtraction, + receiverTyping: droppedBoundaries.sites, + dispatchBoundary: droppedBoundaries.dispatch + dispatchBoundarySymbols, + externalBoundary: droppedBoundaries.external, + undecidedSatisfaction: droppedBoundaries.undecided, + callableValueReferences: droppedBoundaries.callableValueReferences, + }, }, - }; + overloadDrops, + ); } catch (error) { rethrowSymbolIdentityError(error); // Never let the heritage probe's failure suppress a drop we already know // about — the whole point is that silence must not read as certainty. - return epistemicFrom(droppedBoundaries); + return mergeOverloadBoundary(epistemicFrom(droppedBoundaries), overloadDrops); } } @@ -8682,7 +8732,18 @@ export class LocalBackend { symType, (sym.name || sym[1]) as string, direction, + false, ); + const checkOverloads = !opts.skipEpistemic && relationTypes.includes('CALLS'); + // readToolMetadata is request-cached: this shares the existing probe's + // metadata read and needs no graph queries for individual frontier nodes. + const overloadMetadataPromise = checkOverloads + ? Promise.resolve() + .then(() => this.readToolMetadata(repo)) + .then((meta) => meta?.unresolvedOverloads) + .catch(() => undefined) + : Promise.resolve(undefined); + const expandedOverloadIds = new Set(); const beanMetadataPromise = opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) @@ -8882,6 +8943,9 @@ export class LocalBackend { for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) { const nextFrontier: string[] = []; + if (checkOverloads) { + for (const id of frontier) expandedOverloadIds.add(id); + } // Batch frontier nodes into a single Cypher query per depth level. // ids/types/confidence are bound parameters (see above) — no interpolation. @@ -9554,11 +9618,25 @@ export class LocalBackend { // #1858 — await the epistemic boundary probe kicked off alongside the BFS // above. Additive: leaves impactedCount and every existing field untouched. - const [epistemic, beanMetadata, aopMetadata] = await Promise.all([ + const [targetEpistemic, beanMetadata, aopMetadata, overloadSummary] = await Promise.all([ epistemicPromise, beanMetadataPromise, aopMetadataPromise, + overloadMetadataPromise, ]); + const epistemic = + checkOverloads && targetEpistemic.epistemic !== undefined + ? mergeOverloadBoundary( + { ...targetEpistemic, epistemic: targetEpistemic.epistemic }, + probeOverloadBoundaries( + overloadSummary, + expandedOverloadIds, + direction, + (site) => direction !== 'upstream' || includeTests || !isTestFilePath(site.filePath), + traversalComplete, + ), + ) + : targetEpistemic; const base = { target: { diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 4b65b3a8d..0ad26c729 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -346,12 +346,13 @@ Use uid, or name with file_path/kind. Ambiguous results return ranked candidates include_content returns retained source. Leave chain_depth:0 unless layered CALLS neighbours are needed. routes:[{url,method?}] and is_entry_point describe HTTP handlers and process entries. ACCESSES references carry reason "read"/"write"; CALLS can follow field and method chains. -COMPLETENESS: epistemic "lower-bound" means incoming is a floor: callers were missed OR a completeness probe failed. Read boundaries to distinguish them; branch on causes, not prose. Empty callers do not establish safety. -- scopeExtractionFiles: files missing scope edges. Run analyze --force; inspect remaining extraction warnings. -- receiverTyping: dropped call sites with unresolved receiver types; corroborate by text search. -- externalBoundary: calls leaving the indexed program; not a defect, compatible with epistemic "exact". -- dispatchBoundary: symbols across DI/interface boundaries, not sites. Runtime-proxy gaps can be uncountable (0 with lower-bound). -- undecidedSatisfaction: unjudged interface/type pairs without IMPLEMENTS edges; missing dependencies can cause this. +COMPLETENESS: epistemic "lower-bound" means incoming is a floor (missed callers/failed probes). Read boundaries; branch on causes. Empty callers do not prove safety. +- scopeExtractionFiles: files missing scope edges; analyze --force, inspect warnings. +- receiverTyping: sites with unknown receiver types; corroborate by text search. +- overloadResolution: unique unresolved overload/implementation sites matched by symbol/declaring owner. Missing/invalid/capped receipts imply lower-bound even at 0; analyze --force. +- externalBoundary: calls outside indexed code; compatible with "exact", not a defect. +- dispatchBoundary: DI/interface symbols, not sites. Runtime-proxy gaps may be uncountable (0 with lower-bound). +- undecidedSatisfaction: unjudged interface/type pairs without IMPLEMENTS; check dependencies. - callableValueReferences: symbols holding the callable as a value via USES, without a modeled later invocation. Follow the registrations/readers. Zig registrations and JS/TS object properties are captured; bare JS/TS callback arguments are not counted. Synthesized dispatch excludes the whole symbol, so mixed followed/unfollowed registrations can report 0. Zero means no unfollowed registration was proven, not no escapes; a failed probe also reports 0 with lower-bound. Older indexes can lack captures/metadata; re-analyze before trusting zero causes or exact coverage. Use impact() before edits; read gitnexus://repo/{name}/process/{processName} for a full flow. @@ -552,12 +553,13 @@ RESULTS: target, impactedCount, summary, affected_processes, affected_modules, b - staticGated:true marks a provably compile-time-unreachable edge (currently Zig); absence proves neither reachability nor coverage. Traversal/risk do not filter on it. - affected_routes and row routes:[{url,method?}] are callgraph-only HTTP enrichment, not risk inputs. -COMPLETENESS: epistemic "lower-bound" means impactedCount is a floor: missed callers OR a failed completeness probe. Read boundaries for which; use causes for machine decisions. Ambiguous lists/group fan-out may omit this envelope. -- scopeExtractionFiles counts files missing scope edges. Run analyze --force; inspect remaining extraction warnings. -- receiverTyping counts dropped call sites with unresolved receiver types. Corroborate before deletion/rename. -- externalBoundary counts calls leaving the program; not an analyzer defect or an in-graph shortfall. Compatible with epistemic "exact". -- dispatchBoundary counts symbols beyond DI/interface boundaries, not sites. Runtime proxies can prove uncountable gaps (0 with lower-bound). -- undecidedSatisfaction counts unjudged interface/type pairs without IMPLEMENTS edges; missing dependencies are a common fixable cause. +COMPLETENESS: epistemic "lower-bound" makes impactedCount a floor (missed callers/failed probes). Read boundaries; branch on causes. Ambiguous/group results may omit this envelope. +- scopeExtractionFiles: files missing scope edges; analyze --force, inspect warnings. +- receiverTyping: sites with unknown receiver types; corroborate before deletion/rename. +- overloadResolution: when traversing CALLS, unique unresolved overload/implementation sites in the expanded traversal before pagination. Missing/invalid/capped receipts imply lower-bound even at 0; analyze --force. +- externalBoundary: calls outside indexed code; compatible with "exact", not a defect. +- dispatchBoundary: DI/interface symbols, not sites. Runtime-proxy gaps may be uncountable (0 with lower-bound). +- undecidedSatisfaction: unjudged interface/type pairs without IMPLEMENTS; check dependencies. - callableValueReferences counts symbols registering the callable VALUE via USES without a modeled later invocation. Follow registrations/readers; low risk/counts remain floors. Zig registrations and JS/TS object properties are captured; bare JS/TS callback arguments are not counted. Synthesized property dispatch excludes the whole symbol, so mixed followed/unfollowed registrations can report 0. Zero means no unfollowed registration was proven, not no escapes; a failed probe also reports 0 with lower-bound. Older indexes can lack captures/metadata; re-analyze before trusting zero causes or apparently exact coverage from an older analyzer. diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index e97c80e36..a4ca81ac4 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -29,6 +29,7 @@ import fs from 'fs/promises'; import path from 'path'; import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js'; +import type { UnresolvedOverloadSummary } from '../core/ingestion/scope-resolution/unresolved-overloads.js'; import type { NameFallbackSummary } from '../core/ingestion/scope-resolution/name-fallback-summary.js'; import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js'; import { resolveStoragePath } from './storage-resolver.js'; @@ -341,6 +342,12 @@ export interface RepoMeta { * this adds no runtime dependency from storage/ on core/. */ unresolvedReceiverMembers?: UnresolvedReceiverSummary; + /** + * Final unresolved overload/implementation call sites, attributed by public + * candidate, caller and declaring-owner identity. A complete empty summary + * certifies measured-clean coverage; absence or invalid metadata is unverified. + */ + unresolvedOverloads?: UnresolvedOverloadSummary; /** * Files omitted from scope-resolution because their provider capture or * extraction step threw. The rest of each file may still be present in the diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/BillingService.java b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/BillingService.java new file mode 100644 index 000000000..c6b8103b0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/BillingService.java @@ -0,0 +1,17 @@ +package demo; + +import java.util.Arrays; +import java.util.Map; + +public class BillingService { + + private final LookupService lookup; + + public BillingService(LookupService lookup) { + this.lookup = lookup; + } + + public Map settle() { + return lookup.find(Arrays.asList(10L, 20L)); // -> find(Collection) + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/LookupService.java b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/LookupService.java new file mode 100644 index 000000000..339d2346f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/LookupService.java @@ -0,0 +1,38 @@ +package demo; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class LookupService { + + // Group 1: same arity, generic parameter type (Collection vs Long) + public Map find(Collection ids) { + Map out = new HashMap<>(); + for (Long id : ids) out.put(id, new Perm(id)); + return out; + } + + public Perm find(Long id) { + return find(Collections.singletonList(id)).get(id); + } + + // Group 2: same arity, no generics (String vs Long) + public Perm resolve(String code) { + return new Perm(Long.valueOf(code)); + } + + public Perm resolve(Long id) { + return new Perm(id); + } + + // Group 3 (control): different arity + public int count(Req req) { + return req.keyword == null ? 0 : 1; + } + + public int count(Long id, Req req) { + return count(req) + (id == null ? 0 : 1); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/OrderService.java b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/OrderService.java new file mode 100644 index 000000000..16c138bff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/OrderService.java @@ -0,0 +1,35 @@ +package demo; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class OrderService { + + private final LookupService lookup; + + public OrderService(LookupService lookup) { + this.lookup = lookup; + } + + public Map listOrders() { + List ids = Arrays.asList(1L, 2L, 3L); + return lookup.find(ids); // -> find(Collection) + } + + public Perm getOrder(Long id) { + return lookup.find(id); // -> find(Long) + } + + public Perm byCode(String code) { + return lookup.resolve(code); // -> resolve(String) + } + + public Perm byId(Long id) { + return lookup.resolve(id); // -> resolve(Long) + } + + public int summarize(Long id, Req req) { + return lookup.count(req) + lookup.count(id, req); // -> count(Req), count(Long, Req) + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/Perm.java b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/Perm.java new file mode 100644 index 000000000..9eb52865a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/Perm.java @@ -0,0 +1,7 @@ +package demo; + +public class Perm { + private final Long id; + public Perm(Long id) { this.id = id; } + public Long getId() { return id; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/Req.java b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/Req.java new file mode 100644 index 000000000..6affa5b11 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-types/src/main/java/demo/Req.java @@ -0,0 +1,5 @@ +package demo; + +public class Req { + public String keyword; +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-protected-overloads/a/A.java b/gitnexus/test/fixtures/lang-resolution/java-protected-overloads/a/A.java new file mode 100644 index 000000000..60a86a1fc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-protected-overloads/a/A.java @@ -0,0 +1,8 @@ +package a; + +public class A { + protected String pick(String value) { return "string"; } + public String pick(Object value) { return "object"; } + protected static String staticPick(String value) { return "static-string"; } + public static String staticPick(Object value) { return "static-object"; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-protected-overloads/b/B.java b/gitnexus/test/fixtures/lang-resolution/java-protected-overloads/b/B.java new file mode 100644 index 000000000..ca873e6b1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-protected-overloads/b/B.java @@ -0,0 +1,32 @@ +package b; + +import a.A; + +public class B extends A { + class Inner { + String nested(B receiver) { return receiver.pick("x"); } + String baseReceiver(A receiver) { return receiver.pick("x"); } + String siblingReceiver(Sibling receiver) { return receiver.pick("x"); } + class Deep { + String deeplyNested(B receiver) { return receiver.pick("x"); } + } + } + class NestedSubclass extends A { + String enclosingSubclass(B receiver) { return receiver.pick("x"); } + } + static class Nested { + String staticNested(B receiver) { return receiver.pick("x"); } + String protectedStatic() { return A.staticPick("x"); } + } + public static void main(String[] args) { + B receiver = new B(); + Inner inner = receiver.new Inner(); + System.out.println(String.join(",", inner.nested(receiver), + inner.baseReceiver(receiver), inner.siblingReceiver(new Sibling()), + inner.new Deep().deeplyNested(receiver), + receiver.new NestedSubclass().enclosingSubclass(receiver), + new Nested().staticNested(receiver), new Nested().protectedStatic())); + } +} + +class Sibling extends A {} diff --git a/gitnexus/test/helpers/java-overload-persistence-child.mjs b/gitnexus/test/helpers/java-overload-persistence-child.mjs new file mode 100644 index 000000000..6732fb1ea --- /dev/null +++ b/gitnexus/test/helpers/java-overload-persistence-child.mjs @@ -0,0 +1,52 @@ +// Each invocation is a fresh process: no resolver maps or parsed-file objects +// survive between the writer, durable-cache replay, and backend reader. +const request = JSON.parse(process.argv[2]); +const root = new URL('../../dist/', import.meta.url); +const load = (name) => import(new URL(`${name}.js`, root).href); +let receipt; +if (request.action === 'analyze') { + const { runFullAnalysis } = await load('core/run-analyze'); + const { getStoragePaths, loadMeta } = await load('storage/repo-manager'); + const logs = []; + const result = await runFullAnalysis( + request.repo, + { + force: true, + skipAgentsMd: true, + skipSkills: true, + skipFts: true, + workerPoolSize: 1, + }, + { onLog: (message) => logs.push(message), onProgress: () => {} }, + ); + const paths = getStoragePaths(request.repo); + receipt = { + usedWorkerPool: result.pipelineResult?.usedWorkerPool, + reparsed: result.pipelineResult?.reparsedFileCount, + hits: result.pipelineResult?.parseCacheHitFileCount, + storagePath: paths.storagePath, + meta: await loadMeta(paths.storagePath), + logs, + }; +} else if (request.action === 'inspect') { + const { LocalBackend } = await load('mcp/local/local-backend'); + const backend = new LocalBackend(); + try { + await backend.init(); + receipt = {}; + for (const uid of request.targets) { + receipt[uid] = await backend.callTool('impact', { + target_uid: uid, + repo: request.repo, + direction: 'upstream', + maxDepth: 1, + includeTests: true, + }); + } + } finally { + await backend.dispose(); + } +} else { + throw new Error(`Unknown persistence action: ${request.action}`); +} +console.log('JAVA_OVERLOAD_RESULT=' + JSON.stringify(receipt)); diff --git a/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts b/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts index a4b8869d7..5702a5084 100644 --- a/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts +++ b/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts @@ -1,3 +1,4 @@ +import { summarizeUnresolvedOverloads } from '../../src/core/ingestion/scope-resolution/unresolved-overloads.js'; import { runtimeParseIdentity } from '../helpers/runtime-parse-identity.js'; import fs from 'fs'; import path from 'path'; @@ -204,6 +205,7 @@ export const javascriptQuery = query({ handler: async () => null }); lastCommit: 'convex-e2e', indexedAt: new Date(0).toISOString(), scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads(replay.resolutionOutcomes), } satisfies RepoMeta); }, poolAdapter: true, diff --git a/gitnexus/test/integration/impact-callable-value-references.test.ts b/gitnexus/test/integration/impact-callable-value-references.test.ts index 6124d76d3..3f89f03e9 100644 --- a/gitnexus/test/integration/impact-callable-value-references.test.ts +++ b/gitnexus/test/integration/impact-callable-value-references.test.ts @@ -27,6 +27,7 @@ * told an interface boundary exists where there is none, and would draw the * opposite conclusion about whether the gap is reducible. */ +import { summarizeUnresolvedOverloads } from '../../src/core/ingestion/scope-resolution/unresolved-overloads.js'; import { it, expect, beforeAll, vi } from 'vitest'; import path from 'node:path'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; @@ -212,7 +213,10 @@ withTestLbugDB( // completeness was not recorded"), which would make the controls below // pass for the wrong reason and prove nothing about this probe. // `saveMeta` is the only writer production uses. - await saveMeta(path.dirname(h.dbPath), { scopeExtractionReceipt: 1 } as any); + await saveMeta(path.dirname(h.dbPath), { + scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), + } as any); vi.mocked(listRegisteredRepos).mockResolvedValue([ { name: 'test-repo', diff --git a/gitnexus/test/integration/impact-context-integrity.test.ts b/gitnexus/test/integration/impact-context-integrity.test.ts index a074968de..f19e1f56a 100644 --- a/gitnexus/test/integration/impact-context-integrity.test.ts +++ b/gitnexus/test/integration/impact-context-integrity.test.ts @@ -1,3 +1,4 @@ +import { summarizeUnresolvedOverloads } from '../../src/core/ingestion/scope-resolution/unresolved-overloads.js'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -395,6 +396,7 @@ describe('native impact/context result integrity (#3354)', () => { lastCommit: 'integrity-fixture', indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1 as const, + unresolvedOverloads: summarizeUnresolvedOverloads([]), stats: { files: 8, nodes: 11, processes: 3, communities: 0 }, }; await saveMeta(paths.storagePath, meta); @@ -730,6 +732,7 @@ describe.skipIf(process.platform === 'win32')('warm backend index replacement (# lastCommit: 'graph-a', indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1 as const, + unresolvedOverloads: summarizeUnresolvedOverloads([]), stats: { files: 3, nodes: 4, processes: 1, communities: 0 }, }; await saveMeta(paths.storagePath, meta); diff --git a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts index 4b750fc43..e5e147956 100644 --- a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts +++ b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts @@ -28,6 +28,8 @@ import { summarizeUnresolvedReceivers, } from '../../src/core/ingestion/scope-resolution/unresolved-receivers.js'; import type { ResolutionOutcome } from '../../src/core/ingestion/scope-resolution/resolution-outcome.js'; +import { summarizeUnresolvedOverloads } from '../../src/core/ingestion/scope-resolution/unresolved-overloads.js'; +import * as poolAdapter from '../../src/core/lbug/pool-adapter.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; vi.mock('../../src/storage/repo-manager.js', () => ({ @@ -38,6 +40,23 @@ vi.mock('../../src/storage/repo-manager.js', () => ({ })); const SEED = [ + ...['Overloads', 'Unrelated'].map( + (name) => + `CREATE (:Class {id:'Class:src/Overloads.java:${name}', name:'${name}', filePath:'src/Overloads.java', startLine:1, endLine:20, content:'', description:''})`, + ), + ...['String', 'Long'].map( + (type) => + `CREATE (:Method {id:'Method:src/Overloads.java:Overloads.choose#1~${type}', name:'choose', filePath:'src/Overloads.java', startLine:2, endLine:5, content:'', description:''})`, + ), + ...['leaf', 'invoke', 'entry'].map( + (name) => + `CREATE (:Function {id:'Function:src/overload-${name}.ts:${name}', name:'overload${name}', filePath:'src/overload-${name}.ts', startLine:1, endLine:3, content:'', description:''})`, + ), + ...['String', 'Long'].flatMap((type) => [ + `MATCH (owner:Class {id:'Class:src/Overloads.java:Overloads'}), (m:Method {id:'Method:src/Overloads.java:Overloads.choose#1~${type}'}) CREATE (owner)-[:CodeRelation {type:'HAS_METHOD', confidence:1, reason:'member', step:0}]->(m)`, + `MATCH (m:Method {id:'Method:src/Overloads.java:Overloads.choose#1~${type}'}), (leaf:Function {id:'Function:src/overload-leaf.ts:leaf'}) CREATE (m)-[:CodeRelation {type:'CALLS', confidence:1, reason:'direct', step:0}]->(leaf)`, + ]), + `MATCH (entry:Function {id:'Function:src/overload-entry.ts:entry'}), (caller:Function {id:'Function:src/overload-invoke.ts:invoke'}) CREATE (entry)-[:CodeRelation {type:'CALLS', confidence:1, reason:'direct', step:0}]->(caller)`, // Interface + two implementations + an interface-level consumer. `CREATE (iface:Interface {id: 'Interface:src/Logger.ts:Logger', name: 'Logger', filePath: 'src/Logger.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`, `CREATE (email:Class {id: 'Class:src/EmailLogger.ts:EmailLogger', name: 'EmailLogger', filePath: 'src/EmailLogger.ts', startLine: 1, endLine: 20, isExported: true, content: '', description: ''})`, @@ -85,9 +104,175 @@ withTestLbugDB( // Keep each fixture snapshot stable for every read in that test. vi.mocked(loadMeta).mockResolvedValue({ scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), } as Awaited>); }); + describe('overload completeness', () => { + const candidates = [ + 'Method:src/Overloads.java:Overloads.choose#1~String', + 'Method:src/Overloads.java:Overloads.choose#1~Long', + ]; + const owner = 'Class:src/Overloads.java:Overloads'; + const caller = 'Function:src/overload-invoke.ts:invoke'; + const outcome: ResolutionOutcome = { + kind: 'suppressed', + phase: 'receiver-bound-calls', + filePath: 'src/overload-invoke.ts', + name: 'choose', + range: { startLine: 2, startCol: 0, endLine: 2, endCol: 10 }, + reason: 'overload-unknown', + siteKind: 'call', + candidateIds: ['def:choose.String', 'def:choose.Long'], + candidateGraphIds: candidates, + candidateOwnerIds: [owner], + callerId: caller, + candidateSetComplete: true, + }; + const metadata = (outcomes: readonly ResolutionOutcome[] = [outcome]) => ({ + repoPath: '', + lastCommit: '', + indexedAt: '', + scopeExtractionReceipt: 1 as const, + unresolvedOverloads: summarizeUnresolvedOverloads(outcomes), + }); + const impact = (uid: string, params: Record = {}) => + backend.callTool('impact', { target_uid: uid, direction: 'upstream', ...params }); + + it.each(['impact', 'context'] as const)( + 'hedges zero known callers for %s using graph candidate identity', + async (tool) => { + vi.mocked(loadMeta).mockResolvedValue(metadata()); + const result = await backend.callTool( + tool, + tool === 'impact' + ? { target_uid: candidates[0], direction: 'upstream' } + : { uid: candidates[0] }, + ); + expect(result).not.toHaveProperty('error'); + expect(result).toMatchObject({ + epistemic: 'lower-bound', + causes: { overloadResolution: 1 }, + }); + expect( + tool === 'impact' ? result.impactedCount : (result.incoming.calls?.length ?? 0), + ).toBe(0); + }, + ); + + it('finds omissions at expanded intermediate nodes, without double-counting candidates or terminal depths', async () => { + vi.mocked(loadMeta).mockResolvedValue(metadata()); + const leaf = 'Function:src/overload-leaf.ts:leaf'; + const shallow = await impact(leaf, { maxDepth: 1 }); + expect(shallow).toMatchObject({ epistemic: 'exact', impactedCount: 2 }); + const deep = await impact(leaf, { maxDepth: 2 }); + expect(deep).toMatchObject({ + epistemic: 'lower-bound', + impactedCount: 2, + causes: { overloadResolution: 1 }, + }); + const paged = await impact(leaf, { maxDepth: 2, limit: 1, offset: 1 }); + const summary = await impact(leaf, { maxDepth: 2, summaryOnly: true }); + expect(paged.causes.overloadResolution).toBe(1); + expect(summary.causes.overloadResolution).toBe(1); + expect(summary).not.toHaveProperty('byDepth'); + }); + + it('matches class declaring owners without contaminating unrelated classes in the same file', async () => { + vi.mocked(loadMeta).mockResolvedValue(metadata()); + expect(await impact(owner)).toMatchObject({ + epistemic: 'lower-bound', + causes: { overloadResolution: 1 }, + }); + expect(await impact('Class:src/Overloads.java:Unrelated')).toMatchObject({ + epistemic: 'exact', + }); + }); + + it('uses caller identities downstream, including reached callers within the depth bound', async () => { + vi.mocked(loadMeta).mockResolvedValue(metadata()); + expect(await impact(caller, { direction: 'downstream' })).toMatchObject({ + epistemic: 'lower-bound', + causes: { overloadResolution: 1 }, + }); + const entry = 'Function:src/overload-entry.ts:entry'; + expect(await impact(entry, { direction: 'downstream', maxDepth: 1 })).toMatchObject({ + epistemic: 'exact', + }); + expect(await impact(entry, { direction: 'downstream', maxDepth: 2 })).toMatchObject({ + epistemic: 'lower-bound', + causes: { overloadResolution: 1 }, + }); + expect( + await impact('Function:src/overload-leaf.ts:leaf', { direction: 'downstream' }), + ).toMatchObject({ epistemic: 'exact' }); + }); + + it('does not add call omissions to a traversal excluding CALLS', async () => { + vi.mocked(loadMeta).mockResolvedValue(metadata()); + expect(await impact(candidates[0], { relationTypes: ['IMPORTS'] })).toMatchObject({ + epistemic: 'exact', + }); + }); + + it('honors upstream test-caller filtering', async () => { + vi.mocked(loadMeta).mockResolvedValue( + metadata([{ ...outcome, filePath: 'test/Caller.java' }]), + ); + expect(await impact(candidates[0])).toMatchObject({ epistemic: 'exact' }); + expect(await impact(candidates[0], { includeTests: true })).toMatchObject({ + epistemic: 'lower-bound', + causes: { overloadResolution: 1 }, + }); + }); + + it.each([ + undefined, + { schemaVersion: 99 }, + { + schemaVersion: 1, + complete: false, + totalSites: 1, + omittedSites: 1, + sites: [], + }, + ])( + 'keeps missing, malformed and capped coverage distinct from measured zero: %j', + async (summary) => { + vi.mocked(loadMeta).mockResolvedValue({ + ...metadata([]), + unresolvedOverloads: summary, + } as Awaited>); + const result = await impact(candidates[0]); + expect(result).toMatchObject({ + epistemic: 'lower-bound', + causes: { overloadResolution: 0 }, + }); + expect(result.boundaries.join(' ')).toMatch(/not recorded|partial/); + }, + ); + + it('retains overload evidence when the heritage probe fails', async () => { + vi.mocked(loadMeta).mockResolvedValue(metadata()); + const execute = poolAdapter.executeParameterized; + const spy = vi + .spyOn(poolAdapter, 'executeParameterized') + .mockImplementation((repo, query, params) => + query.includes('RETURN DISTINCT iface.id AS id') + ? Promise.reject(new Error('heritage probe unavailable')) + : execute(repo, query, params), + ); + try { + expect(await backend.callTool('context', { uid: candidates[0] })).toMatchObject({ + epistemic: 'lower-bound', + causes: { overloadResolution: 1 }, + }); + } finally { + spy.mockRestore(); + } + }); + }); + it('flags impact() on a concrete impl behind an interface as lower-bound', async () => { const result = await backend.callTool('impact', { target: 'EmailLogger', @@ -155,6 +340,7 @@ withTestLbugDB( lastCommit: '', indexedAt: '', scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), unresolvedReceiverMembers: { counts: { anotherMember: 3 }, totalSites: 10, @@ -185,6 +371,7 @@ withTestLbugDB( lastCommit: '', indexedAt: '', scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), unresolvedReceiverMembers: { counts: { formatDate: 3 }, totalSites: 10, @@ -221,6 +408,7 @@ withTestLbugDB( lastCommit: '', indexedAt: '', scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), unresolvedReceiverMembers: summary, } as Awaited>); @@ -263,6 +451,7 @@ withTestLbugDB( ); vi.mocked(loadMeta).mockResolvedValue({ scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads(outcomes), unresolvedReceiverMembers: summary, } as Awaited>); @@ -281,6 +470,7 @@ withTestLbugDB( async (omittedNames) => { vi.mocked(loadMeta).mockResolvedValue({ scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), unresolvedReceiverMembers: { counts: {}, totalSites: 0, omittedNames }, } as Awaited>); @@ -297,6 +487,7 @@ withTestLbugDB( async (name) => { vi.mocked(loadMeta).mockResolvedValue({ scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), unresolvedReceiverMembers: { counts: {}, totalSites: 3, omittedNames: 2 }, } as Awaited>); @@ -314,6 +505,7 @@ withTestLbugDB( async (name) => { vi.mocked(loadMeta).mockResolvedValue({ scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), unresolvedReceiverMembers: JSON.parse( JSON.stringify({ counts: { [name]: 3 }, totalSites: 5, omittedNames: 2 }), ), @@ -366,6 +558,7 @@ withTestLbugDB( ); vi.mocked(loadMeta).mockResolvedValue({ scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads(analysed.resolutionOutcomes), unresolvedReceiverMembers: summary, } as Awaited>); @@ -390,6 +583,7 @@ withTestLbugDB( lastCommit: 'abc123', indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), scopeExtractionFailures: { total: 2, paths: ['src/broken-a.ts', 'src/broken-b.ts'], @@ -412,6 +606,7 @@ withTestLbugDB( lastCommit: 'abc123', indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), scopeExtractionFailures: { total: 1, paths: ['src/`break`\n\u001b[31m\u202e\u200binject.ts'], @@ -511,6 +706,7 @@ withTestLbugDB( lastCommit: 'abc123', indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), scopeExtractionFailures: { total: 2, paths: ['src/broken-a.ts', 'src/broken-b.ts'], diff --git a/gitnexus/test/integration/impact-scope-omission-persistence.test.ts b/gitnexus/test/integration/impact-scope-omission-persistence.test.ts index 83661475b..829103e4e 100644 --- a/gitnexus/test/integration/impact-scope-omission-persistence.test.ts +++ b/gitnexus/test/integration/impact-scope-omission-persistence.test.ts @@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from 'vitest'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; import { listRegisteredRepos, saveMeta } from '../../src/storage/repo-manager.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; +import { summarizeUnresolvedOverloads } from '../../src/core/ingestion/scope-resolution/unresolved-overloads.js'; vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { const actual = await importOriginal(); @@ -34,6 +35,7 @@ withTestLbugDB( lastCommit: 'abc123', indexedAt: new Date(0).toISOString(), scopeExtractionReceipt: 1 as const, + unresolvedOverloads: summarizeUnresolvedOverloads([]), }; await saveMeta(handle.tmpHandle.dbPath, { ...baseMeta, diff --git a/gitnexus/test/integration/impact-undecided-satisfaction.test.ts b/gitnexus/test/integration/impact-undecided-satisfaction.test.ts index f40a407c1..e2c326462 100644 --- a/gitnexus/test/integration/impact-undecided-satisfaction.test.ts +++ b/gitnexus/test/integration/impact-undecided-satisfaction.test.ts @@ -15,6 +15,7 @@ * the query is the analyzer's own record of what it could not decide, read from * the index metadata. */ +import { summarizeUnresolvedOverloads } from '../../src/core/ingestion/scope-resolution/unresolved-overloads.js'; import { it, expect, beforeAll, vi } from 'vitest'; import path from 'node:path'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; @@ -109,6 +110,7 @@ withTestLbugDB( // file directly would pin a shape no real analyze can produce. await saveMeta(path.dirname(h.dbPath), { scopeExtractionReceipt: 1, + unresolvedOverloads: summarizeUnresolvedOverloads([]), undecidedInterfaceSatisfaction: { counts: { CtxStore: 2 }, totalInterfaces: 1, diff --git a/gitnexus/test/integration/java-overload-impact-persistence.test.ts b/gitnexus/test/integration/java-overload-impact-persistence.test.ts new file mode 100644 index 000000000..288e7482c --- /dev/null +++ b/gitnexus/test/integration/java-overload-impact-persistence.test.ts @@ -0,0 +1,181 @@ +import { execFile } from 'node:child_process'; +import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { expect, it } from 'vitest'; +import type { RepoMeta } from '../../src/storage/repo-meta.js'; + +const exec = promisify(execFile); +const helper = fileURLToPath( + new URL('../helpers/java-overload-persistence-child.mjs', import.meta.url), +); +const fixture = fileURLToPath( + new URL('../fixtures/lang-resolution/java-overload-types/', import.meta.url), +); +const prefix = 'Method:src/main/java/demo/LookupService.java:LookupService.'; +const callers = { + 'find#1~Collection': ['find', 'listOrders', 'settle'], + 'find#1~Long': ['getOrder'], + 'resolve#1~String': ['byCode'], + 'resolve#1~Long': ['byId'], + 'count#1': ['count', 'summarize'], + 'count#2': ['summarize'], +} as const; +const targets = Object.keys(callers).map((suffix) => prefix + suffix); + +interface AnalysisReceipt { + usedWorkerPool: boolean; + reparsed: number; + hits: number; + storagePath: string; + meta: RepoMeta; +} +interface ImpactReceipt { + error?: string; + partial?: boolean; + epistemic: string; + causes?: { overloadResolution?: number }; + summary: { direct: number }; + byDepth: Record>; +} + +it('preserves original overload callers and omission receipts across cold, durable warm, and edited analyses', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-java-overload-')); + const repo = path.join(directory, 'repo'); + const env: NodeJS.ProcessEnv = { + ...process.env, + GITNEXUS_HOME: path.join(directory, 'home'), + GITNEXUS_SHARED_STORE: 'off', + GITNEXUS_MEMORY: 'off', + GITNEXUS_LANG: 'en', + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', + // Repeated impact traversals need more than the writer's 128 MiB minimum. + GITNEXUS_LBUG_BUFFER_POOL_SIZE: '536870912', + }; + delete env.GITNEXUS_STORAGE_PATH; + delete env.GITNEXUS_STORAGE_ROOT; + async function child(action: 'analyze' | 'inspect'): Promise { + const { stdout } = await exec( + process.execPath, + [helper, JSON.stringify({ action, repo, targets })], + { + cwd: repo, + env, + timeout: 180_000, + maxBuffer: 4 * 1024 * 1024, + }, + ); + const line = stdout.split('\n').find((value) => value.startsWith('JAVA_OVERLOAD_RESULT=')); + if (!line) throw new Error(`Missing child receipt: ${stdout}`); + return JSON.parse(line.slice('JAVA_OVERLOAD_RESULT='.length)) as T; + } + function assertCallers(results: Record): void { + for (const [suffix, expected] of Object.entries(callers)) { + const result = results[prefix + suffix]; + expect(result, suffix).not.toHaveProperty('error'); + expect(result.partial, suffix).not.toBe(true); + expect(result.summary.direct, suffix).toBe(expected.length); + expect((result.byDepth['1'] ?? []).map((caller) => caller.name).sort(), suffix).toEqual( + [...expected].sort(), + ); + } + } + try { + await cp(fixture, repo, { recursive: true }); + await exec('git', ['init', '-q', repo]); + const cold = await child('analyze'); + expect(cold.usedWorkerPool).toBe(true); + expect(cold.reparsed).toBe(5); + expect(cold.hits).toBe(0); + const coldResults = await child>('inspect'); + assertCallers(coldResults); + for (const result of Object.values(coldResults)) { + expect(result.causes?.overloadResolution ?? 0).toBe(0); + expect(result.epistemic).toBe('exact'); + } + + // A fresh analyzer process consumes only durable ParsedFile sidechannels. + // Force bypasses the up-to-date shortcut without discarding parser caches. + const warm = await child('analyze'); + expect(warm.usedWorkerPool).toBe(false); + expect(warm.reparsed).toBe(0); + expect(warm.hits).toBe(5); + const warmResults = await child>('inspect'); + assertCallers(warmResults); + for (const uid of targets) { + expect(warmResults[uid].epistemic).toBe(coldResults[uid].epistemic); + expect(warmResults[uid].causes).toEqual(coldResults[uid].causes); + } + + // External.value() has no available return type. Neither overload can be + // chosen; known callers stay intact and each queried candidate is hedged. + const unknownPath = path.join(repo, 'src/main/java/demo/UnknownCalls.java'); + const unknownSource = `package demo; +class UnknownCalls { + LookupService lookup; + Perm unknown() { return lookup.resolve(External.value()); } +} +`; + await writeFile(unknownPath, unknownSource); + const unresolved = await child('analyze'); + expect(unresolved.meta.unresolvedOverloads?.totalSites).toBeGreaterThan(0); + const uncertainResults = await child>('inspect'); + assertCallers(uncertainResults); + for (const suffix of ['resolve#1~String', 'resolve#1~Long']) { + expect(uncertainResults[prefix + suffix]).toMatchObject({ + epistemic: 'lower-bound', + causes: { overloadResolution: 1 }, + }); + } + + // Correcting a retained file must replace its old omission receipt and + // persist the newly proven edge, even while all other parse entries hit. + await writeFile( + unknownPath, + `package demo; +class UnknownCalls { + LookupService lookup; + Perm unknown() { return lookup.resolve("known"); } +} +`, + ); + const correction = await child('analyze'); + expect(correction.reparsed).toBe(1); + expect(correction.hits).toBe(5); + const corrected = await child>('inspect'); + for (const uid of targets) expect(corrected[uid].partial, uid).not.toBe(true); + expect(corrected[prefix + 'resolve#1~String'].summary.direct).toBe(2); + expect( + corrected[prefix + 'resolve#1~String'].byDepth['1'].map((caller) => caller.name).sort(), + ).toEqual(['byCode', 'unknown']); + expect(corrected[prefix + 'resolve#1~Long'].summary.direct).toBe(1); + for (const uid of targets) { + expect(corrected[uid].epistemic).toBe(coldResults[uid].epistemic); + expect(corrected[uid].causes).toEqual(coldResults[uid].causes); + } + + // Delete a file with a persisted unresolved call, so stale omission + // metadata cannot hide behind the earlier correction's empty receipt. + await writeFile(unknownPath, unknownSource); + const unresolvedAgain = await child('analyze'); + expect(unresolvedAgain.meta.unresolvedOverloads?.totalSites).toBeGreaterThan(0); + await rm(unknownPath); + const deletion = await child('analyze'); + expect(deletion.meta.unresolvedOverloads).toMatchObject({ + complete: true, + totalSites: 0, + omittedSites: 0, + sites: [], + }); + const restored = await child>('inspect'); + assertCallers(restored); + for (const uid of targets) { + expect(restored[uid].epistemic).toBe(coldResults[uid].epistemic); + expect(restored[uid].causes).toEqual(coldResults[uid].causes); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}, 600_000); diff --git a/gitnexus/test/integration/resolvers/java-javac-overloads.test.ts b/gitnexus/test/integration/resolvers/java-javac-overloads.test.ts new file mode 100644 index 000000000..d03c0c2fb --- /dev/null +++ b/gitnexus/test/integration/resolvers/java-javac-overloads.test.ts @@ -0,0 +1,268 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { FIXTURES } from './helpers.js'; + +const compilerLocale = ['-J-Duser.language=en', '-J-Duser.country=US']; +const javacAvailable = + spawnSync('javac', [...compilerLocale, '-version'], { encoding: 'utf8' }).status === 0; + +describe('javac oracle for Java overload resolution', () => { + it.runIf(javacAvailable)( + 'distinguishes a statically imported source type from the boxed long type', + () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-static-type-')); + try { + const sources = { + 'names/Names.java': 'package names; public class Names { public static class Long {} }', + 'client/Oracle.java': `package client; import static names.Names.Long; +public class Oracle { + static String select(Long value) { return "source"; } + static String select(Number value) { return "number"; } + public static void main(String[] args) { + System.out.println(select(1L) + "," + select(new Long())); + } +}`, + }; + for (const [name, source] of Object.entries(sources)) { + const file = path.join(root, name); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, source); + } + execFileSync('javac', [ + ...compilerLocale, + '-d', + root, + ...Object.keys(sources).map((name) => path.join(root, name)), + ]); + expect( + execFileSync('java', ['-cp', root, 'client.Oracle'], { encoding: 'utf8' }).trim(), + ).toBe('number,source'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(javacAvailable)( + 'selects the accessible Object overload for a declared method return and known arguments', + () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-accessible-')); + try { + const source = path.join(root, 'Oracle.java'); + writeFileSync( + source, + `class AccessFilter { + private String accessFiltered(String value) { return "private"; } + public String accessFiltered(Object value) { return "object"; } +} +public class Oracle { + static Object external() { return null; } + public static void main(String[] args) { + AccessFilter value = new AccessFilter(); + Object argument = external(); + System.out.println(String.join(",", value.accessFiltered(external()), + value.accessFiltered(argument), value.accessFiltered("x"))); + } +}`, + ); + execFileSync('javac', [...compilerLocale, '-d', root, source]); + expect(execFileSync('java', ['-cp', root, 'Oracle'], { encoding: 'utf8' }).trim()).toBe( + 'object,object,object', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(javacAvailable)( + 'confirms enclosing-subclass access and protected receiver restrictions', + () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-protected-')); + try { + const sources = path.join(FIXTURES, 'java-protected-overloads'); + execFileSync('javac', [ + ...compilerLocale, + '-d', + root, + path.join(sources, 'a/A.java'), + path.join(sources, 'b/B.java'), + ]); + expect(execFileSync('java', ['-cp', root, 'b.B'], { encoding: 'utf8' }).trim()).toBe( + 'string,object,object,string,string,string,static-string', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(javacAvailable)( + 'compiles the exact issue fixture and confirms its six target descriptors', + () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-overloads-')); + try { + const sources = path.join(FIXTURES, 'java-overload-types/src/main/java/demo'); + execFileSync('javac', [ + ...compilerLocale, + '-d', + root, + ...readdirSync(sources) + .filter((name) => name.endsWith('.java')) + .map((name) => path.join(sources, name)), + ]); + const bytecode = execFileSync( + 'javap', + [ + '-classpath', + root, + '-c', + '-p', + 'demo.LookupService', + 'demo.OrderService', + 'demo.BillingService', + ], + { encoding: 'utf8' }, + ); + for (const [descriptor, count] of [ + ['find:(Ljava/util/Collection;)Ljava/util/Map;', 3], + ['find:(Ljava/lang/Long;)Ldemo/Perm;', 1], + ['resolve:(Ljava/lang/String;)Ldemo/Perm;', 1], + ['resolve:(Ljava/lang/Long;)Ldemo/Perm;', 1], + ['count:(Ldemo/Req;)I', 2], + ['count:(Ljava/lang/Long;Ldemo/Req;)I', 1], + ] as const) { + expect( + bytecode + .split('\n') + .filter((line) => line.includes('invoke') && line.includes(descriptor)), + descriptor, + ).toHaveLength(count); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(javacAvailable)( + 'confirms phase ordering, arrays, invariance, target typing and zero-argument specificity', + () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-phases-')); + try { + const file = path.join(root, 'Oracle.java'); + writeFileSync( + file, + `import java.util.*; +class Oracle { + static String phase(Integer x) { return "box"; } static String phase(long x) { return "wide"; } + static String boxed(long x) { return "unbox"; } static String boxed(Number x) { return "reference"; } + static String variable(String... x) { return "varargs"; } static String variable(Object x) { return "fixed"; } + static String empty(Object... x) { return "object"; } static String empty(String... x) { return "string"; } + static String generic(Collection x) { return "collection"; } static String generic(Object x) { return "object"; } + static String primitive(List x) { return "list"; } static String primitive(Object x) { return "object"; } + static String array(Object[] x) { return "array"; } static String array(Object x) { return "object"; } + public static void main(String[] args) { + List ids = Arrays.asList(1L); + System.out.println(String.join(",", phase(1), boxed(Integer.valueOf(1)), variable("x"), empty(), + generic(ids), generic(Arrays.asList(1L)), generic(Collections.singletonList(1L)), + primitive(Arrays.asList(new long[]{1L})), array(new int[1][1]))); + } +}`, + ); + execFileSync('javac', [...compilerLocale, '-d', root, file]); + expect(execFileSync('java', ['-cp', root, 'Oracle'], { encoding: 'utf8' }).trim()).toBe( + 'wide,reference,fixed,string,object,collection,collection,object,array', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(javacAvailable)( + 'confirms inherited field precedence and enhanced-for variable scope', + () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-lexical-')); + try { + const source = path.join(root, 'Lexical.java'); + writeFileSync( + source, + `class Lexical { + static class Base { String value = "base"; } + static class Outer { Long value = 1L; class Inner extends Base { + String pick(String value) { return "string"; } String pick(Long value) { return "long"; } + String byThis() { return pick(this.value); } String byImplicit() { return pick(value); } + } } + static class Loop { Long item = 1L; + String[] source(String value) { return new String[]{"wrong"}; } + String[] source(Long value) { return new String[]{"right"}; } + String body(String value) { return "string"; } String body(Long value) { return "long"; } + String run() { String result = ""; for (String item : source(item)) result += item + ":" + body(item); return result; } + } + public static void main(String[] args) { + Outer.Inner value = new Outer().new Inner(); + System.out.println(value.byThis() + "," + value.byImplicit() + "," + new Loop().run()); + } +}`, + ); + execFileSync('javac', [...compilerLocale, '-d', root, source]); + expect(execFileSync('java', ['-cp', root, 'Lexical'], { encoding: 'utf8' }).trim()).toBe( + 'string,string,right:string', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(javacAvailable).each([ + [ + 'null tie', + 'void choose(String x) {} void choose(Integer x) {} void run() { choose(null); }', + 'ambiguous', + ], + [ + 'crossed parameters', + 'void choose(String x,Object y) {} void choose(Object x,String y) {} void run() { choose("a","b"); }', + 'ambiguous', + ], + [ + 'poly target tie', + 'void choose(java.util.List x) {} void choose(java.util.Collection x) {} void run() { choose(java.util.Arrays.asList(1)); }', + 'ambiguous', + ], + ['widen then box', 'void choose(Long x) {} void run() { choose(1); }', 'incompatible'], + [ + 'static invocation after selection', + 'static void choose(Object x) {} void choose(String x) {} static void run() { choose("x"); }', + 'non-static method choose(String)', + ], + [ + 'type-qualified invocation after selection', + 'static void choose(Object x) {} void choose(String x) {} static void run() { Invalid.choose("x"); }', + 'non-static method choose(String)', + ], + ])('rejects %s', (_name, body, diagnostic) => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-invalid-')); + try { + const source = path.join(root, 'Invalid.java'); + mkdirSync(path.join(root, 'classes')); + writeFileSync(source, `class Invalid { ${body} }`); + const compilation = spawnSync( + 'javac', + [...compilerLocale, '-d', path.join(root, 'classes'), source], + { + encoding: 'utf8', + }, + ); + expect(compilation.status).not.toBe(0); + expect(compilation.stderr).toContain(diagnostic); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/java-lombok.test.ts b/gitnexus/test/integration/resolvers/java-lombok.test.ts index 34a3a27f1..fcf5bcc0f 100644 --- a/gitnexus/test/integration/resolvers/java-lombok.test.ts +++ b/gitnexus/test/integration/resolvers/java-lombok.test.ts @@ -12,6 +12,7 @@ import { runPipelineFromRepo, getRelationships, getNodesByLabelFull, + getResolutionOutcomes, } from './helpers.js'; import { loadParseCache, @@ -44,6 +45,39 @@ function projectCallsToGetName(result: Awaited { + it('keeps an inherited synthetic setter in the overload family when its formal types are unavailable', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-lombok-overload-')); + try { + writeFixtureRepo(root, { + 'Inheritance.java': `import lombok.Data; +@Data class GeneratedBase { private String value; } +class GeneratedChild extends GeneratedBase { public void setValue(Object value) {} } +class Consumer { void choose(GeneratedChild target) { target.setValue("x"); } } +`, + }); + const linked = await runPipelineFromRepo(root, () => {}); + expect( + getRelationships(linked, 'CALLS').filter( + (edge) => edge.source === 'choose' && edge.target === 'setValue', + ), + ).toEqual([]); + expect(getResolutionOutcomes(linked)).toContainEqual( + expect.objectContaining({ + kind: 'suppressed', + reason: 'overload-unknown', + name: 'setValue', + candidateGraphIds: expect.arrayContaining([ + 'Method:Inheritance.java:GeneratedBase.setValue#1', + 'Method:Inheritance.java:GeneratedChild.setValue#1', + ]), + candidateSetComplete: true, + }), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + it.each(['same-file', 'cross-file'] as const)( 'resolves all getter and setter calls to their owner in a %s fixture', async (layout) => { diff --git a/gitnexus/test/integration/resolvers/java-overload-jls.test.ts b/gitnexus/test/integration/resolvers/java-overload-jls.test.ts new file mode 100644 index 000000000..1442028f5 --- /dev/null +++ b/gitnexus/test/integration/resolvers/java-overload-jls.test.ts @@ -0,0 +1,542 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../../src/types/pipeline.js'; +import { FIXTURES, getRelationships, getResolutionOutcomes, writeFixtureRepo } from './helpers.js'; + +function targets(result: PipelineResult, caller: string, method: string): string[] { + return getRelationships(result, 'CALLS') + .filter((edge) => edge.source === caller && edge.target === method) + .map((edge) => edge.rel.targetId) + .sort(); +} + +it('records unresolved receivers once across replay without merging distinct sites', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-java-receiver-replay-')); + try { + writeFixtureRepo(root, { + 'Replay.java': `class Replay { + void unresolved() { + System.out.println("first"); + System.out.println("second"); + External.value.value().value(); + } + }`, + }); + const result = await runPipelineFromRepo(root, () => {}); + const drops = getResolutionOutcomes(result) + .filter((outcome) => outcome.kind === 'suppressed') + .filter((outcome) => outcome.reason === 'receiver-unresolved'); + expect(drops.filter((outcome) => outcome.name === 'println')).toHaveLength(2); + expect( + drops.filter((outcome) => outcome.name === 'value' && outcome.siteKind === 'call'), + ).toHaveLength(2); + const identities = drops.map((outcome) => + JSON.stringify([outcome.filePath, outcome.range, outcome.siteKind, outcome.name]), + ); + expect(new Set(identities).size).toBe(drops.length); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('Java protected overloads in enclosing subclasses', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-protected-overloads'), () => {}); + }, 120_000); + + it.each(['nested', 'deeplyNested', 'enclosingSubclass', 'staticNested'])( + 'permits protected access from %s through the enclosing subclass', + (caller) => { + expect(targets(result, caller, 'pick')).toEqual(['Method:a/A.java:A.pick#1~String']); + }, + ); + + it.each(['baseReceiver', 'siblingReceiver'])( + 'keeps the protected receiver restriction for %s', + (caller) => { + expect(targets(result, caller, 'pick')).toEqual(['Method:a/A.java:A.pick#1~Object']); + }, + ); + + it('allows a protected static method through the declaring type', () => { + expect(targets(result, 'protectedStatic', 'staticPick')).toEqual([ + 'Method:a/A.java:A.staticPick#1~String', + ]); + }); +}); + +describe('Java imported overloads and declared dispatch bounds', () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-java-imported-overloads-')); + let result: PipelineResult; + beforeAll(async () => { + writeFixtureRepo(root, { + 'api/Explicit.java': `package api; public class Explicit { + public static void pick(Object x) {} public static void shadow(Object x) {} + }`, + 'api/Demand.java': `package api; public class Demand { + public static void pick(String x) {} public static void shadow(Object x) {} + }`, + 'client/Imports.java': `package client; import static api.Explicit.pick; + import static api.Explicit.shadow; import static api.Demand.*; + class Imports { void importedPick() { pick("x"); } + void importedShadow() { shadow("x"); } }`, + 'api/Hidden.java': `package api; class Hidden { public static void hidden() {} }`, + 'client/HiddenUse.java': `package client; import static api.Hidden.hidden; + class HiddenUse { void inaccessibleOwner() { hidden(); } }`, + 'api/PrivateHost.java': `package api; import static api.PrivateHost.Holder.secret; + public class PrivateHost { public static class Holder { private static void secret() {} } + void privateImport() { secret(); } }`, + 'Dispatch.java': `interface Parent { void task(); } interface Child extends Parent {} + interface StaticAPI { + static void staticRun() {} private void privateRun() {} + default void privateBridge() { this.privateRun(); } + } + class RuntimeDecoy implements StaticAPI { public void staticRun() {} public void privateRun() {} } + class ChildImpl implements Child { public void task() {} } + class OtherImpl implements Parent { public void task() {} } + class Dispatch { + void staticDispatch() { StaticAPI.staticRun(); } + void childDispatch(Child child) { child.task(); } + void anonymousDispatch() { Child child = new Child() { public void task() {} }; child.task(); } + void runnableGood() { Runnable r = new Runnable() { public void run() {} public void run(String x) {} }; r.run(); } + void runnableBad() { Runnable r = new Runnable() { public void run() {} public void run(String x) {} }; r.run("x"); } + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 120_000); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + it('keeps different-signature demand imports in the overload family', () => { + expect(targets(result, 'importedPick', 'pick')).toEqual([ + 'Method:api/Demand.java:Demand.pick#1', + ]); + }); + it('lets a single static import shadow only an identical demand-import signature', () => { + expect(targets(result, 'importedShadow', 'shadow')).toEqual([ + 'Method:api/Explicit.java:Explicit.shadow#1', + ]); + }); + it('does not import a public method through an inaccessible owner type', () => { + expect(targets(result, 'inaccessibleOwner', 'hidden')).toEqual([]); + }); + it('does not grant nestmate access at a static-import declaration', () => { + expect(targets(result, 'privateImport', 'secret')).toEqual([]); + }); + it('retains the original child-interface bound during runtime dispatch', () => { + const ids = targets(result, 'childDispatch', 'task'); + expect(ids).toContain('Method:Dispatch.java:ChildImpl.task#0'); + expect(ids).not.toContain('Method:Dispatch.java:OtherImpl.task#0'); + }); + it('keeps a known anonymous receiver on its own implementation branch', () => { + const ids = targets(result, 'anonymousDispatch', 'task'); + expect(ids).toContain('Method:Dispatch.java:Dispatch$1.task#0'); + expect(ids).not.toContain('Method:Dispatch.java:ChildImpl.task#0'); + expect(ids).not.toContain('Method:Dispatch.java:OtherImpl.task#0'); + }); + it('restricts a Runnable receiver to the platform run() signature', () => { + expect(targets(result, 'runnableGood', 'run')).toEqual([ + 'Method:Dispatch.java:Dispatch$2.run#0', + ]); + expect(targets(result, 'runnableBad', 'run')).toEqual([]); + }); + it('does not dispatch static or private interface methods to same-named instance methods', () => { + expect(targets(result, 'staticDispatch', 'staticRun')).toEqual([ + 'Method:Dispatch.java:StaticAPI.staticRun#0', + ]); + expect(targets(result, 'privateBridge', 'privateRun')).toEqual([ + 'Method:Dispatch.java:StaticAPI.privateRun#0', + ]); + }); +}); + +describe('Java JLS overload resolution — issue #3789', () => { + let original: PipelineResult; + beforeAll(async () => { + original = await runPipelineFromRepo(path.join(FIXTURES, 'java-overload-types'), () => {}); + }, 120_000); + + it('retains the original six signatures and their exact direct caller counts', () => { + const expected: Record = { + 'find#1~Collection': 3, + 'find#1~Long': 1, + 'resolve#1~String': 1, + 'resolve#1~Long': 1, + 'count#1': 2, + 'count#2': 1, + }; + const calls = getRelationships(original, 'CALLS'); + for (const [signature, count] of Object.entries(expected)) { + const id = `Method:src/main/java/demo/LookupService.java:LookupService.${signature}`; + expect(original.graph.getNode(id), signature).toBeDefined(); + expect( + new Set(calls.filter((edge) => edge.rel.targetId === id).map((edge) => edge.rel.sourceId)) + .size, + signature, + ).toBe(count); + } + }); + + it('selects collection factories without attributing them to the scalar overload', () => { + for (const caller of ['listOrders', 'settle', 'find']) { + expect(targets(original, caller, 'find')).toEqual([ + 'Method:src/main/java/demo/LookupService.java:LookupService.find#1~Collection', + ]); + } + expect( + getResolutionOutcomes(original).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason.startsWith('overload-'), + ), + ).toEqual([]); + }); +}); + +describe('Java compile-time overload families', () => { + const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-java-overload-jls-')); + let result: PipelineResult; + beforeAll(async () => { + writeFixtureRepo(root, { + 'Cases.java': ` +import java.util.*; +class Base { public void inherited(long value) {} public void declared(Object value) {} } +class Derived extends Base { + public void inherited(Integer value) {} + public void declared(String value) {} +} +class Intermediate extends Base {} +class Leaf extends Intermediate { public void inherited(Integer value) {} } +class Nest { + private void nest(Integer value) {} private void nest(long value) {} + class Inner { void nestedPrivate(Nest outer) { outer.nest(1); } } +} +class StaticInvocation { + static void staticChoice(Object value) {} void staticChoice(String value) {} + static void staticUnqualified() { staticChoice("x"); } + static void staticQualified() { StaticInvocation.staticChoice("x"); } +} +class AccessFilter { + private void accessFiltered(String value) {} public void accessFiltered(Object value) {} +} +class BaseShadow { String value; } +class OuterShadow { Long value; class InnerShadow extends BaseShadow { + void shadowPick(String value) {} void shadowPick(Long value) {} + void inheritedByThis() { shadowPick(this.value); } + void inheritedImplicit() { shadowPick(value); } +} } +class ForScope { Long item; + String[] loopSource(String value) { return null; } String[] loopSource(Long value) { return null; } + void loopBody(String value) {} void loopBody(Long value) {} + void enhancedForScope() { for (String item : loopSource(item)) { loopBody(item); } } +} +interface API { void dispatch(long value); void dispatch(Integer value); } +interface Generic { void genericDispatch(T value); } +class StringGeneric implements Generic { + public void genericDispatch(String value) {} public void genericDispatch(Integer value) {} +} +class IntegerGeneric implements Generic { public void genericDispatch(Integer value) {} } +interface Left { void preferred(String value); } +interface Right { void preferred(String value); } +abstract class Both implements Left, Right { void preferred(Object value) {} } +class Implementation implements API { + public void dispatch(long value) {} public void dispatch(Integer value) {} +} +class A { void run() {} A next() { return this; } } +class B { void run() {} B next() { return this; } } +class SubA extends A { void run() {} } +interface Maker { A create(long value); B create(Integer value); } +class MakerImpl implements Maker { public SubA create(long value) { return new SubA(); } public B create(Integer value) { return new B(); } } +class Cases { + void phase(Integer value) {} void phase(long value) {} + void boxed(long value) {} void boxed(Number value) {} + void variable(String... values) {} void variable(Object value) {} + void empty(Object... values) {} void empty(String... values) {} + void nullable(String value) {} void nullable(Integer value) {} + void crossed(String a, Object b) {} void crossed(Object a, String b) {} + void opaque(String value) {} void opaque(Long value) {} + void forbidden(Long value) {} + void array(Object value) {} void array(String[] value) {} + void dimensions(Object[] value) {} void dimensions(Object value) {} + void concrete(Collection value) {} void concrete(Object value) {} + void poly(Collection value) {} void poly(Object value) {} + void polyAmbiguous(List value) {} void polyAmbiguous(Collection value) {} + void primitiveArray(List value) {} void primitiveArray(Object value) {} + B make(Integer value) { return new B(); } A make(long value) { return new A(); } + A uncertain(String value) { return new A(); } B uncertain(Long value) { return new B(); } + void dimension(String value) {} void dimension(Object value) {} + void widening() { phase(1); } + void refBeforeUnbox(Integer value) { boxed(value); } + void fixedBeforeVarargs() { variable("x"); } + void emptyVarargs() { empty(); } + void nullAmbiguous() { nullable(null); } + void crossedAmbiguous() { crossed("a", "b"); } + Object external() { return null; } + void unknownArgument() { opaque(external()); } + void knownInapplicable() { forbidden(1); } + void arraySpecific(String[] value) { array(value); } + void primitiveDimensions(int[][] value) { dimensions(value); } + void invariant(List value) { concrete(value); } + void targetTyped(Long value) { poly(Arrays.asList(value)); } + void targetTypedSingleton(Long value) { poly(Collections.singletonList(value)); } + void polyTie() { polyAmbiguous(Arrays.asList(1)); } + void primitiveFactory(long[] values) { primitiveArray(Arrays.asList(values)); } + void inheritedPhase(Derived value) { value.inherited(1); } + void emptyIntermediate(Leaf value) { value.inherited(1); } + void declaredReceiver() { Base value = new Derived(); value.declared("x"); } + void virtualDispatch(API value) { value.dispatch(1); } + void chain() { make(1).run(); } + void assignedChain() { var value = make(1); value.run(); } + void nestedChain() { make(1).next().run(); } + void unknownAssignment() { var value = uncertain(external()); value.run(); } + void ambiguousAssignment() { var value = uncertain(null); value.run(); } + void interfaceReturn(Maker maker) { maker.create(1).run(); } + void trailingDimension(String value[]) { dimension(value); } + void equivalentPreferred(Both value) { value.preferred("x"); } + void genericCall(Generic value) { value.genericDispatch("x"); } + void inaccessibleAlternate(AccessFilter value) { value.accessFiltered(external()); } + void accessibleKnown(AccessFilter value) { value.accessFiltered("x"); } + void accessibleObject(AccessFilter value, Object argument) { value.accessFiltered(argument); } + void genericAny(Generic value, T item) { value.genericDispatch(item); } +}`, + 'api/Service.java': `package api; public interface Service { void select(long value); void select(Integer value); }`, + 'client/Client.java': `package client; class Client { void acrossPackages(api.Service service) { service.select(1); } }`, + 'names/Names.java': `package names; public class Names { public static class Long {} }`, + 'names/Other.java': `package names; public class Other { public static class Long {} }`, + 'client/StaticType.java': `package client; import static names.Names.Long; +class StaticType { + void select(Long value) {} void select(Number value) {} + void staticTypeImport() { select(1L); } + void staticSourceType(Long value) { select(value); } +}`, + 'client/MissingStaticType.java': `package client; import static external.Names.Long; +class MissingStaticType { void select(Long value) {} void select(Number value) {} void missingStaticType() { select(1L); } }`, + 'client/ConflictingStaticType.java': `package client; import static names.Names.Long; import static names.Other.Long; +class ConflictingStaticType { void select(Long value) {} void select(Number value) {} void conflictingStaticTypes() { select(1L); } }`, + 'first/Base.java': `package first; public class Base { void access(String value) {} protected void protectedChoice(Integer value) {} protected void protectedChoice(long value) {} }`, + 'second/Middle.java': `package second; public class Middle extends first.Base {}`, + 'second/ProtectedChild.java': `package second; import first.Base; class ProtectedChild extends Base { void superProtected() { super.protectedChoice(1); } }`, + 'first/Child.java': `package first; class Child extends second.Middle { + void access(Object value) {} void interruptedPackage() { access("x"); } + void castBase() { ((Base)this).access("x"); } +}`, + 'shadow/Shadow.java': `package shadow; +class Arrays { static Object asList(long value) { return null; } } +class Long {} +class Shadow { + void select(java.util.Collection value) {} void select(java.lang.Long value) {} + void fakeFactory() { select(Arrays.asList(1L)); } + void scalar(java.lang.Long value) {} void scalar(Long value) {} + void shadowedBox(Long value) { scalar(value); } + void generic(java.lang.Long value) {} void generic(Object value) {} + void typeParameter(Long value) { generic(value); } +} +class FactoryField { + Object Arrays; + void select(java.util.Collection value) {} void select(java.lang.Long value) {} + void fieldFactory() { select(Arrays.asList(1L)); } +} +class Incomplete extends external.Base { + void select(java.lang.String value) {} void select(java.lang.Long value) {} + void inheritedUnknown() { select("x"); } +} +class ObjectOverload { + void wait(Integer value) {} + void implicitObject() throws InterruptedException { wait(1); } +}`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 120_000); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + it.each([ + ['widening', 'phase', 'Cases.phase#1~long'], + ['refBeforeUnbox', 'boxed', 'Cases.boxed#1~Number'], + ['fixedBeforeVarargs', 'variable', 'Cases.variable#1'], + ['emptyVarargs', 'empty', 'Cases.empty'], + ['arraySpecific', 'array', 'Cases.array#1~String[]'], + ['primitiveDimensions', 'dimensions', 'Cases.dimensions#1~Object[]'], + ['invariant', 'concrete', 'Cases.concrete#1~Object'], + ['targetTyped', 'poly', 'Cases.poly#1~Collection'], + ['targetTypedSingleton', 'poly', 'Cases.poly#1~Collection'], + ['primitiveFactory', 'primitiveArray', 'Cases.primitiveArray#1~Object'], + ['inheritedPhase', 'inherited', 'Base.inherited#1'], + ['emptyIntermediate', 'inherited', 'Base.inherited#1'], + ['trailingDimension', 'dimension', 'Cases.dimension#1~Object'], + ['nestedPrivate', 'nest', 'Nest.nest#1~long'], + ['accessibleKnown', 'accessFiltered', 'AccessFilter.accessFiltered#1~Object'], + ['accessibleObject', 'accessFiltered', 'AccessFilter.accessFiltered#1~Object'], + ['declaredReceiver', 'declared', 'Base.declared#1'], + ])('%s selects %s by the earliest applicable JLS phase', (caller, method, signature) => { + expect(targets(result, caller, method)).toEqual([`Method:Cases.java:${signature}`]); + }); + + it.each([ + ['nullAmbiguous', 'nullable', 'ambiguous'], + ['crossedAmbiguous', 'crossed', 'ambiguous'], + ['unknownArgument', 'opaque', 'unknown'], + ['knownInapplicable', 'forbidden', 'inapplicable'], + ['polyTie', 'polyAmbiguous', 'ambiguous'], + ['staticUnqualified', 'staticChoice', 'inapplicable'], + ['staticQualified', 'staticChoice', 'inapplicable'], + ])('%s preserves a terminal %s outcome', (caller, method, status) => { + expect(targets(result, caller, method)).toEqual([]); + expect(getResolutionOutcomes(result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'suppressed', + name: method, + reason: status === 'ambiguous' ? 'member-lookup-ambiguous' : `overload-${status}`, + }), + ]), + ); + }); + + it('fans out only the selected interface signature', () => { + expect(targets(result, 'virtualDispatch', 'dispatch')).toEqual([ + 'Method:Cases.java:API.dispatch#1~long', + 'Method:Cases.java:Implementation.dispatch#1~long', + ]); + }); + + it.each(['inheritedByThis', 'inheritedImplicit'])( + 'uses the inherited field before the enclosing field for %s', + (caller) => { + const calls = targets(result, caller, 'shadowPick'); + expect(calls).toHaveLength(1); + expect(result.graph.getNode(calls[0]!)?.properties.parameterTypes).toEqual(['String']); + }, + ); + + it('uses the field in an enhanced-for iterable and the loop variable in its body', () => { + expect(targets(result, 'enhancedForScope', 'loopSource')).toEqual([ + 'Method:Cases.java:ForScope.loopSource#1~Long', + ]); + expect(targets(result, 'enhancedForScope', 'loopBody')).toEqual([ + 'Method:Cases.java:ForScope.loopBody#1~String', + ]); + }); + + it('preserves generic dispatch filtering and matches the grounded signature across implementation overloads', () => { + expect(targets(result, 'genericCall', 'genericDispatch')).toEqual([ + 'Method:Cases.java:Generic.genericDispatch#1', + 'Method:Cases.java:StringGeneric.genericDispatch#1~String', + ]); + expect(targets(result, 'genericAny', 'genericDispatch')).toEqual([ + 'Method:Cases.java:Generic.genericDispatch#1', + 'Method:Cases.java:IntegerGeneric.genericDispatch#1', + 'Method:Cases.java:StringGeneric.genericDispatch#1~String', + ]); + }); + + it('selects the String varargs declaration even when legacy variadic graph IDs coincide', () => { + const lines = readFileSync(path.join(root, 'Cases.java'), 'utf8').split('\n'); + const line = lines.findIndex((text) => text.includes('void empty(String...')); + const column = lines[line]!.indexOf('void empty(String...'); + expect(getResolutionOutcomes(result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'resolved', + name: 'empty', + targetId: `def:Cases.java#${line + 1}:${column}:Method:empty`, + }), + ]), + ); + }); + + it('treats interface methods as public across package boundaries', () => { + expect(targets(result, 'acrossPackages', 'select')).toEqual([ + 'Method:api/Service.java:Service.select#1~long', + ]); + }); + + it('resolves a source type named Long by identity without assigning wrapper semantics', () => { + expect(targets(result, 'shadowedBox', 'scalar')).toEqual([ + 'Method:shadow/Shadow.java:Shadow.scalar#1~Long', + ]); + }); + + it.each([ + ['staticTypeImport', 'Number'], + ['staticSourceType', 'Long'], + ])( + 'resolves %s with the statically imported source type distinct from java.lang.Long', + (caller, parameter) => { + expect(targets(result, caller, 'select')).toEqual([ + `Method:client/StaticType.java:StaticType.select#1~${parameter}`, + ]); + }, + ); + + it('does not re-inherit a package member after an intermediate class in another package', () => { + expect(targets(result, 'interruptedPackage', 'access')).toEqual([ + 'Method:first/Child.java:Child.access#1', + ]); + expect(targets(result, 'castBase', 'access')).toEqual(['Method:first/Base.java:Base.access#1']); + }); + + it('permits a protected superclass call from a subclass in another package', () => { + expect(targets(result, 'superProtected', 'protectedChoice')).toEqual([ + 'Method:first/Base.java:Base.protectedChoice#1~long', + ]); + }); + + it.each([ + ['fakeFactory', 'select'], + ['fieldFactory', 'select'], + ['typeParameter', 'generic'], + ['inheritedUnknown', 'select'], + ['implicitObject', 'wait'], + ['missingStaticType', 'select'], + ['conflictingStaticTypes', 'select'], + ['equivalentPreferred', 'preferred'], + ])('records uncertainty for %s without a guessed overload edge', (caller, method) => { + expect(targets(result, caller, method)).toEqual([]); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.kind === 'suppressed' && + outcome.name === method && + outcome.reason === 'overload-unknown' && + outcome.callerId?.includes(`.${caller}#`), + ), + ).toBe(true); + }); + + it('reports unmodeled method-call arguments even when javac can select an overload', () => { + expect(targets(result, 'inaccessibleAlternate', 'accessFiltered')).toEqual([]); + expect(getResolutionOutcomes(result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'suppressed', + name: 'accessFiltered', + reason: 'overload-unknown', + callerId: expect.stringContaining('.inaccessibleAlternate#'), + detail: 'Unsupported Java argument, generic constraint, or subtype evidence', + }), + ]), + ); + }); + + it.each(['chain', 'assignedChain', 'nestedChain'])( + 'uses the selected compile-time return for %s', + (caller) => { + expect(targets(result, caller, 'make')).toEqual(['Method:Cases.java:Cases.make#1~long']); + expect(targets(result, caller, 'run')).toEqual(['Method:Cases.java:A.run#0']); + }, + ); + + it.each(['unknownAssignment', 'ambiguousAssignment'])( + 'does not infer an assignment receiver before selection for %s', + (caller) => { + expect(targets(result, caller, 'uncertain')).toEqual([]); + expect(targets(result, caller, 'run')).toEqual([]); + }, + ); + + it('uses the selected interface declaration return despite a covariant implementation return', () => { + expect(targets(result, 'interfaceReturn', 'run')).toContain('Method:Cases.java:A.run#0'); + expect(targets(result, 'interfaceReturn', 'run')).not.toContain('Method:Cases.java:B.run#0'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 1c8720877..c174baf7f 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -658,13 +658,11 @@ describe('Java variadic call resolution', () => { expect(fmtCall!.targetFilePath).toBe('com/example/util/Formatter.java'); }); - it('0-arg call to format(int, String...) still resolves in legacy mode (arity rejection is registry-only)', () => { - // When `requiredParameterCount = 1`, `javaArityCompatibility` returns - // 'incompatible' for 0-arg calls, which would prevent the CALLS edge. - // This test documents the current resolution behavior for this shape. + it('rejects a 0-arg call to format(int, String...) with a required prefix', () => { + // JLS 15.12.2.4 still requires every fixed-prefix parameter. const calls = getRelationships(result, 'CALLS'); const zeroArgFmtCall = calls.find((c) => c.target === 'format' && c.source === 'badCall'); - expect(zeroArgFmtCall).toBeDefined(); + expect(zeroArgFmtCall).toBeUndefined(); }); }); @@ -2905,22 +2903,12 @@ describe('Java cast receiver resolution', () => { ); }); - // ((String) obj).act(): `String` is a resolvable-SHAPE cast type (simple - // identifier) that is not locally indexed, so resolution deliberately falls - // back to obj's OWN declared type (Fallback). This is intentionally kept, - // unlike the unparseable-cast case (#2353 review F1), whose criterion is: - // a paren group that is type-shaped but UNPARSEABLE (generic / array / FQN) - // must resolve to nothing, because falling through to the pre-cast - // expression's declared type emits a confident wrong edge. Here the cast IS - // parseable — it just names a type we didn't index — so no better - // information exists, and upcast casts make the declared type a plausible - // dispatch target. Residual risk kept visible: a cross-cast to an unindexed - // sibling type would still emit this declared-type fallback edge. - it('falls back to the declared type for a cast to an unindexed simple type (String)', () => { + // The cast's type governs member lookup. String has no act() method; + // falling back to the pre-cast Fallback type invents a call javac rejects. + it('does not fall back to the pre-cast type for an unindexed String receiver', () => { const calls = getRelationships(result, 'CALLS'); const actCall = calls.find((c) => c.target === 'act' && c.source === 'castUnindexedType'); - expect(actCall).toBeDefined(); - expect(actCall!.targetFilePath).toBe('models/Fallback.java'); + expect(actCall).toBeUndefined(); }); }); @@ -3100,16 +3088,9 @@ describe('Java bare-this dispatch (Case 4 pinning)', () => { expect(getNodesByLabel(result, 'Interface')).toEqual(['Task']); }); - // Characterization, not idealization: `this.greet("world")` (arity 1) - // inside Derived binds to Derived.greet(String, int) — Case 4's - // `pickFirstNonStaticOnly` short-circuits on a single-overload owner - // without arity narrowing, so the inherited Base.greet(String) never gets - // a look. The ideal Base.greet target is the deferred Case 4 arity item - // (docs/plans/2026-07-02-001 § Deferred). Under PR #2353's Case 0.5, the - // `hiddenByName` C++ name-hiding rule dropped this member site entirely - // and a free-call fallback edge (reason 'local-call') to the same target - // masked the drop. - it('pins this.greet("world") in Derived to Derived.greet(String,int) — arity-blind shortcut', () => { + // The inherited one-parameter overload participates alongside the local + // two-parameter declaration (JLS 15.12.2); javac selects Base.greet(String). + it('resolves this.greet("world") to the inherited Base.greet(String)', () => { const calls = getRelationships(result, 'CALLS'); const greetEdges = [ ...new Set( @@ -3118,7 +3099,7 @@ describe('Java bare-this dispatch (Case 4 pinning)', () => { .map((c) => `${c.targetFilePath} reason=${c.rel.reason}`), ), ].sort(); - expect(greetEdges).toEqual(['models/Derived.java reason=global']); + expect(greetEdges).toEqual(['models/Base.java reason=import-resolved']); }); // Characterization, not idealization: with field `size` AND method diff --git a/gitnexus/test/unit/callable-id-lockstep.test.ts b/gitnexus/test/unit/callable-id-lockstep.test.ts index 5555286e6..37d286af0 100644 --- a/gitnexus/test/unit/callable-id-lockstep.test.ts +++ b/gitnexus/test/unit/callable-id-lockstep.test.ts @@ -69,6 +69,26 @@ const semanticDef = ( }); describe('boundCallableStartRow - #2735 semantic position join', () => { + it('reads unrelated definition names only while building the per-file index', () => { + let reads = 0; + const defs = Array.from({ length: 128 }, (_, i) => { + const def = semanticDef('src/file.ext', 'Method', `Owner.method${i}`, i + 1, 0); + const qualifiedName = def.qualifiedName; + Object.defineProperty(def, 'qualifiedName', { + get() { + reads++; + return qualifiedName; + }, + }); + return def; + }); + for (let i = 0; i < defs.length; i++) { + const wrapper = stubNode({ type: 'binding_wrapper', id: i, row: i, endColumn: 20 }); + expect(boundCallableStartRow(wrapper, `method${i}`, 'Method', defs)).toBe(i); + } + expect(reads).toBeLessThanOrEqual(defs.length * 2); + }); + it('keeps the wrapper row when no semantic definition is available', () => { const wrapper = stubNode({ type: 'binding_wrapper', diff --git a/gitnexus/test/unit/java-export-detection.test.ts b/gitnexus/test/unit/java-export-detection.test.ts new file mode 100644 index 000000000..ac04e8e87 --- /dev/null +++ b/gitnexus/test/unit/java-export-detection.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { javaExportChecker } from '../../src/core/ingestion/export-detection.js'; +import { getJavaParser } from '../../src/core/ingestion/languages/java/query.js'; + +describe('Java export detection', () => { + it.each([ + ['public class A { void run() {} }', true], + ['class A { public void run() {} }', true], + ['class A { /* comment */ @Deprecated public void run() {} }', true], + ['class A { private void run() {} }', false], + ['class A { void run() {} }', false], + ])('retains modifier and ancestor visibility: %s', (source, exported) => { + const tree = getJavaParser().parse(source); + const method = tree.rootNode.descendantsOfType('method_declaration')[0]!; + expect(javaExportChecker(method.childForFieldName('name')!, 'run')).toBe(exported); + }); + + it('does not scan sibling methods while checking a declaration', () => { + const tree = getJavaParser().parse( + `class Wide { ${Array.from({ length: 128 }, (_, i) => `void method${i}() {}`).join('\n')} }`, + ); + const body = tree.rootNode.namedChildren[0]!.childForFieldName('body')!; + const name = body.namedChildren[0]!.childForFieldName('name')!; + const child = vi.spyOn(body, 'child'); + const namedChild = vi.spyOn(body, 'namedChild'); + expect(javaExportChecker(name, 'method0')).toBe(false); + expect(child.mock.calls.length + namedChild.mock.calls.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/gitnexus/test/unit/mcp-response-filter.test.ts b/gitnexus/test/unit/mcp-response-filter.test.ts index 75d1ce641..cc34ad23d 100644 --- a/gitnexus/test/unit/mcp-response-filter.test.ts +++ b/gitnexus/test/unit/mcp-response-filter.test.ts @@ -19,7 +19,7 @@ const context = { typed_properties: [{ uid: 'Property:src/pay.ts:currency' }], epistemic: 'lower-bound', boundaries: ['Dynamic dispatch is unresolved'], - causes: { callableValueReferences: 1 }, + causes: { callableValueReferences: 1, overloadResolution: 2 }, warning: 'Source extraction incomplete', partial: true, truncated: true, diff --git a/gitnexus/test/unit/scope-resolution/callable-value-target-index.test.ts b/gitnexus/test/unit/scope-resolution/callable-value-target-index.test.ts index 6f301fe70..98512086e 100644 --- a/gitnexus/test/unit/scope-resolution/callable-value-target-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/callable-value-target-index.test.ts @@ -64,6 +64,34 @@ const targetsFor = (nodes: readonly StubNode[], defs: readonly ReturnType { + it.each([128, 512])( + 'rejects %i unrelated value names without reading definition positions', + (count) => { + let positionReads = 0; + const definitions = Array.from({ length: count }, (_, index) => { + const definition = def('Variable', 'a.ts', `config${index}`, index + 1); + return { + ...definition, + get nodeId() { + positionReads++; + return definition.nodeId; + }, + }; + }); + const scopes = scopesOf(definitions); + positionReads = 0; + expect( + buildGraphTargetIndex( + scopes, + new Map() as GraphNodeLookup, + undefined, + graphOf([node('Function', 'a.ts', 'handler', 3)]), + ).size, + ).toBe(0); + expect(positionReads).toBe(0); + }, + ); + it('admits a value binding whose callable node sits at its own line', () => { // The #2687 closure-binding shape: the value def and the Function node are // the same construct, so they share file, line and name. @@ -80,6 +108,21 @@ describe('buildGraphTargetIndex — value bindings join by position', () => { ).toEqual([]); }); + it('rejects a same-name callable at the same line in another file', () => { + expect( + targetsFor( + [node('Function', 'other.ts', 'handler', 3)], + [def('Variable', 'a.ts', 'handler', 3)], + ), + ).toEqual([]); + }); + + it('normalises the value binding sigil before looking for callable names', () => { + expect( + targetsFor([node('Function', 'a.php', 'save', 5)], [def('Variable', 'a.php', '$save', 5)]), + ).toEqual(['def:a.php#5:0:Variable:$save => Function:a.php:save']); + }); + it('REJECTS a value binding whose node at that position is NOT callable', () => { expect( targetsFor([node('Const', 'a.ts', 'CONFIG', 4)], [def('Const', 'a.ts', 'CONFIG', 4)]), diff --git a/gitnexus/test/unit/scope-resolution/java/java-overload-argument-facts.test.ts b/gitnexus/test/unit/scope-resolution/java/java-overload-argument-facts.test.ts new file mode 100644 index 000000000..faad45eb0 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/java/java-overload-argument-facts.test.ts @@ -0,0 +1,380 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getJavaParser } from '../../../../src/core/ingestion/languages/java/query.js'; +import { + captureJavaOverloadFacts, + clearJavaOverloadFacts, + getJavaOverloadCall, + getJavaOverloadFacts, + isJavaOverloadFileFacts, + setJavaOverloadFacts, +} from '../../../../src/core/ingestion/languages/java/overload-argument-facts.js'; + +function capture(source: string) { + const tree = getJavaParser().parse(source); + return captureJavaOverloadFacts(tree.rootNode)!; +} +afterEach(clearJavaOverloadFacts); + +describe('Java lossless overload argument facts', () => { + it('does not compare bindings from unrelated methods with the same parameter name', () => { + const size = 128; + const tree = getJavaParser().parse( + `class Wide { ${Array.from( + { length: size }, + (_, i) => `void method${i}(String value) { use(value); }`, + ).join('\n')} }`, + ); + const owner = tree.rootNode.namedChildren[0]!; + const id = owner.id; + let reads = 0; + Object.defineProperty(owner, 'id', { + get() { + reads++; + return id; + }, + }); + const facts = captureJavaOverloadFacts(tree.rootNode)!; + expect(facts.calls.map((call) => call.arguments[0])).toEqual( + Array.from({ length: size }, () => + expect.objectContaining({ kind: 'typed', spelling: 'String' }), + ), + ); + expect(reads).toBeLessThanOrEqual(size * 16); + }); + + it('materializes a wide class body once during traversal', () => { + const source = `class Wide { ${Array.from( + { length: 128 }, + (_, i) => `void method${i}() { use("text"); }`, + ).join('\n')} }`; + const tree = getJavaParser().parse(source); + const body = tree.rootNode.namedChildren[0]!.childForFieldName('body')!; + const children = body.namedChildren; + let reads = 0; + Object.defineProperty(body, 'namedChildren', { + get() { + reads++; + return children; + }, + }); + const facts = captureJavaOverloadFacts(tree.rootNode)!; + expect(facts.methods).toHaveLength(128); + expect(facts.calls).toHaveLength(128); + expect(reads).toBe(1); + }); + + it('keeps formal parameters in their own methods and respects field/block shadowing', () => { + const facts = capture(`class A { + Long value; + void first(String value) { use(value); } + void second(Long value) { use(value); } + void blocks() { { String value = "x"; use(value); } use(value); } + }`); + expect( + facts.calls.filter((call) => call.name === 'use').map((call) => call.arguments[0]), + ).toMatchObject([ + { kind: 'typed', spelling: 'String' }, + { kind: 'typed', spelling: 'Long' }, + { kind: 'typed', spelling: 'String' }, + { kind: 'typed', spelling: 'Long' }, + ]); + }); + + it('does not let a later local declaration hide the field at an earlier call', () => { + const facts = capture( + 'class A { Long value; void run() { use(value); String value = "x"; use(value); } }', + ); + expect(facts.calls.map((call) => call.arguments[0])).toMatchObject([ + { kind: 'typed', spelling: 'Long' }, + { kind: 'typed', spelling: 'String' }, + ]); + }); + + it('uses an explicit this field declaration despite a local with the same name', () => { + const facts = capture( + 'class A { Long value; void run(String value) { use(value); use(this.value); } }', + ); + expect(facts.calls.map((call) => call.arguments[0])).toMatchObject([ + { kind: 'typed', spelling: 'String' }, + { kind: 'typed', spelling: 'Long' }, + ]); + }); + + it('looks up inherited fields before enclosing fields and retains declaration context', () => { + const facts = capture(`class Base { String value; } + class Outer { Long value; class Inner extends Base { + void run(Long value) { use(this.value); use(value); } + void implicit() { use(value); } + } }`); + const arguments_ = facts.calls + .filter((call) => call.name === 'use') + .map((call) => call.arguments[0]); + expect(arguments_).toMatchObject([ + { kind: 'typed', spelling: 'String', at: { startLine: 1 } }, + { kind: 'typed', spelling: 'Long', at: { startLine: 3 } }, + { kind: 'typed', spelling: 'String', at: { startLine: 1 } }, + ]); + }); + + it('keeps enhanced-for bindings out of the iterable expression', () => { + const facts = capture( + 'class A { Long item; void run() { for (String item : source(item)) use(item); } }', + ); + expect(facts.calls.map((call) => call.arguments[0])).toMatchObject([ + { kind: 'typed', spelling: 'Long' }, + { kind: 'typed', spelling: 'String' }, + ]); + }); + + it.each([ + ['unknown superclass', '', 'extends External'], + ['generic field substitution', 'class Base { T value; }', 'extends Base'], + [ + 'ambiguous interface fields', + 'interface Left { String value = "x"; } interface Right { Long value = 1L; }', + 'implements Left, Right', + ], + ])('does not infer an outer field across %s', (_description, declarations, heritage) => { + const facts = capture(`${declarations} class Outer { Long value; class Inner ${heritage} { + void run() { use(value); use(this.value); } + } }`); + expect( + facts.calls.filter((call) => call.name === 'use').map((call) => call.arguments[0]), + ).toEqual([{ kind: 'unknown' }, { kind: 'unknown' }]); + }); + + it('does not inherit private fields or use an outer field for explicit this', () => { + const facts = capture( + 'class Base { private String value; } class Outer { Long value; class Inner extends Base { void run() { use(value); use(this.value); } } }', + ); + expect(facts.calls.map((call) => call.arguments[0])).toMatchObject([ + { kind: 'typed', spelling: 'Long' }, + { kind: 'unknown' }, + ]); + }); + + it('resolves an enclosing field through a deep empty inheritance diamond', () => { + const declarations = ['interface A0 {}', 'interface B0 {}']; + // Without memoization this small hierarchy expands into millions of paths. + for (let depth = 1; depth <= 24; depth++) { + declarations.push( + `interface A${depth} extends A${depth - 1}, B${depth - 1} {}`, + `interface B${depth} extends A${depth - 1}, B${depth - 1} {}`, + ); + } + const facts = capture(`${declarations.join('\n')} + class Outer { Long value; class Inner implements A24, B24 { + void run() { use(value); use(value); } + } }`); + expect(facts.calls.map((call) => call.arguments[0])).toMatchObject([ + { kind: 'typed', spelling: 'Long' }, + { kind: 'typed', spelling: 'Long' }, + ]); + }); + + it('does not reuse a shallow field proof beyond the inheritance depth bound', () => { + const declarations = ['interface A0 {}']; + for (let depth = 1; depth <= 63; depth++) + declarations.push(`interface A${depth} extends A${depth - 1} {}`); + const facts = capture(`${declarations.join('\n')} + class Outer { Long value; class Inner implements A0, A63 { + void run() { use(value); use(value); } + } }`); + expect(facts.calls.map((call) => call.arguments[0])).toEqual([ + { kind: 'unknown' }, + { kind: 'unknown' }, + ]); + }); + + it('keeps cyclic field inheritance unknown across repeated lookups', () => { + const facts = capture(`interface Left extends Right {} interface Right extends Left {} + class Outer { Long value; class Inner implements Left, Right { + void run() { use(value); use(value); } + } }`); + expect(facts.calls.map((call) => call.arguments[0])).toEqual([ + { kind: 'unknown' }, + { kind: 'unknown' }, + ]); + }); + + it('retains numeric suffixes, declared containers, dimensions, casts, and null separately', () => { + const facts = capture( + 'class A { void run(java.util.List ids, int[][] grid) { use(1L, 1f, ids, grid, (String)null, null); } }', + ); + expect(facts.calls[0]!.arguments).toMatchObject([ + { kind: 'typed', spelling: 'long' }, + { kind: 'typed', spelling: 'float' }, + { kind: 'typed', spelling: 'java.util.List' }, + { kind: 'typed', spelling: 'int[][]' }, + { kind: 'typed', spelling: 'String' }, + { kind: 'null' }, + ]); + }); + + it('retains qualified generic formal types and array dimensions in candidate declarations', () => { + const facts = capture( + 'class A { void use(java.util.Collection ids, String[] names, int value[], Long... rest) {} }', + ); + expect(facts.methods[0]!.parameterTypes).toEqual([ + 'java.util.Collection', + 'String[]', + 'int[]', + 'Long', + 'varargs', + ]); + }); + + it('preserves trailing dimensions on method and explicitly typed lambda formals', () => { + const facts = capture( + 'class A { void run(String value[]) { use(value); java.util.function.Consumer lambda = (String item[]) -> use(item); } }', + ); + expect( + facts.calls.filter((call) => call.name === 'use').map((call) => call.arguments[0]), + ).toMatchObject([ + { kind: 'typed', spelling: 'String[]' }, + { kind: 'typed', spelling: 'String[]' }, + ]); + }); + + it('retains poly factories and explicit type arguments rather than freezing List', () => { + const facts = capture( + 'import java.util.Arrays; class A { void run(Long id) { use(Arrays.asList(id, 1L)); } }', + ); + expect(facts.calls.find((call) => call.name === 'use')!.arguments[0]).toMatchObject({ + kind: 'factory', + owner: 'Arrays', + name: 'asList', + typeArguments: ['Number'], + shadowed: false, + arguments: [ + { kind: 'typed', spelling: 'Long' }, + { kind: 'typed', spelling: 'long' }, + ], + }); + }); + + it('records value shadowing of a factory qualifier and type-parameter declarations', () => { + const facts = capture( + 'class A { void run(Object Arrays) { use(Arrays.asList(1L)); } }', + ); + expect(facts.types[0]!.typeParameters).toEqual(['Long']); + expect(facts.methods[0]!.typeParameters).toEqual(['String']); + expect(facts.calls.find((call) => call.name === 'use')!.arguments[0]).toMatchObject({ + kind: 'factory', + shadowed: true, + }); + }); + + it('keeps same-start chained call sites distinct through a plain-data round trip', () => { + const facts = capture('class A { void run(Long id) { lookup.find(id).get(id); } }'); + const restored: unknown = JSON.parse(JSON.stringify(facts)); + expect(isJavaOverloadFileFacts(restored)).toBe(true); + setJavaOverloadFacts('A.java', restored as typeof facts); + for (const call of facts.calls) + expect(getJavaOverloadCall('A.java', call.range, call.name)).toEqual(call); + setJavaOverloadFacts('A.java', undefined); + expect(getJavaOverloadFacts('A.java')).toBeUndefined(); + expect( + getJavaOverloadCall('A.java', facts.calls[0]!.range, facts.calls[0]!.name), + ).toBeUndefined(); + }); + + it('assigns enum constant methods to their synthetic owners and retains the host base', () => { + const facts = capture(`class Host { enum Mode { + FIRST { void hook() { helper(); } }, SECOND { void hook() {} }, PLAIN; + void helper() {} + } }`); + expect(facts.types.map(({ name, simpleName, bases }) => ({ name, simpleName, bases }))).toEqual( + [ + { name: 'Host', simpleName: 'Host', bases: [] }, + { name: 'Mode', simpleName: 'Mode', bases: [] }, + { name: 'Host$Mode$1', simpleName: 'Host$Mode$1', bases: ['Mode'] }, + { name: 'Host$Mode$2', simpleName: 'Host$Mode$2', bases: ['Mode'] }, + ], + ); + expect(facts.methods.map(({ owner, name }) => `${owner}.${name}`)).toEqual([ + 'Host$Mode$1.hook', + 'Host$Mode$2.hook', + 'Mode.helper', + ]); + }); + + it('captures implicit record accessors without duplicating explicit zero-argument accessors', () => { + const facts = capture(`class Host { void run() { + record Data(String name, int count, String... tags) { + public String name() { return name; } + int count(int factor) { return count * factor; } + } + } }`); + const accessors = facts.methods.filter((method) => method.owner === 'Host$1Data'); + expect( + accessors.map(({ name, parameterTypes, modifiers }) => ({ + name, + parameterTypes, + modifiers, + })), + ).toEqual([ + { name: 'count', parameterTypes: [], modifiers: ['public'] }, + { name: 'tags', parameterTypes: [], modifiers: ['public'] }, + { name: 'name', parameterTypes: [], modifiers: ['public'] }, + { name: 'count', parameterTypes: ['int'], modifiers: [] }, + ]); + expect(isJavaOverloadFileFacts(JSON.parse(JSON.stringify(facts)))).toBe(true); + }); + + it('does not synthesize accessor facts for malformed record component names', () => { + const facts = capture('record Missing(int x, y) {} record Unnamed(int _) {}'); + expect(facts.methods.map(({ owner, name }) => `${owner}.${name}`)).toEqual(['Missing.x']); + }); + + it('retains type visibility and gives only direct interface members implicit public access', () => { + const facts = capture(`public class Outer { + private static class Hidden {} + protected class Protected {} + class PackageMember {} + } + interface API { + class Member {} + enum Mode { VALUE } + default void run() { class Local {} } + } + @interface Annotation { class Member {} }`); + expect(facts.types.map(({ name, modifiers }) => ({ name, modifiers }))).toEqual([ + { name: 'Outer', modifiers: ['public'] }, + { name: 'Hidden', modifiers: ['private', 'static'] }, + { name: 'Protected', modifiers: ['protected'] }, + { name: 'PackageMember', modifiers: [] }, + { name: 'API', modifiers: [] }, + { name: 'Member', modifiers: ['public'] }, + { name: 'Mode', modifiers: ['public'] }, + { name: 'API$1Local', modifiers: [] }, + { name: 'Annotation', modifiers: [] }, + { name: 'Member', modifiers: ['public'] }, + ]); + }); + + it('validates optional persisted type modifiers without rejecting older facts', () => { + const facts = capture('public class A {}'); + const { modifiers: _modifiers, ...olderType } = facts.types[0]!; + expect(isJavaOverloadFileFacts({ ...facts, types: [olderType] })).toBe(true); + expect(isJavaOverloadFileFacts(JSON.parse(JSON.stringify(facts)))).toBe(true); + expect(isJavaOverloadFileFacts({ ...facts, types: [{ ...olderType, modifiers: null }] })).toBe( + false, + ); + expect(isJavaOverloadFileFacts({ ...facts, types: [{ ...olderType, modifiers: [1] }] })).toBe( + false, + ); + }); + + it('rejects malformed persisted facts and retains empty types for inherited inventories', () => { + expect(capture('class Empty {}').types).toMatchObject([{ name: 'Empty', bases: [] }]); + const facts = capture('class A { void run() { use(null); } }'); + expect( + isJavaOverloadFileFacts({ + ...facts, + calls: [{ ...facts.calls[0], arguments: [{ kind: 'typed', spelling: 1 }] }], + }), + ).toBe(false); + expect(isJavaOverloadFileFacts({ ...facts, methods: [null] })).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/java/java-overload-assignment-capture.test.ts b/gitnexus/test/unit/scope-resolution/java/java-overload-assignment-capture.test.ts new file mode 100644 index 000000000..27915b068 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/java/java-overload-assignment-capture.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { javaProvider } from '../../../../src/core/ingestion/languages/java.js'; +import { emitJavaScopeCaptures } from '../../../../src/core/ingestion/languages/java/captures.js'; +import { extract } from '../../../../src/core/ingestion/scope-extractor.js'; + +describe('Java inferred call-result assignment captures', () => { + it('keeps overload return types on their own declarations with complete written syntax', () => { + const source = `class Case { + java.util.List[] make(Integer value) { return null; } + p.Result make(long value)[] { return null; } + }`; + const parsed = extract(emitJavaScopeCaptures(source, 'Case.java'), 'Case.java', javaProvider); + expect( + parsed.localDefs + .filter((definition) => definition.type === 'Method') + .map((definition) => ({ + parameters: definition.parameterTypes, + returnType: definition.returnType, + })), + ).toEqual([ + { parameters: ['Integer'], returnType: 'java.util.List[]' }, + { parameters: ['long'], returnType: 'p.Result[]' }, + ]); + }); + it('preserves the exact initializer call identity only for var declarations', () => { + const source = `class Case { + Object make(int value) { return null; } + void run(Case other) { + var first = make(1); + var wrapped = (make(2)); + var member = other.make(3); + Object typed = make(4); + var constructed = new Case(); + } + }`; + const parsed = extract(emitJavaScopeCaptures(source, 'Case.java'), 'Case.java', javaProvider); + const sites = parsed.callResultAssignmentSites ?? []; + expect(sites.map((site) => site.lhs)).toEqual(['first', 'wrapped', 'member']); + for (const site of sites) { + expect(parsed.referenceSites).toContainEqual( + expect.objectContaining({ name: 'make', atRange: site.callSite, inScope: site.inScope }), + ); + } + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/java/java-overload-resolution.test.ts b/gitnexus/test/unit/scope-resolution/java/java-overload-resolution.test.ts new file mode 100644 index 000000000..8b6cb86f2 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/java/java-overload-resolution.test.ts @@ -0,0 +1,437 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildQualifiedNameIndex, + buildScopeTree, + type Range, + type Scope, + type SymbolDefinition, +} from 'gitnexus-shared'; +import { createSemanticModel } from '../../../../src/core/ingestion/model/semantic-model.js'; +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; +import { + captureJavaOverloadFacts, + clearJavaOverloadFacts, + setJavaOverloadFacts, +} from '../../../../src/core/ingestion/languages/java/overload-argument-facts.js'; +import { resolveJavaReceiverMember } from '../../../../src/core/ingestion/languages/java/overload-resolution.js'; +import { getJavaParser } from '../../../../src/core/ingestion/languages/java/query.js'; + +function resolveSynthetic( + signatures: readonly { count: number; types?: readonly string[] }[], + arity = 1, +) { + const filePath = 'Synthetic.java'; + const range: Range = { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }; + const atRange: Range = { startLine: 5, startCol: 0, endLine: 5, endCol: 12 }; + const owner: SymbolDefinition = { + nodeId: 'Class:Synthetic.java:Pair', + filePath, + type: 'Class', + qualifiedName: 'Pair', + declarationRange: range, + }; + const methods: SymbolDefinition[] = signatures.map((signature, index) => ({ + nodeId: `Method:Synthetic.java:Pair.setValue#${index}`, + filePath, + type: 'Method', + ownerId: owner.nodeId, + qualifiedName: 'Pair.setValue', + parameterCount: signature.count, + parameterTypes: signature.types === undefined ? undefined : [...signature.types], + isSynthetic: true, + })); + const classScope: Scope = { + id: 'class', + kind: 'Class', + parent: 'module', + filePath, + range, + ownedDefs: [owner, ...methods], + imports: [], + bindings: new Map(), + typeBindings: new Map(), + }; + const moduleScope: Scope = { + ...classScope, + id: 'module', + kind: 'Module', + parent: null, + ownedDefs: [], + }; + const definitions = [owner, ...methods]; + const scopes = { + scopeTree: buildScopeTree([moduleScope, classScope]), + defs: buildDefIndex(definitions), + qualifiedNames: buildQualifiedNameIndex(definitions), + methodDispatch: buildMethodDispatchIndex({ + owners: [owner.nodeId], + computeMro: () => [], + implementsOf: () => [], + }), + } as ScopeResolutionIndexes; + const model = createSemanticModel(); + for (const method of methods) model.methods.register(owner.nodeId, 'setValue', method); + setJavaOverloadFacts(filePath, { + version: 1, + packageName: '', + imports: [], + types: [ + { + name: 'Pair', + simpleName: 'Pair', + kind: 'class_declaration', + range, + visible: range, + bases: [], + typeParameters: [], + }, + ], + methods: [], + calls: [ + { + name: 'setValue', + range: atRange, + arguments: Array.from({ length: arity }, () => ({ + kind: 'typed', + spelling: 'int', + at: atRange, + })), + receiverIsValue: true, + staticContext: false, + }, + ], + }); + return resolveJavaReceiverMember( + owner, + 'setValue', + { inScope: classScope.id, kind: 'call', callForm: 'member', name: 'setValue', atRange, arity }, + scopes, + model, + ); +} + +function hierarchyFixture( + declarations: readonly string[], + bases: readonly string[], + conversion?: { + argumentType: string; + parameterTypes: readonly [string, string]; + }, +) { + const filePath = 'Hierarchy.java'; + const parameterTypes = conversion?.parameterTypes ?? ['Long', 'String']; + const source = `${declarations.join('\n')} + class Owner ${bases.length === 0 ? '' : `implements ${bases.join(', ')}`} { + public void pick(${parameterTypes[0]} value) {} + public void pick(${parameterTypes[1]} value) {} + void run(${conversion === undefined ? '' : `${conversion.argumentType} value`}) { + pick(${conversion === undefined ? '1L' : 'value'}); + } + }`; + const facts = captureJavaOverloadFacts(getJavaParser().parse(source).rootNode)!; + let baseReads = 0; + for (const type of facts.types) { + const declaredBases = type.bases; + Object.defineProperty(type, 'bases', { + get() { + baseReads++; + return declaredBases; + }, + }); + } + const owners: SymbolDefinition[] = facts.types.map((type) => ({ + nodeId: `${type.kind === 'interface_declaration' ? 'Interface' : 'Class'}:${filePath}:${type.name}`, + filePath, + type: type.kind === 'interface_declaration' ? 'Interface' : 'Class', + qualifiedName: type.name, + declarationRange: type.range, + })); + const owner = owners.find((definition) => definition.qualifiedName === 'Owner')!; + const methods: SymbolDefinition[] = facts.methods + .filter((method) => method.name === 'pick') + .map((method, index) => ({ + nodeId: `Method:${filePath}:Owner.pick#${index}`, + filePath, + type: 'Method', + ownerId: owner.nodeId, + qualifiedName: 'Owner.pick', + declarationRange: method.range, + parameterCount: method.parameterTypes.length, + parameterTypes: [...method.parameterTypes], + })); + const classScope: Scope = { + id: 'class', + kind: 'Class', + parent: 'module', + filePath, + range: owner.declarationRange!, + ownedDefs: [owner, ...methods], + imports: [], + bindings: new Map(), + typeBindings: new Map(), + }; + const moduleScope: Scope = { ...classScope, id: 'module', kind: 'Module', parent: null }; + const definitions = [...owners, ...methods]; + const scopes = { + scopeTree: buildScopeTree([moduleScope, classScope]), + defs: buildDefIndex(definitions), + qualifiedNames: buildQualifiedNameIndex(definitions), + // Only Owner declares methods. The captured bases supply the independent + // hierarchy-completeness proof; no inherited-member lookup is needed. + methodDispatch: buildMethodDispatchIndex({ + owners: owners.map((definition) => definition.nodeId), + computeMro: () => [], + implementsOf: () => [], + }), + } as ScopeResolutionIndexes; + const model = createSemanticModel(); + for (const method of methods) model.methods.register(owner.nodeId, 'pick', method); + setJavaOverloadFacts(filePath, facts); + const call = facts.calls.find((site) => site.name === 'pick')!; + return { + baseReads: () => baseReads, + resolve: () => + resolveJavaReceiverMember( + owner, + 'pick', + { + inScope: classScope.id, + kind: 'call', + callForm: 'member', + name: 'pick', + atRange: call.range, + arity: 1, + }, + scopes, + model, + ), + }; +} + +afterEach(clearJavaOverloadFacts); + +describe('Java overload fact lookup work', () => { + it.each(['none', 'method', 'nested'])( + 'bounds declaration reads across independent families (generics=%s)', + (generics) => { + const size = 64; + const generic = generics !== 'none'; + const filePath = 'Wide.java'; + const source = `class Wide${generics === 'nested' ? '' : ''} { ${Array.from( + { length: size }, + (_, i) => ` + void pick${i}(Object value) {} + void pick${i}(String value) {} + ${generic ? '' : ''} void run${i}(${generic ? 'String value' : ''}) { pick${i}(${generic ? 'value' : '"text"'}); } + `, + ).join('\n')} }`; + const facts = captureJavaOverloadFacts(getJavaParser().parse(source).rootNode)!; + const owner: SymbolDefinition = { + nodeId: `Class:${filePath}:Wide`, + filePath, + type: 'Class', + qualifiedName: 'Wide', + declarationRange: facts.types[0]!.range, + }; + const methods: SymbolDefinition[] = facts.methods + .filter((fact) => fact.name.startsWith('pick')) + .map((fact) => ({ + nodeId: `Method:${filePath}:Wide.${fact.name}#1~${fact.parameterTypes[0]}`, + filePath, + type: 'Method', + ownerId: owner.nodeId, + qualifiedName: `Wide.${fact.name}`, + declarationRange: fact.range, + parameterCount: 1, + parameterTypes: [...fact.parameterTypes], + })); + const classScope: Scope = { + id: 'class', + kind: 'Class', + parent: 'module', + filePath, + range: owner.declarationRange!, + ownedDefs: [owner, ...methods], + imports: [], + bindings: new Map(), + typeBindings: new Map(), + }; + const definitions = [owner, ...methods]; + const scopes = { + scopeTree: buildScopeTree([ + { ...classScope, id: 'module', kind: 'Module', parent: null }, + classScope, + ]), + defs: buildDefIndex(definitions), + qualifiedNames: buildQualifiedNameIndex(definitions), + methodDispatch: buildMethodDispatchIndex({ + owners: [owner.nodeId], + computeMro: () => [], + implementsOf: () => [], + }), + } as ScopeResolutionIndexes; + const model = createSemanticModel(); + for (const method of methods) + model.methods.register(owner.nodeId, method.qualifiedName!.split('.')[1]!, method); + setJavaOverloadFacts(filePath, facts); + let reads = 0; + for (const fact of facts.methods) + for (const key of ['name', 'range', 'typeParameters'] as const) { + const value = fact[key]; + Object.defineProperty(fact, key, { + get() { + reads++; + return value; + }, + }); + } + for (const call of facts.calls) { + expect( + resolveJavaReceiverMember( + owner, + call.name, + { + inScope: 'class', + kind: 'call', + callForm: 'member', + name: call.name, + atRange: call.range, + arity: 1, + }, + scopes, + model, + ), + ).toMatchObject( + generic + ? { kind: 'unknown' } + : { kind: 'resolved', definition: { parameterTypes: ['String'] } }, + ); + } + expect(reads).toBeLessThanOrEqual(facts.methods.length * 20); + }, + ); +}); + +describe('Java overload families with synthetic signature metadata', () => { + it.each([ + { label: 'missing', types: undefined }, + { label: 'empty', types: [] }, + { label: 'inconsistent', types: ['int', 'int'] }, + ])( + 'retains singleton compatibility with $label types for one declared parameter', + ({ types }) => { + expect(resolveSynthetic([{ count: 1, types }])).toBeUndefined(); + }, + ); + + it('keeps an overloaded family unknown when one formal signature is unavailable', () => { + expect(resolveSynthetic([{ count: 1 }, { count: 1, types: ['int'] }])).toMatchObject({ + kind: 'unknown', + candidateSetComplete: true, + }); + }); + + it('still rejects a call whose arity differs from the known declaration arity', () => { + expect(resolveSynthetic([{ count: 1 }], 0)).toMatchObject({ kind: 'inapplicable' }); + }); +}); + +describe('Java inherited overload inventory bounds', () => { + it.each([12, 16])( + 'checks a depth-%i diamond without expanding every inheritance path', + (depth) => { + const declarations = ['interface A0 {}', 'interface B0 {}']; + for (let level = 1; level <= depth; level++) { + declarations.push( + `interface A${level} extends A${level - 1}, B${level - 1} {}`, + `interface B${level} extends A${level - 1}, B${level - 1} {}`, + ); + } + const fixture = hierarchyFixture(declarations, [`A${depth}`, `B${depth}`]); + expect(fixture.resolve()).toMatchObject({ + kind: 'resolved', + definition: { parameterTypes: ['Long'] }, + }); + expect(fixture.baseReads()).toBeLessThanOrEqual((declarations.length + 1) * 2); + }, + ); + + it.each([ + ['C1', 'C63'], + ['C63', 'C1'], + ])('does not reuse a shallow proof past the depth bound (%s, %s)', (first, second) => { + const declarations = ['interface C0 {}']; + for (let level = 1; level <= 63; level++) + declarations.push(`interface C${level} extends C${level - 1} {}`); + const fixture = hierarchyFixture(declarations, [first, second]); + expect(fixture.resolve()).toMatchObject({ + kind: 'unknown', + candidateSetComplete: false, + }); + }); + + it('retains cyclic-hierarchy uncertainty across repeated inventory requests', () => { + const fixture = hierarchyFixture( + ['interface Left extends Right {}', 'interface Right extends Left {}'], + ['Left'], + ); + for (let attempt = 0; attempt < 2; attempt++) + expect(fixture.resolve()).toMatchObject({ kind: 'unknown', candidateSetComplete: false }); + }); +}); + +describe('Java source subtype conversion bounds', () => { + const conversion = { argumentType: 'Argument', parameterTypes: ['Unrelated', 'Object'] as const }; + + it.each([12, 16])( + 'checks an unrelated formal through a depth-%i diamond once per type', + (depth) => { + const declarations = ['interface A0 {}', 'interface B0 {}']; + for (let level = 1; level <= depth; level++) { + declarations.push( + `interface A${level} extends A${level - 1}, B${level - 1} {}`, + `interface B${level} extends A${level - 1}, B${level - 1} {}`, + ); + } + declarations.push(`class Argument implements A${depth}, B${depth} {}`, 'class Unrelated {}'); + const fixture = hierarchyFixture(declarations, [], conversion); + expect(fixture.resolve()).toMatchObject({ + kind: 'resolved', + definition: { parameterTypes: ['Object'] }, + }); + expect(fixture.baseReads()).toBeLessThanOrEqual((declarations.length + 1) * 2); + }, + ); + + it.each([ + ['C1', 'C63'], + ['C63', 'C1'], + ])( + 'does not reuse a shallow subtype rejection beyond the depth bound (%s, %s)', + (first, second) => { + const declarations = ['interface C0 {}']; + for (let level = 1; level <= 63; level++) + declarations.push(`interface C${level} extends C${level - 1} {}`); + declarations.push(`class Argument implements ${first}, ${second} {}`, 'class Unrelated {}'); + const fixture = hierarchyFixture(declarations, [], conversion); + expect(fixture.resolve()).toMatchObject({ kind: 'unknown', candidateSetComplete: true }); + }, + ); + + it('retains cyclic subtype uncertainty instead of selecting the Object fallback', () => { + const fixture = hierarchyFixture( + [ + 'interface Left extends Right {}', + 'interface Right extends Left {}', + 'class Argument implements Left {}', + 'class Unrelated {}', + ], + [], + conversion, + ); + for (let attempt = 0; attempt < 2; attempt++) + expect(fixture.resolve()).toMatchObject({ kind: 'unknown', candidateSetComplete: true }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/java/java-overload-types.test.ts b/gitnexus/test/unit/scope-resolution/java/java-overload-types.test.ts new file mode 100644 index 000000000..a5f3648db --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/java/java-overload-types.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { + javaInvocationConversion, + javaSubtype, + selectJavaOverload, + type JavaType, + type JavaPrimitive, + type JavaOverloadCandidate, + type JavaTypeRelations, +} from '../../../../src/core/ingestion/languages/java/overload-types.js'; + +const relations: JavaTypeRelations = { sourceSubtype: () => 'unknown' }; +function type(name: string): JavaType { + if (name === '?') return { kind: 'unknown' }; + if (name === 'null') return { kind: 'null' }; + if (name.endsWith('[]')) return { kind: 'array', component: type(name.slice(0, -2)) }; + if (['boolean', 'byte', 'short', 'char', 'int', 'long', 'float', 'double'].includes(name)) + return { kind: 'primitive', name: name as JavaPrimitive }; + return { + kind: 'reference', + name: name.includes('.') ? name : `java.lang.${name}`, + arguments: [], + }; +} +function select(signatures: readonly (readonly string[])[], arguments_: readonly string[]) { + const candidates: JavaOverloadCandidate[] = signatures.map((parameters) => ({ + definition: parameters.join(','), + parameters: parameters.map((parameter) => type(parameter.replace('...', '[]'))), + variadic: parameters.at(-1)?.endsWith('...') ?? false, + })); + return selectJavaOverload( + candidates, + arguments_.length, + (index, target, phase) => + javaInvocationConversion(type(arguments_[index]!), target, phase, relations), + relations, + ); +} + +describe('Java overload invocation phases and specificity (JLS 15.12.2)', () => { + it.each(['constructor', '__proto__', 'toString'])( + 'treats external name %s as unknown instead of consulting object prototypes', + (name) => { + expect( + javaSubtype({ kind: 'reference', name, arguments: [] }, type('Number'), relations), + ).toBe('unknown'); + }, + ); + it('returns unknown for adversarial type depth instead of overflowing the stack', () => { + let left: JavaType = type('int'); + let right: JavaType = type('int'); + for (let index = 0; index < 12_000; index++) { + left = { kind: 'array', component: left }; + right = { kind: 'array', component: right }; + } + expect(javaSubtype(left, right, relations)).toBe('unknown'); + }); + it('keeps override-equivalent preferred declaration selection unknown rather than ambiguous', () => { + expect(select([['String'], ['String'], ['Object']], ['String']).kind).toBe('unknown'); + expect(select([['int'], ['int']], ['int']).kind).toBe('unknown'); + }); + it.each([ + [[['String'], ['Long']], ['String'], 'String', 'strict'], + [[['long'], ['Integer']], ['int'], 'long', 'strict'], + [[['Object'], ['int']], ['Integer'], 'Object', 'strict'], + [[['long']], ['Integer'], 'long', 'loose'], + [[['String'], ['Object']], ['null'], 'String', 'strict'], + [[['Object'], ['String...']], ['String'], 'Object', 'strict'], + [[['Object'], ['String...']], ['String[]'], 'String...', 'strict'], + [[['Integer'], ['int...']], ['int'], 'Integer', 'loose'], + [[['Object...'], ['String...']], [], 'String...', 'variable'], + [[['Object'], ['Object[]']], ['int[][]'], 'Object[]', 'strict'], + ] as const)( + 'selects %s for %s without registration-order dependence', + (signatures, arguments_, winner, phase) => { + for (const order of [signatures, [...signatures].reverse()]) { + expect(select(order, arguments_)).toMatchObject({ + kind: 'resolved', + definition: winner, + phase, + }); + } + }, + ); + + it.each([ + [[['Long']], ['int']], + [[['byte']], ['int']], + [[['long[]']], ['int[]']], + [[['Object[]']], ['int[]']], + [[['boolean']], ['int']], + ] as const)('rejects invocation conversion %s for %s', (signatures, arguments_) => { + expect(select(signatures, arguments_).kind).toBe('inapplicable'); + }); + + it.each([ + [[['String'], ['Long']], ['null']], + [ + [ + ['int', 'Object'], + ['long', 'String'], + ], + ['int', 'String'], + ], + ] as const)( + 'preserves incomparable applicable signatures %s for %s', + (signatures, arguments_) => { + expect(select(signatures, arguments_).kind).toBe('ambiguous'); + }, + ); + + it('does not treat an unknown argument as null or a compatible wildcard', () => { + expect(select([['String'], ['Long']], ['?']).kind).toBe('unknown'); + }); + + it('does not jump past an uncertain strict-phase candidate', () => { + const candidates: JavaOverloadCandidate[] = [ + { definition: 'unknown reference', parameters: [{ kind: 'unknown' }], variadic: false }, + { definition: 'boxed', parameters: [type('Integer')], variadic: false }, + ]; + expect( + selectJavaOverload( + candidates, + 1, + (_index, target, phase) => javaInvocationConversion(type('int'), target, phase, relations), + relations, + ).kind, + ).toBe('unknown'); + }); + + it('preserves invariant generic arguments across List to Collection widening', () => { + const list = { kind: 'reference' as const, name: 'java.util.List', arguments: [type('Long')] }; + expect( + javaSubtype( + list, + { kind: 'reference', name: 'java.util.Collection', arguments: [type('Long')] }, + relations, + ), + ).toBe('yes'); + expect( + javaSubtype( + list, + { kind: 'reference', name: 'java.util.Collection', arguments: [type('Number')] }, + relations, + ), + ).toBe('no'); + expect( + javaSubtype( + list, + { kind: 'reference', name: 'java.util.Collection', arguments: [{ kind: 'unknown' }] }, + relations, + ), + ).toBe('unknown'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/provider-call-outcome.test.ts b/gitnexus/test/unit/scope-resolution/provider-call-outcome.test.ts new file mode 100644 index 000000000..216e952ef --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/provider-call-outcome.test.ts @@ -0,0 +1,596 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + type ParsedFile, + type ReferenceSite, + type Scope, + type SymbolDefinition, + type TypeRef, +} from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; +import type { + ReceiverMemberResolution, + ScopeResolver, +} from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js'; +import { buildGraphNodeLookup } from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; +import { emitFreeCallFallback } from '../../../src/core/ingestion/scope-resolution/passes/free-call-fallback.js'; +import { emitReceiverBoundCalls } from '../../../src/core/ingestion/scope-resolution/passes/receiver-bound-calls.js'; +import { applyPreciseCallResultBindings } from '../../../src/core/ingestion/scope-resolution/pipeline/run.js'; +import { createCalleeIdAccumulator } from '../../../src/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.js'; +import { + callSuppressionIdentities, + providerCallOutcome, + selectedCallKey, +} from '../../../src/core/ingestion/scope-resolution/passes/provider-call-outcome.js'; +import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; +import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js'; + +function fixture( + extraDefinitions: readonly SymbolDefinition[] = [], + ownerType: 'Class' | 'Interface' = 'Class', + ownerFilePath = 'owner.ts', +) { + const caller: SymbolDefinition = { + nodeId: 'def:caller', + filePath: 'caller.ts', + type: 'Function', + qualifiedName: 'main', + }; + const first: SymbolDefinition = { + nodeId: 'def:first', + filePath: 'first.ts', + type: 'Function', + qualifiedName: 'choose', + }; + const second: SymbolDefinition = { + nodeId: 'def:second', + filePath: 'second.ts', + type: 'Function', + qualifiedName: 'choose', + }; + const owner: SymbolDefinition = { + nodeId: 'def:owner', + filePath: ownerFilePath, + type: ownerType, + qualifiedName: 'Owner', + }; + const member: SymbolDefinition = { + nodeId: 'def:member', + filePath: ownerFilePath, + type: 'Method', + qualifiedName: 'Owner.choose', + ownerId: owner.nodeId, + parameterCount: 1, + parameterTypes: ['int'], + }; + const defs = [caller, first, second, owner, member, ...extraDefinitions]; + const moduleScope: Scope = { + id: 'scope:module', + parent: null, + kind: 'Module', + filePath: 'caller.ts', + range: { startLine: 0, startCol: 0, endLine: 30, endCol: 0 }, + ownedDefs: [], + imports: [], + typeBindings: new Map(), + bindings: new Map(), + }; + const scope: Scope = { + id: 'scope:caller', + parent: moduleScope.id, + kind: 'Function', + filePath: 'caller.ts', + range: { startLine: 1, startCol: 0, endLine: 20, endCol: 0 }, + ownedDefs: [caller], + imports: [], + typeBindings: new Map(), + bindings: new Map([['choose', [{ def: first, origin: 'local' }]]]), + }; + const site: ReferenceSite = { + inScope: scope.id, + name: 'choose', + kind: 'call', + callForm: 'free', + arity: 1, + atRange: { startLine: 3, startCol: 2, endLine: 3, endCol: 10 }, + }; + const parsed: ParsedFile = { + filePath: scope.filePath, + moduleScope: moduleScope.id, + scopes: [moduleScope, scope], + localDefs: defs, + parsedImports: [], + referenceSites: [site], + }; + const scopes = { + scopeTree: buildScopeTree([moduleScope, scope]), + defs: buildDefIndex(defs), + qualifiedNames: buildQualifiedNameIndex(defs), + moduleScopes: buildModuleScopeIndex([ + { filePath: scope.filePath, moduleScopeId: moduleScope.id }, + ]), + methodDispatch: buildMethodDispatchIndex({ + owners: [], + computeMro: () => [], + implementsOf: () => [], + }), + imports: new Map(), + bindings: new Map(), + bindingAugmentations: new Map(), + workspaceFqnBindings: new Map(), + workspaceTypeBindings: new Map(), + namespaceFqnBindings: new Map(), + namespaceTypeBindings: new Map(), + accessibleNamespacesByScope: new Map(), + referenceSites: [site], + sccs: [], + } as unknown as ScopeResolutionIndexes; + const graph = createKnowledgeGraph(); + for (const [definition, id] of [ + [caller, 'Function:caller.ts:main'], + [first, 'Function:first.ts:choose'], + [second, 'Function:second.ts:choose'], + [owner, 'Class:owner.ts:Owner'], + [member, 'Method:owner.ts:Owner.choose#1'], + ...extraDefinitions.map((definition) => [definition, `graph:${definition.nodeId}`] as const), + ] as const) { + graph.addNode({ + id, + label: definition.type, + properties: { + name: definition.qualifiedName!.split('.').at(-1)!, + qualifiedName: definition.qualifiedName, + filePath: definition.filePath, + parameterCount: definition.parameterCount, + parameterTypes: definition.parameterTypes, + }, + }); + } + const nodeLookup = buildGraphNodeLookup(graph); + const model = createSemanticModel(); + const outcomes: ResolutionOutcome[] = []; + const handled = new Set(); + const context = { graph, scopes, nodeLookup, model }; + const run = ( + decision: ReceiverMemberResolution | undefined, + selectedCalls?: Map, + ) => { + emitFreeCallFallback( + graph, + scopes, + [parsed], + nodeLookup, + { bySourceScope: new Map() }, + handled, + model, + buildWorkspaceResolutionIndex([parsed]), + { + resolveUnqualifiedCall: () => decision, + recordResolutionOutcome: (o) => outcomes.push(o), + selectedCalls, + }, + ); + return graph.relationships.filter((relation) => relation.type === 'CALLS'); + }; + return { + context, + site, + member, + owner, + first, + second, + outcomes, + handled, + run, + parsed, + scope, + moduleScope, + }; +} + +function receiverFixture() { + const owners: SymbolDefinition[] = ['First', 'Second'].map((name) => ({ + nodeId: `def:${name}`, + filePath: 'caller.ts', + type: 'Class', + qualifiedName: name, + })); + const implementations = owners.map( + (owner): SymbolDefinition => ({ + nodeId: `${owner.nodeId}.choose`, + filePath: owner.filePath, + type: 'Method', + qualifiedName: `${owner.qualifiedName}.choose`, + ownerId: owner.nodeId, + parameterCount: 1, + parameterTypes: ['int'], + }), + ); + const f = fixture([...owners, ...implementations], 'Interface', 'caller.ts'); + const site: ReferenceSite = { + ...f.site, + callForm: 'member', + explicitReceiver: { name: 'service' }, + }; + const scope: Scope = { + ...f.scope, + bindings: new Map([['Owner', [{ def: f.owner, origin: 'local' }]]]), + typeBindings: new Map([ + [ + 'service', + { + rawName: 'Owner', + declaredAtScope: f.scope.id, + source: 'parameter-annotation', + }, + ], + ]), + }; + const parsed = { + ...f.parsed, + scopes: [f.moduleScope, scope], + referenceSites: [site], + }; + const scopes = { + ...f.context.scopes, + scopeTree: buildScopeTree(parsed.scopes), + referenceSites: [site], + }; + const { graph, model, nodeLookup } = f.context; + model.methods.register(f.owner.nodeId, 'choose', f.member); + for (const implementation of implementations) { + model.methods.register(implementation.ownerId!, 'choose', implementation); + } + for (const owner of owners) { + graph.addRelationship({ + id: `implements:${owner.nodeId}`, + sourceId: `graph:${owner.nodeId}`, + targetId: 'Class:owner.ts:Owner', + type: 'IMPLEMENTS', + confidence: 1, + reason: 'test', + }); + } + const run = ( + resolution: ReceiverMemberResolution, + dispatch?: ScopeResolver['resolveSelectedMethodImplementation'], + ) => { + emitReceiverBoundCalls( + graph, + scopes, + [parsed], + nodeLookup, + f.handled, + { + isSuperReceiver: () => false, + arityCompatibility: () => 'compatible', + resolveReceiverMember: () => resolution, + resolveSelectedMethodImplementation: dispatch, + }, + buildWorkspaceResolutionIndex([parsed]), + model, + { recordResolutionOutcome: (outcome) => f.outcomes.push(outcome) }, + ); + return graph.relationships.filter((edge) => edge.type === 'CALLS'); + }; + return { ...f, owners, implementations, run }; +} + +describe('provider call outcomes', () => { + it('distinguishes nested calls that share their starting position', () => { + const f = fixture(); + expect(selectedCallKey('caller.ts', f.site.atRange)).not.toBe( + selectedCallKey('caller.ts', { + ...f.site.atRange, + endCol: f.site.atRange.endCol + 4, + }), + ); + }); + + it('uses the selected primary return even when runtime dispatch has several targets', () => { + const f = fixture(); + const parsed = { + ...f.parsed, + callResultAssignmentSites: [{ callSite: f.site.atRange, inScope: f.scope.id, lhs: 'result' }], + }; + const returned: TypeRef = { + rawName: 'Owner', + declaredAtScope: 'scope:declaration', + source: 'return-annotation', + }; + const index = { + ...buildWorkspaceResolutionIndex([parsed]), + declaredReturnTypeByCallableId: new Map([[f.first.nodeId, returned]]), + }; + const calleeIds = createCalleeIdAccumulator(); + calleeIds.add('caller.ts', 3, 2, 'Function:first.ts:choose'); + calleeIds.add('caller.ts', 3, 2, 'Function:second.ts:choose'); + const selected = new Map([[selectedCallKey('caller.ts', f.site.atRange), f.first]]); + const update = () => + applyPreciseCallResultBindings( + [parsed], + f.context.scopes, + index, + calleeIds, + f.context.nodeLookup, + f.context.model, + true, + selected, + ); + expect(update()).toBe(1); + expect(f.scope.typeBindings.get('result')).toMatchObject({ + rawName: 'Owner', + declaredAtScope: 'scope:declaration', + }); + expect(update()).toBe(0); + }); + + it.each([undefined, null])( + 'clears an inferred assignment before an unselected producer can leak a target (%s)', + (primary) => { + const f = fixture(); + const parsed = { + ...f.parsed, + callResultAssignmentSites: [ + { callSite: f.site.atRange, inScope: f.scope.id, lhs: 'result' }, + ], + }; + (f.scope.typeBindings as Map).set('result', { + rawName: 'WrongReturn', + declaredAtScope: f.scope.id, + source: 'assignment-inferred', + }); + const selected = new Map(); + if (primary === null) selected.set(selectedCallKey('caller.ts', f.site.atRange), null); + const update = () => + applyPreciseCallResultBindings( + [parsed], + f.context.scopes, + buildWorkspaceResolutionIndex([parsed]), + createCalleeIdAccumulator(), + f.context.nodeLookup, + f.context.model, + true, + selected, + ); + expect(update()).toBe(1); + expect(f.scope.typeBindings.has('result')).toBe(false); + expect(update()).toBe(0); + }, + ); + + it('keeps scope identities separate from graph method, owner and caller identities', () => { + const f = fixture(); + const result = providerCallOutcome( + { + kind: 'unknown', + candidateIds: [f.member.nodeId], + candidateSetComplete: true, + }, + f.site, + 'caller.ts', + 'receiver-bound-calls', + f.context, + ); + expect(result).toMatchObject({ + kind: 'suppressed', + reason: 'overload-unknown', + siteKind: 'call', + candidateIds: ['def:member'], + candidateGraphIds: ['Method:owner.ts:Owner.choose#1'], + candidateOwnerIds: ['Class:owner.ts:Owner'], + callerId: 'Function:caller.ts:main', + candidateSetComplete: true, + }); + }); + + it('retains known candidates and reports incomplete identity coverage', () => { + const f = fixture(); + const result = callSuppressionIdentities([f.member.nodeId, 'def:missing'], f.site, f.context); + expect(result.candidateGraphIds).toEqual(['Method:owner.ts:Owner.choose#1']); + expect(result.candidateSetComplete).toBe(false); + }); + + it('prefers scope definitions, then the first supplied definition, then the model', () => { + const f = fixture(); + f.context.model.symbols.add('second.ts', 'choose', 'def:supplied', 'Function', { + qualifiedName: 'choose', + }); + f.context.model.symbols.add('second.ts', 'choose', 'def:model', 'Function', { + qualifiedName: 'choose', + }); + const result = callSuppressionIdentities( + [f.member.nodeId, 'def:supplied', 'def:model', 'def:supplied'], + f.site, + f.context, + true, + [ + { ...f.first, nodeId: f.member.nodeId }, + { ...f.first, nodeId: 'def:supplied' }, + { ...f.second, nodeId: 'def:supplied' }, + ], + ); + expect(result).toMatchObject({ + candidateGraphIds: [ + 'Method:owner.ts:Owner.choose#1', + 'Function:first.ts:choose', + 'Function:second.ts:choose', + ], + candidateOwnerIds: ['Class:owner.ts:Owner'], + callerId: 'Function:caller.ts:main', + candidateSetComplete: true, + }); + }); + + it.each([256, 512])('bounds identity lookup work for %i supplied candidates', (size) => { + let reads = 0; + const ids = Array.from({ length: size }, (_, i) => `def:candidate:${i}`); + const definitions = ids.map( + (id, i): SymbolDefinition => ({ + get nodeId() { + reads++; + return id; + }, + filePath: `candidate${i}.ts`, + type: 'Function', + qualifiedName: 'choose', + }), + ); + const f = fixture(definitions); + const suppliedIds = new Set(ids); + const context = { + ...f.context, + scopes: { + ...f.context.scopes, + defs: buildDefIndex(f.parsed.localDefs.filter((def) => !suppliedIds.has(def.nodeId))), + }, + }; + reads = 0; + const result = callSuppressionIdentities(ids, f.site, context, true, definitions); + expect(reads).toBeLessThanOrEqual(size * 16); + expect(result.candidateGraphIds).toEqual(ids.map((id) => `graph:${id}`)); + expect(result.candidateSetComplete).toBe(true); + }); + + it.each(['unknown', 'ambiguous', 'inapplicable'] as const)( + 'does not resume lexical fallback after a provider %s decision', + (kind) => { + const f = fixture(); + expect( + f.run({ + kind, + candidateIds: [f.first.nodeId], + candidateSetComplete: true, + }), + ).toEqual([]); + expect(f.handled.has('caller.ts:3:2')).toBe(true); + expect(f.outcomes).toContainEqual( + expect.objectContaining({ + kind: 'suppressed', + candidateGraphIds: ['Function:first.ts:choose'], + callerId: 'Function:caller.ts:main', + candidateSetComplete: true, + reason: kind === 'ambiguous' ? 'member-lookup-ambiguous' : `overload-${kind}`, + }), + ); + }, + ); + + it('honors the selected declaration before a different lexical first match', () => { + const f = fixture(); + expect(f.run({ kind: 'resolved', definition: f.second }).map((edge) => edge.targetId)).toEqual([ + 'Function:second.ts:choose', + ]); + expect(f.outcomes).toContainEqual( + expect.objectContaining({ + kind: 'resolved', + phase: 'free-call-fallback', + targetId: f.second.nodeId, + }), + ); + }); + + it('preserves existing lookup when the provider declines handling', () => { + const f = fixture(); + const selected = new Map(); + expect(f.run(undefined, selected).map((edge) => edge.targetId)).toEqual([ + 'Function:first.ts:choose', + ]); + expect(selected.get(selectedCallKey('caller.ts', f.site.atRange))).toBe(f.first); + }); + + it('retains a partial boundary when a selected declaration has no graph identity', () => { + const f = fixture(); + expect( + f.run({ + kind: 'resolved', + definition: { + ...f.second, + nodeId: 'def:missing', + qualifiedName: 'missing', + }, + }), + ).toEqual([]); + expect(f.handled.has('caller.ts:3:2')).toBe(true); + expect(f.outcomes).toContainEqual( + expect.objectContaining({ + kind: 'suppressed', + reason: 'overload-unknown', + candidateSetComplete: false, + }), + ); + }); + + it.each(['unknown', 'ambiguous', 'inapplicable'] as const)( + 'does not emit a first member after a receiver %s decision', + (kind) => { + const f = receiverFixture(); + expect( + f.run({ + kind, + candidateIds: [f.member.nodeId], + candidateSetComplete: true, + }), + ).toEqual([]); + expect(f.handled.has('caller.ts:3:2')).toBe(true); + expect(f.outcomes).toContainEqual( + expect.objectContaining({ + kind: 'suppressed', + phase: 'receiver-bound-calls', + candidateSetComplete: true, + }), + ); + }, + ); + + it('preserves the selected declaration while dispatching its signature', () => { + const f = receiverFixture(); + const calls = f.run({ kind: 'resolved', definition: f.member }, (selected, owner) => { + expect(selected).toBe(f.member); + const implementation = f.implementations.find((item) => item.ownerId === owner.nodeId); + return implementation === undefined + ? undefined + : { kind: 'resolved', definition: implementation }; + }); + expect(calls.map((edge) => edge.targetId).sort()).toEqual( + [ + 'Method:owner.ts:Owner.choose#1', + ...f.implementations.map((item) => `graph:${item.nodeId}`), + ].sort(), + ); + expect(f.outcomes).toContainEqual( + expect.objectContaining({ + kind: 'resolved', + phase: 'receiver-bound-calls', + targetId: f.member.nodeId, + }), + ); + }); + + it('aggregates unresolved implementations separately from a resolved primary', () => { + const f = receiverFixture(); + const calls = f.run({ kind: 'resolved', definition: f.member }, (_selected, owner) => ({ + kind: 'unknown', + candidateSetComplete: true, + candidateIds: f.implementations + .filter((item) => item.ownerId === owner.nodeId) + .map((item) => item.nodeId), + })); + expect(calls.map((edge) => edge.targetId)).toEqual(['Method:owner.ts:Owner.choose#1']); + const dispatch = f.outcomes.filter((outcome) => outcome.phase === 'receiver-dispatch'); + expect(dispatch).toHaveLength(1); + expect(dispatch[0]).toMatchObject({ + kind: 'suppressed', + reason: 'overload-unknown', + candidateSetComplete: true, + candidateIds: f.implementations.map((item) => item.nodeId), + candidateOwnerIds: f.owners.map((owner) => `graph:${owner.nodeId}`), + }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/selected-call-propagation.test.ts b/gitnexus/test/unit/scope-resolution/selected-call-propagation.test.ts new file mode 100644 index 000000000..70de849c3 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/selected-call-propagation.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + type BindingRef, + type CallResultAssignmentSite, + type ParsedFile, + type ReferenceSite, + type Scope, + type SymbolDefinition, + type TypeRef, +} from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; +import { createCalleeIdAccumulator } from '../../../src/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.js'; +import { buildGraphNodeLookup } from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; +import { emitReceiverBoundCalls } from '../../../src/core/ingestion/scope-resolution/passes/receiver-bound-calls.js'; +import { + callResultBindingKey, + selectedCallKey, +} from '../../../src/core/ingestion/scope-resolution/passes/provider-call-outcome.js'; +import { applyPreciseCallResultBindings } from '../../../src/core/ingestion/scope-resolution/pipeline/run.js'; +import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; +import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js'; + +function chainFixture(reverse: boolean) { + const count = 24; + const filePath = 'chain.code'; + const caller: SymbolDefinition = { + nodeId: 'caller', + filePath, + type: 'Function', + qualifiedName: 'main', + }; + const owners: SymbolDefinition[] = Array.from({ length: count }, (_, i) => ({ + nodeId: `owner:${i}`, + filePath, + type: 'Class', + qualifiedName: `Owner${i}`, + })); + const methods: SymbolDefinition[] = owners.map((owner, i) => ({ + nodeId: `method:${i}`, + filePath, + type: 'Method', + ownerId: owner.nodeId, + qualifiedName: `${owner.qualifiedName}.${i + 1 < count ? `next${i}` : 'finish'}`, + parameterCount: 0, + parameterTypes: [], + })); + const module: Scope = { + id: 'module', + parent: null, + kind: 'Module', + filePath, + range: { startLine: 0, startCol: 0, endLine: 100, endCol: 0 }, + ownedDefs: owners, + imports: [], + typeBindings: new Map(), + bindings: new Map( + owners.map((owner) => [owner.qualifiedName!, [{ def: owner, origin: 'local' }]]), + ), + }; + const bindings = new Map(); + const typeBindings = new Map(); + for (let i = 0; i < count; i++) { + // The producer join must clear every speculative inferred binding except + // the explicitly typed chain root before reference resolution. + typeBindings.set(`value${i}`, { + rawName: `Owner${i}`, + declaredAtScope: module.id, + source: i === 0 ? 'parameter-annotation' : 'assignment-inferred', + }); + bindings.set(`value${i}`, [ + { + def: { nodeId: `value:${i}`, filePath, type: 'Variable', qualifiedName: `value${i}` }, + origin: 'local', + }, + ]); + } + const scope: Scope = { + id: 'function', + parent: module.id, + kind: 'Function', + filePath, + range: { startLine: 1, startCol: 0, endLine: 90, endCol: 0 }, + ownedDefs: [caller], + imports: [], + typeBindings, + bindings, + }; + const sites: ReferenceSite[] = methods.map((method, i) => ({ + inScope: scope.id, + kind: 'call', + callForm: 'member', + arity: 0, + name: method.qualifiedName!.split('.').at(-1)!, + explicitReceiver: { name: `value${i}` }, + atRange: { startLine: i + 2, startCol: 0, endLine: i + 2, endCol: 15 }, + })); + const assignments: CallResultAssignmentSite[] = sites.slice(0, -1).map((site, i) => ({ + callSite: site.atRange, + inScope: scope.id, + lhs: `value${i + 1}`, + })); + const defs = [caller, ...owners, ...methods]; + const parsed: ParsedFile = { + filePath, + moduleScope: module.id, + scopes: [module, scope], + localDefs: defs, + parsedImports: [], + referenceSites: reverse ? [...sites].reverse() : sites, + callResultAssignmentSites: assignments, + }; + const scopes = { + scopeTree: buildScopeTree(parsed.scopes), + defs: buildDefIndex(defs), + qualifiedNames: buildQualifiedNameIndex(defs), + moduleScopes: buildModuleScopeIndex([{ filePath, moduleScopeId: module.id }]), + methodDispatch: buildMethodDispatchIndex({ + owners: [], + computeMro: () => [], + implementsOf: () => [], + }), + imports: new Map(), + bindings: new Map(), + bindingAugmentations: new Map(), + workspaceFqnBindings: new Map(), + workspaceTypeBindings: new Map(), + namespaceFqnBindings: new Map(), + namespaceTypeBindings: new Map(), + accessibleNamespacesByScope: new Map(), + referenceSites: sites, + sccs: [], + } as unknown as ScopeResolutionIndexes; + const graph = createKnowledgeGraph(); + for (const def of defs) + graph.addNode({ + id: def.nodeId, + label: def.type, + properties: { + name: def.qualifiedName!.split('.').at(-1)!, + qualifiedName: def.qualifiedName, + filePath, + parameterCount: def.parameterCount, + parameterTypes: def.parameterTypes, + }, + }); + const nodeLookup = buildGraphNodeLookup(graph); + const model = createSemanticModel(); + for (const [i, method] of methods.entries()) + model.methods.register(owners[i]!.nodeId, sites[i]!.name, method); + const workspace = { + ...buildWorkspaceResolutionIndex([parsed]), + declaredReturnTypeByCallableId: new Map( + methods.slice(0, -1).map((method, i) => [ + method.nodeId, + { + rawName: `Owner${i + 1}`, + declaredAtScope: module.id, + source: 'return-annotation' as const, + }, + ]), + ), + }; + const selected = new Map(); + const pending = new Map(); + const outcomes: ResolutionOutcome[] = []; + const calleeIds = createCalleeIdAccumulator(); + const byProducer = new Map( + assignments.map((assignment) => [selectedCallKey(filePath, assignment.callSite), assignment]), + ); + const clearCount = applyPreciseCallResultBindings( + [parsed], + scopes, + workspace, + calleeIds, + nodeLookup, + model, + true, + selected, + ); + let selections = 0; + const run = (unknownRoot: boolean) => + emitReceiverBoundCalls( + graph, + scopes, + [parsed], + nodeLookup, + new Set(), + { + isSuperReceiver: () => false, + arityCompatibility: () => 'compatible', + resolveReceiverMember: (owner) => { + selections++; + const definition = methods.find((method) => method.ownerId === owner.nodeId)!; + return unknownRoot && owner.nodeId === owners[0]!.nodeId + ? { kind: 'unknown', candidateIds: [definition.nodeId], candidateSetComplete: true } + : { kind: 'resolved', definition }; + }, + }, + workspace, + model, + { + selectedCalls: selected, + pendingCallReceivers: pending, + callResultProducers: new Map( + assignments.map((assignment) => [ + callResultBindingKey(scope.id, assignment.lhs), + assignment, + ]), + ), + onSelectedCall: (key) => { + const assignment = byProducer.get(key); + if (assignment === undefined) return; + // Deliver the exact dependency slice to the same return-binding join + // used by the orchestrator; no workspace scan per selected producer. + applyPreciseCallResultBindings( + [{ ...parsed, callResultAssignmentSites: [assignment] }], + scopes, + workspace, + calleeIds, + nodeLookup, + model, + true, + selected, + ); + }, + recordResolutionOutcome: (outcome) => outcomes.push(outcome), + }, + ); + return { + count, + clearCount, + graph, + methods, + outcomes, + pending, + selected, + typeBindings, + run, + selections: () => selections, + }; +} + +describe('selected call dependency propagation', () => { + it.each([false, true])( + 'resolves a long assignment chain in one receiver pass (reverse=%s)', + (reverse) => { + const f = chainFixture(reverse); + expect(f.clearCount).toBe(f.count - 1); + f.run(false); + expect( + [...f.graph.iterRelationshipsByType('CALLS')].map((edge) => edge.targetId).sort(), + ).toEqual(f.methods.map((method) => method.nodeId).sort()); + expect(f.selections()).toBe(f.count); + expect(f.pending.size).toBe(0); + expect(f.typeBindings.get(`value${f.count - 1}`)?.declaredAtScope).toBe('module'); + }, + ); + + it.each([false, true])('keeps a long unknown-origin chain suppressed (reverse=%s)', (reverse) => { + const f = chainFixture(reverse); + f.run(true); + expect([...f.graph.iterRelationshipsByType('CALLS')]).toEqual([]); + expect(f.selections()).toBe(1); + expect(f.pending.size).toBe(f.count - 1); + expect(f.typeBindings.size).toBe(1); + expect(f.outcomes).toContainEqual( + expect.objectContaining({ reason: 'receiver-unresolved', name: 'finish' }), + ); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/unresolved-overloads.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-overloads.test.ts new file mode 100644 index 000000000..f976837c7 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/unresolved-overloads.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest'; +import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; +import { + MAX_OVERLOAD_IDENTITIES_PER_SITE, + MAX_OVERLOAD_SUMMARY_BYTES, + MAX_UNRESOLVED_OVERLOAD_SITES, + probeOverloadBoundaries, + summarizeUnresolvedOverloads, +} from '../../../src/core/ingestion/scope-resolution/unresolved-overloads.js'; + +type Suppressed = Extract; +const target = 'Method:src/Host.java:Host.find#1~String'; +const otherTarget = 'Method:src/Host.java:Host.find#1~Long'; +const owner = 'Class:src/Host.java:Host'; +const caller = 'Method:src/Caller.java:Caller.run#0'; + +function suppressed(overrides: Partial = {}): Suppressed { + return { + kind: 'suppressed', + phase: 'receiver-bound-calls', + filePath: 'src/Caller.java', + name: 'find', + range: { startLine: 5, startCol: 2, endLine: 5, endCol: 20 }, + reason: 'overload-ambiguous', + candidateIds: ['def:Host.find.String', 'def:Host.find.Long'], + candidateGraphIds: [target, otherTarget], + candidateOwnerIds: [owner], + callerId: caller, + candidateSetComplete: true, + siteKind: 'call', + ...overrides, + }; +} + +function resolved(outcome: Suppressed): Extract { + return { + kind: 'resolved', + phase: outcome.phase, + filePath: outcome.filePath, + name: outcome.name, + range: outcome.range, + targetId: target, + }; +} + +const probe = ( + summary: unknown, + ids: readonly string[], + direction: 'upstream' | 'downstream' | 'both' = 'upstream', +) => probeOverloadBoundaries(summary, new Set(ids), direction); + +describe('persisted unresolved overload coverage', () => { + it('records a measured-clean receipt, distinct from an absent receipt', () => { + const clean = summarizeUnresolvedOverloads([]); + expect(clean).toEqual({ + schemaVersion: 1, + complete: true, + totalSites: 0, + omittedSites: 0, + sites: [], + }); + expect(probe(clean, [target])).toEqual({ notes: [], sites: 0 }); + expect(probe(undefined, [target])).toMatchObject({ + sites: 0, + notes: [expect.stringContaining('not recorded')], + }); + expect(probe(summarizeUnresolvedOverloads([], false), [target]).notes).toEqual([ + expect.stringContaining('partial'), + ]); + }); + + it('matches public candidate, caller and declaring-owner IDs in their own directions', () => { + const summary = summarizeUnresolvedOverloads([suppressed()]); + expect(probe(summary, [target]).sites).toBe(1); + expect(probe(summary, [owner]).sites).toBe(1); + expect(probe(summary, [caller], 'downstream').sites).toBe(1); + expect(probe(summary, [caller], 'both').sites).toBe(1); + expect(probe(summary, [caller])).toEqual({ sites: 0, notes: [] }); + expect(probe(summary, [target], 'downstream')).toEqual({ sites: 0, notes: [] }); + expect(probe(summary, ['def:Host.find.String'])).toEqual({ sites: 0, notes: [] }); + expect(probe(summary, ['Class:src/Host.java:Unrelated'])).toEqual({ sites: 0, notes: [] }); + }); + + it('counts one site once when several candidates, owners or phases match', () => { + const outcome = suppressed(); + const summary = summarizeUnresolvedOverloads([ + outcome, + outcome, + suppressed({ + phase: 'receiver-dispatch', + reason: 'overload-unknown', + name: 'qualified.find', + }), + ]); + expect(summary.totalSites).toBe(1); + expect(probe(summary, [target, otherTarget, owner, caller], 'both').sites).toBe(1); + expect(summary.sites[0].reasons).toEqual(['overload-ambiguous', 'overload-unknown']); + }); + + it('reconciles resolved-after-suppressed only within the same decision phase', () => { + const primary = suppressed(); + expect(summarizeUnresolvedOverloads([primary, resolved(primary)]).sites).toEqual([]); + const dispatch = suppressed({ phase: 'receiver-dispatch', reason: 'overload-unknown' }); + const summary = summarizeUnresolvedOverloads([primary, dispatch, resolved(primary)]); + expect(summary.totalSites).toBe(1); + expect(summary.sites[0].reasons).toEqual(['overload-unknown']); + expect(probe(summary, [target]).sites).toBe(1); + }); + + it('uses the last decision in a phase and excludes proven inapplicable calls', () => { + const first = suppressed(); + const last = suppressed({ candidateGraphIds: [otherTarget], candidateIds: ['def:other'] }); + expect(probe(summarizeUnresolvedOverloads([first, last]), [target])).toEqual({ + sites: 0, + notes: [], + }); + expect( + summarizeUnresolvedOverloads([first, suppressed({ reason: 'overload-inapplicable' })]).sites, + ).toEqual([]); + expect(summarizeUnresolvedOverloads([resolved(first), first]).totalSites).toBe(1); + }); + + it('does not label property accesses or unrelated suppressions as missed calls', () => { + const summary = summarizeUnresolvedOverloads([ + suppressed({ reason: 'member-lookup-ambiguous', siteKind: 'read' }), + suppressed({ reason: 'member-lookup-ambiguous', siteKind: 'write' }), + suppressed({ reason: 'receiver-unresolved' }), + suppressed({ reason: 'selected-callable-deleted' }), + { ...resolved(suppressed()), kind: 'fallback-guessed', targetId: target }, + { + kind: 'reexport-ambiguous', + phase: 'exports', + filePath: 'src/barrel.ts', + name: 'find', + candidateIds: ['def:one', 'def:two'], + }, + ]); + expect(summary).toMatchObject({ complete: true, totalSites: 0, sites: [] }); + const oldMember = summarizeUnresolvedOverloads([ + suppressed({ reason: 'member-lookup-ambiguous', siteKind: undefined }), + ]); + expect(oldMember).toMatchObject({ complete: false, totalSites: 0 }); + expect(probe(oldMember, [target]).notes.join(' ')).toContain('partial'); + }); + + it.each([ + ['missing candidate identities', { candidateGraphIds: undefined }], + ['empty candidate identities', { candidateGraphIds: [] }], + ['missing caller identity', { callerId: undefined }], + ['missing candidate completeness', { candidateSetComplete: undefined }], + ['incomplete candidates', { candidateSetComplete: false }], + ])('does not certify absent keys with %s', (_description, overrides) => { + const summary = summarizeUnresolvedOverloads([suppressed(overrides)]); + expect(summary.complete).toBe(false); + expect(probe(summary, ['Method:elsewhere:unseen'])).toEqual({ + sites: 0, + notes: [expect.stringContaining('partial')], + }); + }); + + it('keeps known matches as measured floors in a partial summary', () => { + const summary = summarizeUnresolvedOverloads([suppressed({ candidateSetComplete: false })]); + expect(probe(summary, [target])).toEqual({ + sites: 1, + notes: [expect.stringContaining('1 call site'), expect.stringContaining('partial')], + }); + }); + + it('bounds site count and never treats an omitted candidate as measured zero', () => { + const outcomes = Array.from({ length: MAX_UNRESOLVED_OVERLOAD_SITES + 1 }, (_, index) => + suppressed({ + range: { startLine: index + 1, startCol: 0, endLine: index + 1, endCol: 1 }, + candidateIds: [`def:${index}`], + candidateGraphIds: [`Method:file:${index}`], + }), + ); + const summary = summarizeUnresolvedOverloads(outcomes); + expect(summary).toMatchObject({ + complete: false, + totalSites: outcomes.length, + omittedSites: 1, + }); + expect(summary.sites).toHaveLength(MAX_UNRESOLVED_OVERLOAD_SITES); + const retained = new Set(summary.sites.flatMap((site) => site.candidateIds)); + const omitted = outcomes.find((outcome) => !retained.has(outcome.candidateGraphIds![0]))!; + expect(probe(summary, omitted.candidateGraphIds!)).toEqual({ + sites: 0, + notes: [expect.stringContaining('partial')], + }); + }); + + it('bounds per-site identities and serialized bytes with explicit partial coverage', () => { + const many = Array.from( + { length: MAX_OVERLOAD_IDENTITIES_PER_SITE + 1 }, + (_, index) => `Method:file:${index}`, + ); + const refs = summarizeUnresolvedOverloads([suppressed({ candidateGraphIds: many })]); + expect(refs.complete).toBe(false); + expect(refs.sites[0].candidateIds).toHaveLength(MAX_OVERLOAD_IDENTITIES_PER_SITE); + const huge = summarizeUnresolvedOverloads([ + suppressed({ name: 'x'.repeat(MAX_OVERLOAD_SUMMARY_BYTES) }), + ]); + expect(huge).toMatchObject({ complete: false, omittedSites: 1, sites: [] }); + expect(Buffer.byteLength(JSON.stringify(huge))).toBeLessThan(MAX_OVERLOAD_SUMMARY_BYTES); + }); + + it('is deterministic and preserves identities through JSON including prototype-shaped strings', () => { + const one = suppressed({ candidateGraphIds: ['constructor', '__proto__', 'toString'] }); + const two = suppressed({ range: { startLine: 10, startCol: 2, endLine: 10, endCol: 20 } }); + const summary = summarizeUnresolvedOverloads([one, two]); + expect(summarizeUnresolvedOverloads([two, one])).toEqual(summary); + const restored = JSON.parse(JSON.stringify(summary)); + expect(probe(restored, ['__proto__', 'constructor'])).toEqual({ + sites: 1, + notes: [expect.stringContaining('1 call site')], + }); + }); + + it('supports caller-file filtering without counting excluded sites', () => { + const summary = summarizeUnresolvedOverloads([suppressed({ filePath: 'test/Caller.java' })]); + expect(probeOverloadBoundaries(summary, new Set([target]), 'upstream', () => false)).toEqual({ + notes: [], + sites: 0, + }); + }); + + it('hedges a capped traversal without inventing counts for unexamined targets', () => { + const summary = summarizeUnresolvedOverloads([suppressed()]); + expect( + probeOverloadBoundaries(summary, new Set(['unexamined']), 'upstream', () => true, false), + ).toEqual({ + sites: 0, + notes: [expect.stringContaining('traversal was incomplete')], + }); + expect( + probeOverloadBoundaries( + summarizeUnresolvedOverloads([]), + new Set([target]), + 'upstream', + () => true, + false, + ), + ).toEqual({ + sites: 0, + notes: [], + }); + }); + + it.each([ + null, + [], + { schemaVersion: 2, complete: true, totalSites: 0, omittedSites: 0, sites: [] }, + { schemaVersion: 1, complete: true, totalSites: -1, omittedSites: 0, sites: [] }, + { schemaVersion: 1, complete: true, totalSites: 1, omittedSites: 0, sites: [] }, + { schemaVersion: 1, complete: true, totalSites: 1, omittedSites: 1, sites: [] }, + { schemaVersion: 1, complete: 'true', totalSites: 0, omittedSites: 0, sites: [] }, + Object.create({ schemaVersion: 1, complete: true, totalSites: 0, omittedSites: 0, sites: [] }), + ])('treats malformed receipts as unverified: %j', (summary) => { + expect(probe(summary, [target])).toEqual({ + sites: 0, + notes: [expect.stringContaining('metadata is invalid')], + }); + }); + + it('rejects malformed, duplicated or forged site identities', () => { + const summary = summarizeUnresolvedOverloads([suppressed()]); + for (const sites of [ + [{ ...summary.sites[0], candidateIds: ['same', 'same'] }], + [{ ...summary.sites[0], id: 'invented' }], + [{ ...summary.sites[0], range: { startLine: 1, startCol: 0, endLine: 0, endCol: 0 } }], + [{ ...summary.sites[0], reasons: ['receiver-unresolved'] }], + [summary.sites[0], summary.sites[0]], + ]) { + expect( + probe({ ...summary, totalSites: sites.length, sites }, [target]).notes.join(' '), + ).toContain('metadata is invalid'); + } + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 8315e6e6f..e70c94929 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -115,6 +115,7 @@ export default defineConfig({ 'test/integration/api-impact-e2e.test.ts', 'test/integration/shape-check-regression.test.ts', 'test/integration/java-class-impact.test.ts', + 'test/integration/java-overload-impact-persistence.test.ts', 'test/integration/class-impact-all-languages.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', @@ -227,6 +228,7 @@ export default defineConfig({ 'test/integration/api-impact-e2e.test.ts', 'test/integration/shape-check-regression.test.ts', 'test/integration/java-class-impact.test.ts', + 'test/integration/java-overload-impact-persistence.test.ts', 'test/integration/class-impact-all-languages.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts',