fix(java): resolve overloads by JLS phases (#3791)

* docs(plans): add Java overload repair plan

* fix(java): resolve overloads by JLS phases

Select complete overload families before shared pruning so strict,
loose and variable-arity applicability preserve Java call targets.
Keep unsupported or ambiguous families terminal and persist their
omission boundaries so impact counts cannot imply complete coverage.

Preserve declared return types, synthetic members and bounded runtime
dispatch. Memoize inheritance walks to avoid exponential diamond work.

Fixes #3789

* refactor(java): simplify overload capture paths

Reuse record accessor captures and require suppression identity context.
Remove redundant cache-layout assertions and test-history comments.
Populate real overload receipts in the accuracy fixture so its exact
control matches the production metadata contract.

* docs: remove implementation plan from PR

Exclude the planning document from the published change.

* fix(java): honor enclosing protected access

* docs(mcp): qualify overload coverage (#3791)

* perf(java): bound overload ingestion costs

Index method facts and lexical bindings, and avoid repeated native child and callable-definition scans. Add measured cold, warm, and edited scaling checks at 128, 512, and 2048 units.

Refs #3789

* refactor(java): simplify overload lookup state

Index diagnostic candidates once to bound lookup work linearly,
preserving scope and first-match precedence. Keep local bindings
out of the field index and remove redundant metadata.

Refs #3789

* fix(java): correct replay diagnostics and CI

Repeated impact queries exhausted the persistence test's 128 MiB buffer
pool. Give the reader 512 MiB and reject partial results before checking
callers.

Coalesce receiver replay diagnostics by full source-site identity while
compacting resolved drops in one linear pass. This removes 51 duplicate
receipts without merging distinct calls at the same start position.

Refresh the Java capture fingerprints after attributing every difference
to return types, var initializers, and seven new fixture files. Keep the
1.5 scaling budget. Receiver calls move 140 -> 141: two new fixture sites
and one existing chain newly resolved. Shape states are unchanged; origin
counts reflect selected return types instead of earlier guesses.

Refs #3789

* ci(java): enforce overload scaling benchmark

Run the 54-analysis correctness and scaling check in the existing
scope-resolution benchmark job. Upload its report with that job's
measurements so regressions fail CI instead of relying on manual runs.

Refs #3789

* test(java): clarify overload coverage (#3791)

Separate unmodeled call-argument coverage from Java accessibility and
confirm the accessible Object overload with an executed javac oracle.

Give incomplete-attribution cases distinct titles: JSON formatting drops
undefined properties and made three test identities collide in CI.

* perf(scope): skip unmatched value names

Reject impossible callable names before parsing definition positions or
allocating positional keys. Keep the exact file/line/name admission and
ambiguity rules unchanged.

The callable-value-flow benchmark overhead drops from 1.747-1.883 locally
to 1.210-1.271, below the unchanged 1.9 budget. Target fingerprints and
cardinalities are identical.

Refs #3791

* fix(java): resolve types from static imports

Map package-qualified model names to scope-index keys before resolving
explicit static member-type imports. Preserve uncertainty for missing
or conflicting imports.

Verify Number selection after boxing long and preserve the imported
source type identity, with an executed javac oracle for both targets.

Refs #3791

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
Gergő Magyar 2026-11-01 11:08:00 +00:00 • committed by GitHub
parent 21298f2423
commit b5b4f13f2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 9002 additions and 263 deletions

View file

@ -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

View file

@ -432,6 +432,56 @@ Wire format is **v2**: `2|<base>|<step>|<step>…`, 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.

View file

@ -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.

View file

@ -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
}
}
}

View file

@ -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();

View file

@ -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,
"<<unclassified>>": 13,
"chain-unwrap": 1
},
"callDropsByOrigin": {
"in-program": 58,
"unknown": 49,
"external": 31,
"unknown": 64,
"in-program": 53,
"external": 22,
"<<unclassified>>": 2
}
}

