GitNexus/gitnexus/test/unit/jvm-package-siblings.test.ts
Abhigyan Patwari e7ef82dfcb
fix(kotlin): type locals from package-qualified factory calls (#3660)
* fix(scope): follow namespace factory return types and record untyped drops

`import mod as rp; x = rp.make(); x.m()` lost the receiver type even when
`make` is declared `-> C`: `x` was bound to the raw dotted name `rp.make`,
and nothing followed a dotted name to a function's return type. The inline
`rp.make().m()` failed the same way in the compound resolver, which read
`ns.f()` only as a construction.

Resolve `ns.f` to `f`'s return annotation when `ns` is a namespace import,
the fact `propagateImportedReturnTypes` already mirrors for a named import.
The post-finalize chain follow uses it for bindings and the compound
resolver for inline calls. Only sites that emitted nothing change: the
full lang-resolution corpus emits the identical 2,838 CALLS/ACCESSES edges.

Record the drops that left `impact` at "exact":
- a plain receiver bound to a namespace function with no usable return type
  (`x = ns.untyped()`);
- an outer call whose `file:line:col` key a resolved inner site already
  claimed (`ns.untyped().m()`, `s.repo.Delete(id)`). Only a duplicate
  capture of the same member hides a drop now.

Receiver baseline: call drops 128 -> 151, reads 44 -> 48, ten shape-arm
cells INVISIBLE-GAP -> VISIBLE-GAP; every new drop has no CALLS edge to its
member (BASELINE.md lists them). The python.factory-qualified accuracy gap
is removed; the python-scope fingerprint and capture golden gain the new
fixture only.

Fixes #3498

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dart): type locals from prefixed-import factory calls

`import 'mod.dart' as rp; final x = rp.make(); x.m()` gave `x` no type:
local call inference accepted only a bare callee (`Callee()`), and the
prefix-stripping type normalizer would have reduced `rp.make` to `make`.

Bind a local initialized by exactly `prefix.member(...)` (direct or
awaited) to the dotted callee and keep its prefix, so the shared chain
follow types it from the namespace function's return annotation. Only
call-derived bindings keep the prefix; annotated `prefix.Type` spellings
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(zig): type locals from namespace-module factory calls

`const rp = @import("mod.zig"); const x = rp.make(); x.m()` bound `x` to
the namespace handle `rp` itself: the call-return rule reads a non-value
head as the type being constructed (`Counter.init()`), and import bindings
are not values. The member name was gone before the shared chain follow
could use the new namespace return-type lookup.

For a module-level handle on a lowercase file (a namespace module, by Zig
convention), bind the dotted callee `rp.make` so the shared chain follow
types it from `make`'s return annotation. File-struct handles
(`@import("Page.zig")`, any spelling of the handle) keep naming the
struct, so `Page.init()` is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(bench): rebaseline captures and receiver counts for Dart and Zig

- receiver-resolution: the Dart fixture's untyped factory adds one call
  drop (151 -> 152); every typed form in the Dart and Zig fixtures resolves.
- scope-capture (dart): two existing dart-method-enrichment locals
  initialized by `obj.method()` / `Type.method()` gain a call-derived
  binding, plus the new fixture's captures. A full lang-resolution A/B
  emits the identical edges outside the new fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scope): bind same-namespace inheritance bases before sibling visibility exists

Cause: same-package / same-namespace visibility (`populateNamespaceSiblings`)
is populated after the inheritance pre-pass and `buildMro`. A base written
without an import (`class C extends B`, B in another file of the package)
could bind only through the workspace-unique simple-name fallback, which any
other package declaring a `B` defeats: no EXTENDS edge, an empty MRO, and
lost calls through inherited members. In the combined lang-resolution corpus
`java-grandparent-resolution` `c.greet().save()` dropped because
`java-unrelated-method-collision` declares default-package A and B.

