mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* fix(jvm): bound same-package sibling injection * docs(jvm): document sibling injection cap * fix(jvm): mark truncated sibling sets incomplete and bound the merge Review follow-ups on the sibling injection cap (#2732): - The cap silently produced a third visibility state. Before it, a file was either fully visible (package under 500 files) or fully incomplete; once `injectedIds.size` hit the cap, real siblings were dropped while `isVisibilityIncomplete` still returned `false`. That flag gates wildcard attribution in seven Spring passes (`bean-candidates.ts:199` and the java/ kotlin bean-metadata, conditionals, config-bindings and DI resolvers), so 201-500-file packages — exactly this cap's population — resolved wildcard annotations against a truncated sibling set with no log signal. Truncation now marks the file incomplete and analyze warns once with the affected file count. - The cap only bounded `bindingAugmentations`; the two `typeBindings` merges below it still absorbed every sibling, so a class excluded from the binding set could still steer receiver/variable type inference through `scope.typeBindings`. Both halves now use the same bounded sibling set, and the merge iterates that set directly rather than filtering a full rescan, so the cap bounds the work as well as the result. - Path segments are split once per bucket instead of on every pairwise proximity comparison — that comparison runs O(files²) per package. - `JvmPackageFact` was re-declared locally instead of imported from `package-facts.js`, where the canonical declaration still serves both languages' facades and capture side-channels. Nothing kept the copies in sync. Restored the import. - README/.env.example: `GITNEXUS_MAX_INJECTED_SIBLINGS` does not lift the fixed 500-file package skip (including at `0`), and truncation disables wildcard attribution for the affected files. Both are now stated. * test(jvm): restore the language-facade coverage and pin the cap's behaviour The cap rewrite replaced the per-language harness with generic fixtures, dropping the Java/Kotlin capture-side-channel and facade coverage (package fact extraction, the 500-file skip, fail-closed on a file that produced no ParsedFile) and leaving a proximity fixture whose candidates were already in order — so it could not tell a working sort from plain truncation of the input. Restores that harness and adds cap-specific cases on top, driven through the shared JVM factory. The fixture interleaves near and distant siblings, so the retained set is only reachable by a working proximity sort. Covers: the exact capped set, truncation marking the file visibility-incomplete, type bindings bounded by the same sibling set, the unbounded `0` override staying complete, and the documented default of 200 applying when the variable is unset. Each new case fails against the pre-fix implementation. --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
import type { ParsedFile, ScopeResolutionIndexes, 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',
|
|
typeBindings: new Map(moduleTypeBindings ?? []),
|
|
ownedDefs: [],
|
|
},
|
|
];
|
|
if (def !== undefined) {
|
|
scopes.push({
|
|
id: `class:${index}`,
|
|
kind: 'Class',
|
|
parent: moduleId,
|
|
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);
|
|
});
|
|
});
|