View file

@ -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<T>()`) 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": {

View file

@ -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,

View file

@ -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',

View file

@ -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;

View file

@ -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<string, readonly SpringMessageProduce
/** Clear facts retained by a prior workspace pass in a long-lived process. */
export function clearJavaClassAnnotationFacts(): void {
clearJavaOverloadFacts();
classAnnotations.clear();
springAopFacts.clear();
springConfigConsumers.clear();
@ -157,6 +166,7 @@ export function collectJavaCaptureSideChannel(
filePath: string,
): JavaCaptureSideChannel | undefined {
const facts = classAnnotations.get(filePath);
const overloadFacts = getJavaOverloadFacts(filePath);
const aopFacts = springAopFacts.get(filePath) ?? [];
const configConsumers = springConfigConsumers.get(filePath) ?? [];
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
@ -166,6 +176,7 @@ export function collectJavaCaptureSideChannel(
const messageProducerFacts = springMessageProducerFacts.get(filePath) ?? [];
const packageFact = getJavaPackageFact(filePath);
if (
overloadFacts === undefined &&
facts.length === 0 &&
aopFacts.length === 0 &&
configConsumers.length === 0 &&
@ -180,6 +191,7 @@ export function collectJavaCaptureSideChannel(
}
return {
kind: 'java',
...(overloadFacts === undefined ? {} : { overloadFacts }),
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
classAnnotations: facts,
...(aopFacts.length > 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,

View file

@ -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),
];

View file

@ -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<string, JavaOverloadFileFacts>();
const callIndexes = new Map<string, ReadonlyMap<string, JavaOverloadCallFact>>();
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<number, JavaOverloadTypeFact>();
const typeNodesByFact = new Map<JavaOverloadTypeFact, SyntaxNode>();
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<string, Binding[]>();
const localBindings = new Map<number, Map<string, Binding[]>>();
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<string, Binding[]>();
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<string, readonly Binding[] | undefined>();
const fieldsFor = (
owner: SyntaxNode,
name: string,
inherited: boolean,
seen = new Set<number>(),
): 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<string, Binding>();
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))),
)
);
}

File diff suppressed because it is too large Load diff

View file

@ -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<JavaType, { kind: 'reference' }>;
export type JavaInvocationPhase = 'strict' | 'loose' | 'variable';
export interface JavaTypeRelations {
readonly sourceSubtype: (from: JavaReferenceType, to: JavaReferenceType) => JavaVerdict;
}
export interface JavaOverloadCandidate<T> {
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<T> =
| { 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<JavaPrimitive>([
'boolean',
'byte',
'short',
'char',
'int',
'long',
'float',
'double',
]);
const widening: Readonly<Record<JavaPrimitive, readonly JavaPrimitive[]>> = {
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<Record<JavaPrimitive, string>> = {
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<Record<string, readonly string[]>> = {
'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<T>(
candidate: JavaOverloadCandidate<T>,
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<T>(
left: JavaOverloadCandidate<T>,
right: JavaOverloadCandidate<T>,
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<T>(
candidates: readonly JavaOverloadCandidate<T>[],
arity: number,
argumentConversion: (index: number, target: JavaType, phase: JavaInvocationPhase) => JavaVerdict,
relations: JavaTypeRelations,
): JavaOverloadDecision<T> {
for (const phase of ['strict', 'loose', 'variable'] as const) {
const applicable: JavaOverloadCandidate<T>[] = [];
const uncertain: JavaOverloadCandidate<T>[] = [];
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;
}

View file

@ -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',

View file

@ -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. */

View file

@ -835,7 +835,7 @@ export function buildGraphTargetIndex(
graph: KnowledgeGraph,
): ReadonlyMap<string, Target> {
const out = new Map<string, Target>();
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>,
): 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<string, readonly string[]>;
readonly callableNames: ReadonlySet<string>;
/**
* 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<string, string[]>();
const callableByPosition = new Map<string, string>();
const callableNames = new Set<string>();
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 {

View file

@ -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<ScopeId, readonly Reference[]> },
_referenceIndex: {
readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]>;
},
handledSites: Set<string>,
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<string, SymbolDefinition | null>;
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<string>();
const outcomeContext: CallOutcomeContext = {
graph,
scopes,
nodeLookup,
model,
};
const recordSuppressed = (input: Parameters<typeof recordSuppressedOutcome>[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 }),
});
}

View file

@ -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<ResolutionOutcome, { kind: 'suppressed' }>,
'candidateGraphIds' | 'candidateOwnerIds' | 'callerId' | 'candidateSetComplete' | 'siteKind'
> {
const { graph, scopes, nodeLookup, model } = context;
const ids = [...new Set(candidateIds)];
const candidateGraphIds = new Set<string>();
const candidateOwnerIds = new Set<string>();
let candidateSetComplete = inventoryComplete;
let suppliedById: Map<string, SymbolDefinition> | undefined;
let byId: Map<string, SymbolDefinition> | 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<ReceiverMemberResolution, { kind: 'resolved' }>;
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,
),
};
}

View file

@ -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<string>;
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
readonly recordSourceBinding?: SourceBindingRecorder;
/** Compile-time primaries, separate from runtime fanout in calleeIdSink. */
readonly selectedCalls?: Map<string, SymbolDefinition | null>;
/** 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<string, string>;
readonly callResultProducers?: ReadonlyMap<string, CallResultAssignmentSite>;
/** 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<string>();
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<string, DispatchTarget>();
const unresolvedDispatchCandidates = new Set<string>();
const unresolvedDispatchOwners = new Set<string>();
let hasUnresolvedDispatch = false;
let dispatchCandidatesComplete = true;
const bestIncomingCount = new Map<string, number>([[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<string, string | Set<string>>();
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<string, Map<string, ReferenceSite>>();
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,
),
});
}

View file

@ -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<string, TypeRef>;
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<typeof buildWorkspaceResolutionIndex>,
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<typeof buildGraphNodeLookup>,
model: SemanticModel,
requireOwnedHoistedReturn = false,
selectedCalls?: ReadonlyMap<string, SymbolDefinition | null>,
): 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<string, { type: TypeRef; def: SymbolDefinition }>();
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<string, TypeRef>).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<string, SymbolDefinition | null>();
const pendingCallReceivers = selectedCalls === undefined ? undefined : new Map<string, string>();
const callResultProducers =
selectedCalls === undefined ? undefined : new Map<string, CallResultAssignmentSite>();
const assignmentsByProducer = new Map<string, CallResultAssignmentSite[]>();
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<string, number>();
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,

View file

@ -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;

View file

@ -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<string>([
'overload-ambiguous',
'overload-ambiguous-normalization',
'conversion-rank-tied',
'member-lookup-ambiguous',
'overload-unknown',
]);
type Suppressed = Extract<ResolutionOutcome, { kind: 'suppressed' }>;
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>): 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<string, Map<string, Suppressed>>();
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<string, unknown> {
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<string>();
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<string>,
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 };
}

View file

@ -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<string, SymbolDefinition[]>
>();
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;

View file

@ -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,

View file

@ -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<string>();
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: {

View file

@ -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.

View file

@ -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

View file

@ -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<Long, Perm> settle() {
return lookup.find(Arrays.asList(10L, 20L)); // -> find(Collection<Long>)
}
}

View file

@ -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<Long> vs Long)
public Map<Long, Perm> find(Collection<Long> ids) {
Map<Long, Perm> 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);
}
}

View file

@ -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<Long, Perm> listOrders() {
List<Long> ids = Arrays.asList(1L, 2L, 3L);
return lookup.find(ids); // -> find(Collection<Long>)
}
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)
}
}

View file

@ -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; }
}

View file

@ -0,0 +1,5 @@
package demo;
public class Req {
public String keyword;
}

View file

@ -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"; }
}

View file

@ -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 {}

View file

@ -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));

View file

@ -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,

View file

@ -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',

View file

@ -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);

View file

@ -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<ReturnType<typeof loadMeta>>);
});
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<string, unknown> = {}) =>
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<ReturnType<typeof loadMeta>>);
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<ReturnType<typeof loadMeta>>);
@ -263,6 +451,7 @@ withTestLbugDB(
);
vi.mocked(loadMeta).mockResolvedValue({
scopeExtractionReceipt: 1,
unresolvedOverloads: summarizeUnresolvedOverloads(outcomes),
unresolvedReceiverMembers: summary,
} as Awaited<ReturnType<typeof loadMeta>>);
@ -281,6 +470,7 @@ withTestLbugDB(
async (omittedNames) => {
vi.mocked(loadMeta).mockResolvedValue({
scopeExtractionReceipt: 1,
unresolvedOverloads: summarizeUnresolvedOverloads([]),
unresolvedReceiverMembers: { counts: {}, totalSites: 0, omittedNames },
} as Awaited<ReturnType<typeof loadMeta>>);
@ -297,6 +487,7 @@ withTestLbugDB(
async (name) => {
vi.mocked(loadMeta).mockResolvedValue({
scopeExtractionReceipt: 1,
unresolvedOverloads: summarizeUnresolvedOverloads([]),
unresolvedReceiverMembers: { counts: {}, totalSites: 3, omittedNames: 2 },
} as Awaited<ReturnType<typeof loadMeta>>);
@ -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<ReturnType<typeof loadMeta>>);
@ -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'],

View file

@ -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<typeof import('../../src/storage/repo-manager.js')>();
@ -34,6 +35,7 @@ withTestLbugDB(
lastCommit: 'abc123',
indexedAt: new Date(0).toISOString(),
scopeExtractionReceipt: 1 as const,
unresolvedOverloads: summarizeUnresolvedOverloads([]),
};
await saveMeta(handle.tmpHandle.dbPath, {
...baseMeta,

View file

@ -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,

View file

@ -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<Long>': ['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<string, Array<{ name: string }>>;
}
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<T>(action: 'analyze' | 'inspect'): Promise<T> {
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<string, ImpactReceipt>): 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<AnalysisReceipt>('analyze');
expect(cold.usedWorkerPool).toBe(true);
expect(cold.reparsed).toBe(5);
expect(cold.hits).toBe(0);
const coldResults = await child<Record<string, ImpactReceipt>>('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<AnalysisReceipt>('analyze');
expect(warm.usedWorkerPool).toBe(false);
expect(warm.reparsed).toBe(0);
expect(warm.hits).toBe(5);
const warmResults = await child<Record<string, ImpactReceipt>>('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<AnalysisReceipt>('analyze');
expect(unresolved.meta.unresolvedOverloads?.totalSites).toBeGreaterThan(0);
const uncertainResults = await child<Record<string, ImpactReceipt>>('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<AnalysisReceipt>('analyze');
expect(correction.reparsed).toBe(1);
expect(correction.hits).toBe(5);
const corrected = await child<Record<string, ImpactReceipt>>('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<AnalysisReceipt>('analyze');
expect(unresolvedAgain.meta.unresolvedOverloads?.totalSites).toBeGreaterThan(0);
await rm(unknownPath);
const deletion = await child<AnalysisReceipt>('analyze');
expect(deletion.meta.unresolvedOverloads).toMatchObject({
complete: true,
totalSites: 0,
omittedSites: 0,
sites: [],
});
const restored = await child<Record<string, ImpactReceipt>>('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);

View file

@ -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<Number> x) { return "collection"; } static String generic(Object x) { return "object"; }
static String primitive(List<Long> 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<Long> 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<Number> x) {} void choose(java.util.Collection<Integer> 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 });
}
});
});

View file

@ -12,6 +12,7 @@ import {
runPipelineFromRepo,
getRelationships,
getNodesByLabelFull,
getResolutionOutcomes,
} from './helpers.js';
import {
loadParseCache,
@ -44,6 +45,39 @@ function projectCallsToGetName(result: Awaited<ReturnType<typeof runPipelineFrom
}
describe('Java Lombok accessor synthesis (pipeline)', () => {
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) => {

View file

@ -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<string, number> = {
'find#1~Collection<Long>': 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<Long>',
]);
}
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<T> { void genericDispatch(T value); }
class StringGeneric implements Generic<String> {
public void genericDispatch(String value) {} public void genericDispatch(Integer value) {}
}
class IntegerGeneric implements Generic<Integer> { 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<Number> value) {} void concrete(Object value) {}
void poly(Collection<Number> value) {} void poly(Object value) {}
void polyAmbiguous(List<Number> value) {} void polyAmbiguous(Collection<Integer> value) {}
void primitiveArray(List<Long> 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<Long> 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<String> 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); }
<T> void genericAny(Generic<T> 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<java.lang.Long> 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) {}
<Long> void typeParameter(Long value) { generic(value); }
}
class FactoryField {
Object Arrays;
void select(java.util.Collection<java.lang.Long> 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<Number>'],
['targetTypedSingleton', 'poly', 'Cases.poly#1~Collection<Number>'],
['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');
});
});

View file

@ -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

View file

@ -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',

View file

@ -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);
});
});

View file

@ -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,

View file

@ -64,6 +64,34 @@ const targetsFor = (nodes: readonly StubNode[], defs: readonly ReturnType<typeof
.sort();
describe('buildGraphTargetIndex — value bindings join by position', () => {
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)]),

View file

@ -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<String> 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> { T value; }', 'extends Base<String>'],
[
'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<Long> 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<Long>' },
{ 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<Long> ids, String[] names, int value[], Long... rest) {} }',
);
expect(facts.methods[0]!.parameterTypes).toEqual([
'java.util.Collection<Long>',
'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<String[]> 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<Long>', () => {
const facts = capture(
'import java.util.Arrays; class A { void run(Long id) { use(Arrays.<Number>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<Long> { <String> 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);
});
});

View file

@ -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<T> {
java.util.List<T>[] make(Integer value) { return null; }
p.Result<Long> 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<T>[]' },
{ parameters: ['long'], returnType: 'p.Result<Long>[]' },
]);
});
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 }),
);
}
});
});

View file

@ -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' ? '<String>' : ''} { ${Array.from(
{ length: size },
(_, i) => `
void pick${i}(Object value) {}
void pick${i}(String value) {}
${generic ? '<String>' : ''} 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 });
});
});

View file

@ -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<string>[] = 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<string>[] = [
{ 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');
});
});

View file

@ -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<string>();
const context = { graph, scopes, nodeLookup, model };
const run = (
decision: ReceiverMemberResolution | undefined,
selectedCalls?: Map<string, SymbolDefinition | null>,
) => {
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<string, TypeRef>).set('result', {
rawName: 'WrongReturn',
declaredAtScope: f.scope.id,
source: 'assignment-inferred',
});
const selected = new Map<string, SymbolDefinition | null>();
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<string, SymbolDefinition | null>();
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}`),
});
});
});

View file

@ -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<string, readonly BindingRef[]>();
const typeBindings = new Map<string, TypeRef>();
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<string, SymbolDefinition | null>();
const pending = new Map<string, string>();
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' }),
);
});
});

View file

@ -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<ResolutionOutcome, { kind: 'suppressed' }>;
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> = {}): 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<ResolutionOutcome, { kind: 'resolved' }> {
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');
}
});
});

View file

@ -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',