Moving the sibling hook ahead of the pre-pass was measured and rejected. It
retargeted correct edges and minted wrong ones, because the sibling channels
carry candidates the pre-pass must not see: C# global-namespace types are
consulted at every scope (same-file `Cat : Animal` bound another fixture's
global Animal), namespace buckets merge across source roots with no
proximity order (PHP, C#), and a qualified `extends app.base.Box` bound
through a same-package `app.Box`.

Fix: an optional `ScopeResolver.resolveImplicitInheritanceBase`, consulted by
the pre-pass only when the shared chain bound nothing, the base was written
bare (no qualifier, name capture at the start of the base expression), and
no import claims the name. It adds edges and never retargets one.
`resolveSameNamespaceInheritanceBase` picks the candidate in the deriving
class's namespace, nearest by shared path prefix, refusing a tie. Java and
Kotlin answer from their package facts, C# from the exact `namespacePrefix`.

Corpus A/B (main vs this, same corpus): EXTENDS/IMPLEMENTS +42, all within
their own fixture, 0 removed. CALLS: six `super.save()` edges keep their
target but are now MRO-dispatched (`global`, as in isolation), one moves off
another fixture's BaseModel.Save onto its own, and 7 are new (both
grandparent `greet` calls, a default-method `validate`, the new fixtures).
Receiver bench: call drops 128 -> 127, all kinds 192 -> 191, shape arm
unchanged. Scope-capture fingerprints re-baselined for the added fixtures
only (parked, --check passes on the old ones).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(java): resolve a duplicated package+type import to the importer's own root

Cause: `buildJavaPackageIndex` kept the first file declaring each
package+type. One repository can hold several build modules or sample
projects that each declare `models.User`, so every `import models.User;`
bound whichever copy was parsed first, a wrong IMPORTS edge at full
confidence that carries the receiver types behind it. In the combined
lang-resolution corpus 24 fixtures declare `models.User`, and e.g.
`java-heritage` UserService.processUser called
`java-assignment-chain/models/User.save`.

Fix: the index keeps every declaring file in a sparse `duplicateTypes` map
(only colliding pairs, so a normal workspace pays nothing), and a type import
resolves to the declaring file sharing the longest leading path with the
importer (its own module or source root). A tie, or an unknown importer,
refuses rather than guessing. Wildcard imports are unchanged.

Corpus A/B (previous commit vs this, same corpus): 40 CALLS edges that
crossed into another fixture's User/Repo/Address/UserService are gone, 55
CALLS/ACCESSES edges now land in their own fixture (0 cross-fixture), and
`java-parent-resolution` User IMPLEMENTS Serializable moves off
`java-heritage` onto its own interface. Receiver-unresolved drops unchanged;
import-target and receiver benches pass. Scope-capture java fingerprint
re-baselined for the added fixture only (parked, --check passes on the old
one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(scope): expose the position a receiver-chain fold reaches

`foldReceiverChain` answers "which class", so a chain whose last step is
declared as a type the program does not contain (`Dictionary<string,
User>`, `String`) reads only as a miss. That miss is informative to the
receiver-origin classifier: every hop was typed by a program declaration
and the last one names a library type.

Extract the fold loop into `foldReceiverChainState` and export
`foldReceiverChainPosition`, which returns the final position including
one whose declared type named no class, and reports nothing to
`recordReceiverType`. `foldReceiverChain` keeps its behaviour: it now
wraps the same loop and makes the receiver-type report from the final
state, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scope): classify library-rooted receiver drops from provider evidence

`classifyReceiverOrigin` had one source of positive external evidence,
`LanguageProvider.isBuiltInName`. That set also filters callee names out
of the call graph, so it could not grow to cover host globals, stdlib
imports or library-typed values without dropping real edges, and every
such drop hedged `impact()` to `lower-bound`. It also read a base name as
evidence even where the program bound that name itself: a parameter
named `console` with an unresolved type, or an unresolved import of a
built-in spelling from the program's own module, came back `external`.

Add two provider data hooks read only by the classifier:
`externalReceiverNames` (globals, platform roots, library types) and
`isExternalModuleSpecifier` (import specifiers reserved for the runtime
or stdlib). The classifier now answers `external` when:
- the base is unbound at the site and is a built-in or listed name;
- the base is bound only by imports that resolved to no program file and
  all name a library module;
