mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
279 lines
8.6 KiB
TypeScript
279 lines
8.6 KiB
TypeScript
/**
|
|
* Unit tests for the Kotlin companion-promoted-method "static-only"
|
|
* marker mechanism (#1756 / U5 of the lang-kotlin remediation plan).
|
|
*
|
|
* Pins the contract of the `isKotlinStaticOnly` reader and the
|
|
* implicit `WeakSet`-backed writer driven by
|
|
* `populateKotlinOwners`:
|
|
*
|
|
* 1. Round-trip: methods declared inside a companion-object scope
|
|
* pass `isKotlinStaticOnly` after `populateKotlinOwners` runs;
|
|
* methods declared directly on a regular class scope do not.
|
|
* 2. Identity, not structure: spreading a marked def into a new
|
|
* object reference produces a structurally-identical but
|
|
* identity-distinct def that does NOT pass the marker check.
|
|
* Documents the identity-based design boundary that the
|
|
* previous enumerable-property mechanism did not enforce.
|
|
* 3. Multi-def fanout: marking three companion methods in one
|
|
* pass leaves all three readable and an unmarked sibling
|
|
* unaffected.
|
|
*
|
|
* The writer is intentionally not exported — these tests drive it
|
|
* through the public `populateKotlinOwners` entry point using a
|
|
* hand-built `ParsedFile` shape, mirroring the runtime call site.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import type { ParsedFile, Range, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
|
import {
|
|
clearCompanionScopes,
|
|
isCompanionScope,
|
|
markCompanionScope,
|
|
} from '../../src/core/ingestion/languages/kotlin/companion-scopes.js';
|
|
import {
|
|
isKotlinStaticOnly,
|
|
populateKotlinOwners,
|
|
} from '../../src/core/ingestion/languages/kotlin/owners.js';
|
|
|
|
const RANGE: Range = { startLine: 1, startCol: 0, endLine: 1, endCol: 0 };
|
|
|
|
function makeScope(args: {
|
|
id: string;
|
|
parent: string | null;
|
|
kind: Scope['kind'];
|
|
filePath: string;
|
|
ownedDefs: readonly SymbolDefinition[];
|
|
}): Scope {
|
|
return {
|
|
id: args.id as ScopeId,
|
|
parent: args.parent === null ? null : (args.parent as ScopeId),
|
|
kind: args.kind,
|
|
range: RANGE,
|
|
filePath: args.filePath,
|
|
bindings: new Map(),
|
|
ownedDefs: args.ownedDefs,
|
|
imports: [],
|
|
typeBindings: new Map(),
|
|
} as Scope;
|
|
}
|
|
|
|
function makeMethodDef(args: { nodeId: string; filePath: string; name: string }): SymbolDefinition {
|
|
return {
|
|
nodeId: args.nodeId,
|
|
filePath: args.filePath,
|
|
type: 'Function',
|
|
qualifiedName: args.name,
|
|
} as SymbolDefinition;
|
|
}
|
|
|
|
function makeClassDef(args: { nodeId: string; filePath: string; name: string }): SymbolDefinition {
|
|
return {
|
|
nodeId: args.nodeId,
|
|
filePath: args.filePath,
|
|
type: 'Class',
|
|
qualifiedName: args.name,
|
|
} as SymbolDefinition;
|
|
}
|
|
|
|
/**
|
|
* Build a synthetic `ParsedFile` modelling:
|
|
*
|
|
* class Outer {
|
|
* fun instanceMethod() { ... } // regular instance method
|
|
* companion object {
|
|
* fun staticMethod() { ... } // companion-promoted method
|
|
* }
|
|
* }
|
|
*
|
|
* The companion scope is registered with `markCompanionScope` so
|
|
* `populateCompanionMembersOnEnclosingClass` recognises it as the
|
|
* companion-object scope to walk for promotion + marking.
|
|
*/
|
|
function buildCompanionFixture(
|
|
filePath: string,
|
|
companionMethods: readonly string[],
|
|
instanceMethods: readonly string[],
|
|
): {
|
|
parsed: ParsedFile;
|
|
outerClassDef: SymbolDefinition;
|
|
companionMethodDefs: SymbolDefinition[];
|
|
instanceMethodDefs: SymbolDefinition[];
|
|
} {
|
|
const moduleScopeId = `${filePath}:module`;
|
|
const outerClassScopeId = `${filePath}:class:Outer`;
|
|
const companionScopeId = `${filePath}:class:Outer.Companion`;
|
|
|
|
const outerClassDef = makeClassDef({
|
|
nodeId: `${filePath}#Outer`,
|
|
filePath,
|
|
name: 'Outer',
|
|
});
|
|
|
|
const companionMethodDefs = companionMethods.map((name) =>
|
|
makeMethodDef({
|
|
nodeId: `${filePath}#Outer.Companion.${name}`,
|
|
filePath,
|
|
name,
|
|
}),
|
|
);
|
|
|
|
const instanceMethodDefs = instanceMethods.map((name) =>
|
|
makeMethodDef({
|
|
nodeId: `${filePath}#Outer.${name}`,
|
|
filePath,
|
|
name,
|
|
}),
|
|
);
|
|
|
|
const scopes: Scope[] = [
|
|
makeScope({
|
|
id: moduleScopeId,
|
|
parent: null,
|
|
kind: 'Module',
|
|
filePath,
|
|
ownedDefs: [outerClassDef],
|
|
}),
|
|
makeScope({
|
|
id: outerClassScopeId,
|
|
parent: moduleScopeId,
|
|
kind: 'Class',
|
|
filePath,
|
|
ownedDefs: [outerClassDef],
|
|
}),
|
|
makeScope({
|
|
id: companionScopeId,
|
|
parent: outerClassScopeId,
|
|
kind: 'Class',
|
|
filePath,
|
|
ownedDefs: [],
|
|
}),
|
|
];
|
|
|
|
// Each companion method lives in its own Function scope whose parent
|
|
// is the companion-class scope — the exact shape
|
|
// `populateCompanionMembersOnEnclosingClass` iterates.
|
|
companionMethodDefs.forEach((def, idx) => {
|
|
scopes.push(
|
|
makeScope({
|
|
id: `${filePath}:fn:companion:${idx}`,
|
|
parent: companionScopeId,
|
|
kind: 'Function',
|
|
filePath,
|
|
ownedDefs: [def],
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Instance methods on the outer class — Function scopes whose
|
|
// parent is the outer-class scope; populateClassOwnedMembers
|
|
// stamps these with `ownerId = Outer` but they MUST NOT be
|
|
// tagged by the companion promotion pass.
|
|
instanceMethodDefs.forEach((def, idx) => {
|
|
scopes.push(
|
|
makeScope({
|
|
id: `${filePath}:fn:instance:${idx}`,
|
|
parent: outerClassScopeId,
|
|
kind: 'Function',
|
|
filePath,
|
|
ownedDefs: [def],
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Tell the companion-scope side-channel that
|
|
// `outerClassScopeId.companion` is the companion scope id —
|
|
// matches what `emitKotlinScopeCaptures` does at runtime.
|
|
markCompanionScope(filePath, companionScopeId as ScopeId);
|
|
|
|
const parsed: ParsedFile = {
|
|
filePath,
|
|
moduleScope: moduleScopeId as ScopeId,
|
|
scopes,
|
|
parsedImports: [],
|
|
localDefs: [outerClassDef, ...companionMethodDefs, ...instanceMethodDefs],
|
|
referenceSites: [],
|
|
};
|
|
|
|
return { parsed, outerClassDef, companionMethodDefs, instanceMethodDefs };
|
|
}
|
|
|
|
describe('isKotlinStaticOnly (WeakSet-backed marker)', () => {
|
|
beforeEach(() => {
|
|
clearCompanionScopes();
|
|
});
|
|
|
|
it('marks companion-object methods and leaves instance methods unmarked (round-trip)', () => {
|
|
const { parsed, companionMethodDefs, instanceMethodDefs } = buildCompanionFixture(
|
|
'fixture-roundtrip.kt',
|
|
['staticMethod'],
|
|
['instanceMethod'],
|
|
);
|
|
|
|
populateKotlinOwners(parsed);
|
|
|
|
expect(isKotlinStaticOnly(companionMethodDefs[0]!)).toBe(true);
|
|
expect(isKotlinStaticOnly(instanceMethodDefs[0]!)).toBe(false);
|
|
});
|
|
|
|
it('keys on def identity, not on def structure (spread copy is not marked)', () => {
|
|
const { parsed, companionMethodDefs } = buildCompanionFixture(
|
|
'fixture-identity.kt',
|
|
['staticMethod'],
|
|
[],
|
|
);
|
|
|
|
populateKotlinOwners(parsed);
|
|
|
|
const marked = companionMethodDefs[0]!;
|
|
// Spread produces a new object reference with identical fields.
|
|
// The previous enumerable-property marker would have copied through;
|
|
// the WeakSet correctly tracks identity only.
|
|
const structuralClone = { ...marked } as SymbolDefinition;
|
|
|
|
expect(isKotlinStaticOnly(marked)).toBe(true);
|
|
expect(isKotlinStaticOnly(structuralClone)).toBe(false);
|
|
// Sanity: the clone really does have the same own-properties.
|
|
expect(structuralClone.nodeId).toBe(marked.nodeId);
|
|
expect(structuralClone.qualifiedName).toBe(marked.qualifiedName);
|
|
});
|
|
|
|
it('marks every companion method in a multi-method companion and leaves siblings unaffected', () => {
|
|
const { parsed, companionMethodDefs, instanceMethodDefs } = buildCompanionFixture(
|
|
'fixture-multi.kt',
|
|
['create', 'build', 'of'],
|
|
['save'],
|
|
);
|
|
|
|
populateKotlinOwners(parsed);
|
|
|
|
expect(isKotlinStaticOnly(companionMethodDefs[0]!)).toBe(true);
|
|
expect(isKotlinStaticOnly(companionMethodDefs[1]!)).toBe(true);
|
|
expect(isKotlinStaticOnly(companionMethodDefs[2]!)).toBe(true);
|
|
expect(isKotlinStaticOnly(instanceMethodDefs[0]!)).toBe(false);
|
|
});
|
|
|
|
it('returns false for a fresh def the writer never saw', () => {
|
|
const unrelated = makeMethodDef({
|
|
nodeId: 'unrelated#foo',
|
|
filePath: 'unrelated.kt',
|
|
name: 'foo',
|
|
});
|
|
|
|
expect(isKotlinStaticOnly(unrelated)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('kotlinScopeResolver.loadResolutionConfig lifecycle', () => {
|
|
it('clears stale companionScopesByFile entries from a prior workspace pass', async () => {
|
|
const staleFile = 'stale-prior-pass.kt';
|
|
const staleScopeId = `scope:${staleFile}#1:0-2:0:Class` as ScopeId;
|
|
markCompanionScope(staleFile, staleScopeId);
|
|
expect(isCompanionScope(staleFile, staleScopeId)).toBe(true);
|
|
|
|
const { kotlinScopeResolver } =
|
|
await import('../../src/core/ingestion/languages/kotlin/scope-resolver.js');
|
|
kotlinScopeResolver.loadResolutionConfig!('/any/repo/path');
|
|
|
|
expect(isCompanionScope(staleFile, staleScopeId)).toBe(false);
|
|
});
|
|
});
|