- the receiver chain folds, through declarations each owner makes, to a
  position declared as a library type (`this.data.Keys` with
  `Dictionary<string, User> data`), and the program declares nothing by
  the dropped member's name (an extension method or trait impl could be
  the real target). The fold skips ancestor-hoisted bindings, which can
  lend another class's member type to the step.
A base the program binds (a parameter, a local, a resolved import, an
import from a non-library module) never takes a name-based verdict. The
in-program arms are unchanged and still win first. No drop is added or
removed and no edge changes.

Java supplies its reserved `java` package root for both hooks: the JVM
refuses to define classes under `java.*`, so `java.util.Optional.of(...)`
and `import java.util.Map` are rooted in the platform. Without the
specifier hook, the stricter import rule would have demoted the existing
`import java.util.List; List.of(...)` verdict to unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(typescript): name JavaScript host globals as the program boundary

`document.body.appendChild(...)`, `window.history.pushState(...)` and
`Buffer.from(raw).toString()` were recorded `unknown`: none of
`document`, `window`, `Buffer`, `process` or `globalThis` is in
`BUILT_INS`, and they cannot be added there because that set also
filters callee names, so `process` would drop every call to a program
function named `process`. Each drop hedged `impact()` to `lower-bound`
although no program node could have been the target.

List the host objects in `EXTERNAL_RECEIVER_NAMES` and supply it to the
TypeScript, JavaScript and Vue providers as `externalReceiverNames`. A
name counts only where the program binds nothing by it and declares
nothing by it, so a `document: Doc` parameter, an untyped `window`
parameter, a local `navigator` and a program `class Buffer` keep their
verdicts. `location`, `history`, `self` and `crypto` stay out: they are
ordinary local names.

Corpus: 5 call drops move from unknown to external. `process.env.*`
(3 reads) stays in-program there because three fixtures declare a
top-level `function process()`, one of them in a global script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): name standard-library imports as the program boundary

`os.environ.setdefault(...)` after `import os`, and
`Path(__file__).resolve()` after `from pathlib import Path`, were
recorded `unknown`. The imports resolve to no file in the program, but an
unresolved import alone cannot be told apart from an in-program module
the resolver missed, and nothing said that `os` and `pathlib` belong to
the interpreter.

Supply `isExternalModuleSpecifier` from a curated set of standard-library
modules, matched on the specifier's first segment (`os.path` -> `os`).
A relative specifier never matches. The classifier still requires the
import to have resolved to no program file, so a local `os.py` that the
resolver finds wins, and a parameter named `os` is the program's own.
Generic nouns an application plausibly uses for its own modules
(`types`, `queue`, `signal`, `secrets`, `string`, `email`, `http`, ...)
are left out. Third-party packages are not listed: an unresolved
`fastapi` import stays `unknown`.

Corpus: 4 call drops move from unknown to external
(`os.environ.setdefault` x3, `Path(__file__).resolve()`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(zig): name @import("std") and @import("builtin") as the program boundary

`std.sort.pdq(...)` and `const Allocator = std.mem.Allocator;` were
recorded `unknown`: `std` is bound by `const std = @import("std")`, an
import the resolver deliberately leaves external, and nothing told the
classifier that the module is the standard library rather than an
in-program file it failed to find.

Supply `isExternalModuleSpecifier` for the two module names Zig
reserves: `std` and the compiler-generated `builtin`. Exact names only,
so `@import("std.zig")` (a file path) stays `unknown`, as does `root`
(the program's own root module) and any module `build.zig` names.

Corpus: 9 drops move from unknown to external: `std.sort.pdq` (2 calls,
2 reads), `std.mem.Allocator` (1 read), and the
`zig-qualified-literal` literals `std.Thread.Mutex{}` and
`std.mem.Allocator{...}` (2 calls, 2 reads). The last four are that
fixture's edge negative control (`useExternal` must emit no edge, which
still holds); their receiver is std, so `external` is the right origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(csharp): name the System namespace and Dictionary as the program boundary

`System.Console.WriteLine(...)` was recorded `unknown`: `Console` is in
`BUILT_INS` but the chain base is `System`, which is not, and `this.data.Keys`
with `Dictionary<string, User> data` was recorded `in-program` because
the base `this` resolves to a program class even though the member lives
on a BCL type.

Supply `externalReceiverNames` with `System` (the BCL root namespace),
`Dictionary` (the one collection capture does not reduce to its element,
because it takes two type arguments) and `string` (a keyword alias that
no program can declare). The base check covers `System.Console`; the
chain fold reaches `Dictionary<string, User>` through the program's own
field declaration. A program `class System`, a field of a program type,
and an extension method the program declares on `Dictionary` all keep
their verdicts.

Corpus: 3 call drops move from unknown to external
(`System.Console.WriteLine`). The two `this.data.Keys/Values` reads in
`csharp-nested-member-foreach` stay in-program there: the fixture's
global-namespace `App` is one of several in the merged corpus, so `this`
binds another fixture's `App` and the fold declines. Analysed alone,
both read `external`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kotlin): name java.lang.System and the platform roots as the program boundary

`System.out.println(...)` in Kotlin was recorded `unknown`: Kotlin's
`BUILT_INS` lists stdlib function names, not `System`, which Kotlin/JVM
imports by default from `java.lang`.

Supply `externalReceiverNames` with `System` and the package roots no
program can declare into (`java`, refused by the JVM; `kotlin`, reserved
by the compiler for the standard library), and `isExternalModuleSpecifier`
for imports under those roots. A program `object System` keeps the
receiver `in-program`.

Corpus: 1 call drop moves from unknown to external
(`kotlin-object-literal-scope` `System.out.println`, whose decoy
`println` on the enclosing object still gets no edge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(bench): rebaseline receiver-origin buckets for library receivers

The preceding commits label library- and runtime-rooted drops `external`
instead of `unknown`. Call drops by origin move from 55 in-program, 44
external and 29 unknown to 55, 62 and 11. Total drops (192), call drops
(128), every per-extension and per-shape count, and every shape-arm
state are unchanged; a same-corpus A/B against main keeps all 2,838
CALLS/ACCESSES edges byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rust): type locals from module-qualified factory calls

`mod modx; use crate::modx as rp; let x = rp::make(); x.m()` left `x`
untyped although `make` is declared `-> C`. The call-return rule kept only
the text before the first `::` (the `Foo::new()` idiom), so `x` was bound
to the module `rp` and the function name was lost. `modx::make()` and
`crate::modx::make()` failed the same way, and the inline `rp::make().m()`
failed in the compound resolver, which cannot split a `::` path. With a
`let modx = Other;` in scope, `modx::make()` even typed `x` as `Other` and
emitted a wrong `Other.m` edge.

- Rust keeps the whole callee path when every segment is lower-case: a
  module function, by rustc's lint-enforced casing. `Foo::new()` and
  `Self::new()` still read as the type.
- New `ScopeResolver.resolveQualifiedCallee` hook. The shared chain follow,
  the compound call branch and the drop recorder hand a callee text no
  scope binds to the provider, which returns the callable it names; the
  shared side reads that callable's return annotation by definition
  identity and never splits the path. Rust answers with the module-tree
  walk behind `resolveQualifiedFreeCall`, so a call's type and its CALLS
  edge come from the same resolution.
- A receiver bound to an in-program module function with no return
  annotation (`x = rp::untyped()`) records a `receiver-unresolved` drop,
  as #3498 does for `ns.untyped()`.
- `rp::make()` did not resolve as a call either: the module-name filter
  rejected the alias `rp`, and under Cargo the import binds the module's
  `mod modx;` declaration rather than `modx.rs`. The `use` candidate now
  admits an alias that names a module and reads a module from its
  top-level declaration.

A full lang-resolution A/B against the parent emits identical CALLS and
ACCESSES edges outside the new fixture, except one new correct edge:
`rust-ambiguous` `main -> models::Handler.handle`, typed from
`let h = services::create_handler()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(bench): rebaseline rust captures and receiver counts

- receiver-resolution: the new rust-qualified-factory-return fixture's two
  untyped sites (`x = rp::untyped(); x.m()` and inline
  `modx::untyped().m()`) are each a call and a callee-position read drop:
  call drops 152 -> 154, reads 48 -> 50, all kinds 220 -> 224. No other
  drop moves in a same-corpus A/B, and shape-arm cells are unchanged.
- scope-capture (rust): corpus growth only. emitRustScopeCaptures is
  untouched, and parking the new fixture reproduces the prior fingerprint
  byte-for-byte; it adds 131 capture groups and 3 fixture entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scope): ask resolveQualifiedCallee about dotted callee paths

A dotted call-return binding (`x -> com.acme.mod.make`) whose head no
namespace import binds came back from followChainPostFinalize untouched.
The dotted branch tried only namespaceCallReturnType, which needs a
two-segment path headed by a namespace import. The inline
`com.acme.mod.make().m()` failed the same way in the compound resolver: once
the object part resolved to no class and named no namespace import, the call
branch returned nothing. A language whose package paths are dotted and never
imported (Kotlin) could type neither form.

Both sites now pass the dotted text to the provider's resolveQualifiedCallee
after their own lookups miss, as they already did for undotted callees, and
read the returned callable's return annotation by definition identity. The
contract documents the two new consult points, and says that a provider must
refuse a path whose head the call site can see as a value or type.

No provider answers dotted text yet: Rust's hook reads only `::` paths. No
edge moves, and the Rust, Python, Dart and Zig resolver suites pass
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kotlin): type locals from package-qualified factory calls

`val x = kqfmod.make(); x.m()` left `x` untyped although `make` is declared
`: C`. `com.acme.mod.make()` failed the same way, and the inline
`kqfmod.make().m()` dropped. Three gaps lined up:

- the constructor-inferred query matches only a bare callee;
- the navigation-call inference handles only typed locals and same-file
  classes;
- nothing resolved a package name, because Kotlin imports declarations, not
  packages, so no import edge names `kqfmod`.

The named-import form (`import kqfmod.make; make()`) already worked.

- The local-assignment synthesis binds `x` to the whole callee path when the
  callee is a dotted path of plain names. The head must be lower-case and
  must not be declared or imported in the file as a value or type. A
  `@type.qualified-callee` marker stops interpretKotlinTypeBinding from
  reducing the path to the function name. Typed-local and same-file-class
  receivers (`repo.find()`, `Logger.create()`) are tried first, so they keep
  their meaning.
- New Kotlin resolveQualifiedCallee. The package is everything before the
  last segment. A package spans files, and across them it must declare
  exactly one top-level function with that name. The index of package facts
  and module bindings is built once per pass. The hook refuses overloads. It
  also refuses a head the call site can see: anything the lexical claim walk
  finds, plus top-level names in same-package sibling files and star-imported
  packages, which that walk misses.
- An untyped package function (`val x = kqfmod.untyped()`) now records a
  receiver-unresolved drop instead of an exact miss.

New fixture kotlin-package-qualified-factory covers:

- single- and multi-segment packages, and inline calls;
- the untyped drop and the named-import control;
- local, parameter, property, import, star-import and same-package values
  named like the package;
- overloads, and companion/object member calls.

A full lang-resolution A/B against the parent emits identical CALLS/ACCESSES
edges and drops outside the new fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(bench): rebaseline kotlin captures and receiver counts

- receiver-resolution: the new kotlin-package-qualified-factory fixture
  records six drops, all in app/Callers.kt. Two are call drops on untyped
  receivers: `x.m()` on `val x = kqfmod.untyped()` and the inline
  `kqfmod.untyped().m()`. The other four come from `com.kqfacme.deep.make()`
  in deepCall and inlineDeep: each is a call drop plus a read drop on the
  `deep` receiver path. Call drops 154 -> 158, reads 50 -> 52, all kinds
  224 -> 230. A same-corpus A/B shows no other drop change. Shape-arm cells
  are unchanged.
- scope-capture (kotlin): moving the new fixture out of the corpus
  reproduces the prior fingerprint byte-for-byte. Base and new emitters
  differ by exactly six matches over the same corpus, all of them in the
  new fixture. Synthetic counts are unchanged, and scaling is 1.039 < 1.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(scope): reuse qualified call file sets

* docs(scope): clarify finalized type refinement

* test(kotlin): sync measured capture counts

* fix(rust): scope generic alias parameters

* fix(scope): respect base candidate visibility

* test(scope): preserve library member collision hedge

* fix(scope): cover more library receiver evidence

* fix(vue): classify imported runtime receivers

* fix(scope): match written JVM base arity

* fix(kotlin): refuse unproven package receivers

* fix(kotlin): prove companion receiver headers

* test(kotlin): complete JVM scope fixtures

* fix(kotlin): unify package receiver shadow checks

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:49:06 +00:00

322 lines
13 KiB
TypeScript

import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js';
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { describe, expect, it } from 'vitest';
import { createJvmPackageSiblingVisibility } from '../../src/core/ingestion/languages/jvm/package-siblings.js';
import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js';
import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js';
import {
isJavaPackageSiblingVisibilityIncomplete,
populateJavaPackageSiblings,
} from '../../src/core/ingestion/languages/java/package-siblings.js';
import { setJavaPackageFact } from '../../src/core/ingestion/languages/java/package-facts.js';
import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js';
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js';
import {
isKotlinPackageSiblingVisibilityIncomplete,
populateKotlinPackageSiblings,
} from '../../src/core/ingestion/languages/kotlin/package-siblings.js';
import { setKotlinPackageFact } from '../../src/core/ingestion/languages/kotlin/package-facts.js';
import type { JvmPackageFact } from '../../src/core/ingestion/languages/jvm/package-facts.js';
interface LanguagePackageHarness {
readonly label: string;
readonly extension: string;
readonly namedSource: string;
readonly defaultSource: string;
readonly malformedSource: string;
readonly brokenBodySource: string;
readonly emit: (source: string, filePath: string) => unknown;
readonly collect: (filePath: string) => { packageFact: JvmPackageFact } | undefined;
readonly setFact: (filePath: string, fact: JvmPackageFact) => void;
readonly populate: (
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
context: { fileContents: ReadonlyMap<string, string> },
) => void;
readonly isIncomplete: (filePath: string) => boolean;
}
const harnesses: readonly LanguagePackageHarness[] = [
{
label: 'Java',
extension: '.java',
namedSource: 'package com.example;\nclass Named {}',
defaultSource: 'class Default {}',
malformedSource: 'package ;\nclass Malformed {}',
brokenBodySource: 'package com.valid;\nclass Broken { void f( }',
emit: emitJavaScopeCaptures,
collect: collectJavaCaptureSideChannel,
setFact: setJavaPackageFact,
populate: populateJavaPackageSiblings,
isIncomplete: isJavaPackageSiblingVisibilityIncomplete,
},
{
label: 'Kotlin',
extension: '.kt',
namedSource: 'package com.example\nclass Named',
defaultSource: 'class Default',
malformedSource: 'package ;\nclass Malformed',
brokenBodySource: 'package com.valid\nclass Broken { fun f( }',
emit: emitKotlinScopeCaptures,
collect: collectKotlinCaptureSideChannel,
setFact: setKotlinPackageFact,
populate: populateKotlinPackageSiblings,
isIncomplete: isKotlinPackageSiblingVisibilityIncomplete,
},
];
/** A file with only a module scope — `moduleWithClass` without the class half. */
function parsedFile(filePath: string, index: number): ParsedFile {
return moduleWithClass(filePath, index);
}
function emptyIndexes(): ScopeResolutionIndexes {
return { bindingAugmentations: new Map() } as unknown as ScopeResolutionIndexes;
}
for (const harness of harnesses) {
describe(`${harness.label} JVM package facts`, () => {
it('captures named/default packages and isolates package-header errors', () => {
const namedPath = `src/Named${harness.extension}`;
harness.emit(harness.namedSource, namedPath);
expect(harness.collect(namedPath)?.packageFact).toEqual({
status: 'known',
packageName: 'com.example',
});
const defaultPath = `src/Default${harness.extension}`;
harness.emit(harness.defaultSource, defaultPath);
expect(harness.collect(defaultPath)?.packageFact).toEqual({
status: 'known',
packageName: '',
});
const malformedPath = `src/Malformed${harness.extension}`;
harness.emit(harness.malformedSource, malformedPath);
expect(harness.collect(malformedPath)?.packageFact).toEqual({ status: 'unknown' });
const brokenBodyPath = `src/BrokenBody${harness.extension}`;
harness.emit(harness.brokenBodySource, brokenBodyPath);
expect(harness.collect(brokenBodyPath)?.packageFact).toEqual({
status: 'known',
packageName: 'com.valid',
});
});
it('marks a capped package incomplete without affecting other package names', () => {
const source = harness.namedSource;
const parsedFiles = Array.from({ length: 501 }, (_, index) => {
const filePath = `src/com/capped/Type${index}${harness.extension}`;
harness.setFact(filePath, { status: 'known', packageName: 'com.capped' });
return parsedFile(filePath, index);
});
const fileContents = new Map(parsedFiles.map((parsed) => [parsed.filePath, source]));
harness.populate(parsedFiles, emptyIndexes(), { fileContents });
expect(harness.isIncomplete(parsedFiles[0].filePath)).toBe(true);
expect(harness.isIncomplete(`src/other/Complete${harness.extension}`)).toBe(false);
});
it('fails wildcard visibility closed when a source file produced no ParsedFile', () => {
const first = parsedFile(`src/A${harness.extension}`, 1);
const second = parsedFile(`src/B${harness.extension}`, 2);
harness.setFact(first.filePath, { status: 'known', packageName: 'com.example' });
harness.setFact(second.filePath, { status: 'known', packageName: 'com.example' });
const skippedPath = `src/Skipped${harness.extension}`;
const fileContents = new Map([
[first.filePath, harness.namedSource],
[second.filePath, harness.namedSource],
[skippedPath, harness.malformedSource],
]);
harness.populate([first, second], emptyIndexes(), { fileContents });
expect(harness.isIncomplete(first.filePath)).toBe(true);
expect(harness.isIncomplete(second.filePath)).toBe(true);
});
});
}
// ─── sibling injection cap (#2732) ───────────────────────────────────
//
// Driven through the shared JVM factory rather than a language facade: the
// cap is language-agnostic, and a synthetic fixture can place candidates at
// chosen path distances without hand-writing hundreds of real sources.
function classDef(nodeId: string, filePath: string, qualifiedName: string): SymbolDefinition {
return { nodeId, filePath, type: 'Class', qualifiedName } as unknown as SymbolDefinition;
}
function moduleWithClass(
filePath: string,
index: number,
def?: SymbolDefinition,
moduleTypeBindings?: ReadonlyMap<string, unknown>,
): ParsedFile {
const moduleId = `module:${index}`;
const scopes: Record<string, unknown>[] = [
{
id: moduleId,
kind: 'Module',
bindings: new Map(),
typeBindings: new Map(moduleTypeBindings ?? []),
ownedDefs: [],
},
];
if (def !== undefined) {
scopes.push({
id: `class:${index}`,
kind: 'Class',
parent: moduleId,
bindings: new Map(),
typeBindings: new Map(),
ownedDefs: [def],
});
}
return { filePath, scopes } as unknown as ParsedFile;
}
function jvmVisibility(facts: ReadonlyMap<string, JvmPackageFact>) {
return createJvmPackageSiblingVisibility({
languageLabel: 'JVM',
getPackageFact: (filePath) => facts.get(filePath),
});
}
/**
* `count` siblings of `com.example`, interleaved near/far so the retained set
* cannot be produced by simply truncating the input order: every odd index is
* a distant `vendor/` file, every even index sits beside the target. A working
* proximity sort keeps the near half; plain truncation would keep half of each.
*/
function interleavedPackage(
targetPath: string,
count: number,
moduleBinding?: (siblingName: string) => ReadonlyMap<string, unknown>,
): { parsedFiles: ParsedFile[]; facts: Map<string, JvmPackageFact> } {
const facts = new Map<string, JvmPackageFact>();
facts.set(targetPath, { status: 'known', packageName: 'com.example' });
const parsedFiles = [moduleWithClass(targetPath, 0)];
for (let index = 0; index < count; index++) {
const isFar = index % 2 === 1;
const name = isFar ? `FarType${index}` : `NearType${index}`;
const filePath = isFar ? `vendor/generated/${name}.java` : `src/com/example/near/${name}.java`;
facts.set(filePath, { status: 'known', packageName: 'com.example' });
parsedFiles.push(
moduleWithClass(
filePath,
index + 1,
classDef(`class:${index}`, filePath, `com.example.${name}`),
moduleBinding?.(name),
),
);
}
return { parsedFiles, facts };
}
function withMaxInjectedSiblings<T>(value: string | undefined, run: () => T): T {
const previous = process.env.GITNEXUS_MAX_INJECTED_SIBLINGS;
if (value === undefined) delete process.env.GITNEXUS_MAX_INJECTED_SIBLINGS;
else process.env.GITNEXUS_MAX_INJECTED_SIBLINGS = value;
try {
return run();
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MAX_INJECTED_SIBLINGS;
else process.env.GITNEXUS_MAX_INJECTED_SIBLINGS = previous;
}
}
describe('JVM sibling injection cap (#2732)', () => {
const targetPath = 'src/com/example/Target.java';
it('keeps the nearest siblings, drops the rest, and injects exactly the cap', () => {
const { parsedFiles, facts } = interleavedPackage(targetPath, 10);
const visibility = jvmVisibility(facts);
const indexes = emptyIndexes();
withMaxInjectedSiblings('4', () =>
visibility.populateNamespaceSiblings(parsedFiles, indexes, {
fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])),
}),
);
const injected = indexes.bindingAugmentations.get('module:0');
expect([...(injected?.keys() ?? [])].sort()).toEqual([
'NearType0',
'NearType2',
'NearType4',
'NearType6',
]);
});
it('marks a file whose sibling set was truncated as visibility-incomplete', () => {
const { parsedFiles, facts } = interleavedPackage(targetPath, 10);
const visibility = jvmVisibility(facts);
withMaxInjectedSiblings('4', () =>
visibility.populateNamespaceSiblings(parsedFiles, emptyIndexes(), {
fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])),
}),
);
// Spring bean/DI/conditional attribution keys off this flag; a truncated
// sibling set must never read as exact package visibility.
expect(visibility.isVisibilityIncomplete(targetPath)).toBe(true);
});
it('bounds type bindings by the same sibling set it bounded bindings by', () => {
// Each sibling module scope carries a type binding named after itself, so
// the merged set names exactly which siblings were treated as visible.
const { parsedFiles, facts } = interleavedPackage(
targetPath,
10,
(name) => new Map([[`Binding_${name}`, { source: 'import' }]]),
);
const visibility = jvmVisibility(facts);
withMaxInjectedSiblings('4', () =>
visibility.populateNamespaceSiblings(parsedFiles, emptyIndexes(), {
fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])),
}),
);
const targetModule = parsedFiles[0].scopes.find((scope) => scope.kind === 'Module');
expect([...(targetModule?.typeBindings.keys() ?? [])].sort()).toEqual([
'Binding_NearType0',
'Binding_NearType2',
'Binding_NearType4',
'Binding_NearType6',
]);
});
it('injects every sibling and stays complete when the cap is disabled', () => {
const { parsedFiles, facts } = interleavedPackage(targetPath, 10);
const visibility = jvmVisibility(facts);
const indexes = emptyIndexes();
withMaxInjectedSiblings('0', () =>
visibility.populateNamespaceSiblings(parsedFiles, indexes, {
fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])),
}),
);
expect(indexes.bindingAugmentations.get('module:0')?.size).toBe(10);
expect(visibility.isVisibilityIncomplete(targetPath)).toBe(false);
});
it('applies the documented default of 200 when the env var is unset', () => {
const { parsedFiles, facts } = interleavedPackage(targetPath, 402);
const visibility = jvmVisibility(facts);
const indexes = emptyIndexes();
withMaxInjectedSiblings(undefined, () =>
visibility.populateNamespaceSiblings(parsedFiles, indexes, {
fileContents: new Map(parsedFiles.map((parsed) => [parsed.filePath, 'class Type {}'])),
}),
);
expect(indexes.bindingAugmentations.get('module:0')?.size).toBe(200);
expect(visibility.isVisibilityIncomplete(targetPath)).toBe(true);
});
});