GitNexus/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts
EVA f6e70016d6
fix(python): resolve mixin self calls to subtype implementations (#3390)
* fix(python): resolve missing mixin self members through subtypes

* fix: honor Python effective MRO and static mixin targets

* fix: bind Python subtype dispatch to receiver provenance

* fix(python): limit mixin fanout to instance receivers

* fix(python): capture call arity and invalidate stale parsed facts

* fix(python): count bound receivers by method context

Preserve static, free, nested and typed variadic parameters; test renamed target receivers without weakening incompatible-arity rejection. Regenerate capture goldens for receiver metadata and eight mixin fixtures. Record the deliberate missing_target coverage outcome: CI measured Python call drops 5->6 and total call drops 113->114; no shape or scaling threshold relaxed.

* test(python): require a call capture before checking unknown arity

* fix(python): bind subtype dispatch to receiver definition

* test(python): include conditional renamed-receiver target

* fix(python): prove positional mixin targets and report partial coverage

Preserve upstream notebook coordinate mapping and maintainer changes. Reject incompatible/implicit-class targets, retain proven targets across ambiguous alternatives, and report capped or unresolved coverage without confusing edge deduplication.

* fix(python): isolate subtype call proof and preserve lookup boundaries

---------

Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-28 09:42:56 +05:30

354 lines
12 KiB
TypeScript

/**
* Per-hook unit tests for the Python scope-resolution provider hooks
* (RFC #909 Ring 3).
*
* Pairs with `python-fixtures.test.ts` (end-to-end fixtures via
* `extractParsedFile`). These tests target each hook in isolation —
* fast, table-driven, no tree-sitter parsing.
*/
import { describe, it, expect } from 'vitest';
import type {
BindingRef,
Callsite,
ParsedImport,
Scope,
ScopeId,
SymbolDefinition,
TypeRef,
WorkspaceIndex,
} from 'gitnexus-shared';
import {
pythonArityCompatibility,
pythonImportOwningScope,
pythonMergeBindings,
pythonReceiverBinding,
pythonBindingScopeFor,
resolvePythonImportTarget,
} from '../../../../src/core/ingestion/languages/python/index.js';
import { pythonMissingReceiverSubtypeDecision } from '../../../../src/core/ingestion/languages/python/scope-resolver.js';
import { prepareSubtypeDispatchCoverage } from '../../../../src/core/ingestion/scope-resolution/passes/receiver-bound-calls.js';
// ─── Helpers ───────────────────────────────────────────────────────────────
const fnScope = (
typeBindings: Record<string, TypeRef> = {},
kind: Scope['kind'] = 'Function',
): Scope => ({
id: 'scope:t.py#1:0-10:0:Function' as ScopeId,
parent: null,
kind,
range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 },
filePath: 't.py',
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map(Object.entries(typeBindings)),
});
const def = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition => ({
nodeId: 'def:1',
filePath: 't.py',
type: 'Function',
...overrides,
});
const binding = (origin: BindingRef['origin'], nodeId = 'd1'): BindingRef => ({
def: def({ nodeId }),
origin,
});
describe('prepareSubtypeDispatchCoverage', () => {
const target = (nodeId: string) => ({ nodeId });
it('retains proven targets while marking ambiguous subtype coverage incomplete', () => {
const coverage = prepareSubtypeDispatchCoverage(
[target('valid')],
['ambiguous-a', 'ambiguous-b'],
32,
);
expect(coverage.targets).toEqual([target('valid')]);
expect(coverage.missingCandidateIds).toEqual(['ambiguous-a', 'ambiguous-b']);
expect(coverage.partialCoverage).toBe(true);
expect(coverage.recordUnresolved).toBe(true);
});
it('keeps the bounded prefix and reports every target dropped by the cap', () => {
const coverage = prepareSubtypeDispatchCoverage(
[target('one'), target('two'), target('three')],
[],
2,
);
expect(coverage.targets).toEqual([target('one'), target('two')]);
expect(coverage.droppedTargets).toEqual([target('three')]);
expect(coverage.missingCandidateIds).toEqual(['three']);
expect(coverage.recordUnresolved).toBe(true);
});
it('does not report complete known targets unresolved when edge emission deduplicates', () => {
const coverage = prepareSubtypeDispatchCoverage([target('already-emitted')], [], 32);
expect(coverage.partialCoverage).toBe(false);
expect(coverage.recordUnresolved).toBe(false);
});
});
// ─── arityCompatibility ────────────────────────────────────────────────────
describe('pythonArityCompatibility', () => {
const callsite = (arity: number): Callsite => ({ arity });
it('returns "unknown" when both parameter counts are missing', () => {
expect(pythonArityCompatibility(def(), callsite(2))).toBe('unknown');
});
it('compatible when argCount sits inside [required, total]', () => {
expect(
pythonArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 1 }), callsite(2)),
).toBe('compatible');
});
it('compatible at the lower bound', () => {
expect(
pythonArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 1 }), callsite(1)),
).toBe('compatible');
});
it('incompatible when argCount is below required', () => {
expect(
pythonArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 2 }), callsite(1)),
).toBe('incompatible');
});
it('incompatible when argCount exceeds total and no varargs are declared', () => {
expect(
pythonArityCompatibility(def({ parameterCount: 2, requiredParameterCount: 0 }), callsite(5)),
).toBe('incompatible');
});
it('compatible when argCount exceeds total but def takes *args', () => {
expect(
pythonArityCompatibility(
def({ parameterCount: 2, requiredParameterCount: 0, parameterTypes: ['int', '*args'] }),
callsite(7),
),
).toBe('compatible');
});
it('compatible when argCount exceeds total but def takes **kwargs', () => {
expect(
pythonArityCompatibility(
def({ parameterCount: 1, requiredParameterCount: 0, parameterTypes: ['**kwargs'] }),
callsite(3),
),
).toBe('compatible');
});
it('"unknown" for negative or non-finite arities (defensive)', () => {
expect(
pythonArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 1 }), callsite(-1)),
).toBe('unknown');
});
});
// ─── receiverBinding ───────────────────────────────────────────────────────
describe('pythonReceiverBinding', () => {
const userType: TypeRef = {
rawName: 'User',
declaredAtScope: 'scope:fake' as ScopeId,
source: 'self',
};
it('returns the `self` binding when present', () => {
expect(pythonReceiverBinding(fnScope({ self: userType }))).toEqual(userType);
});
it('falls back to `cls` when `self` is absent', () => {
expect(pythonReceiverBinding(fnScope({ cls: userType }))).toEqual(userType);
});
it('returns null for free functions (no `self`/`cls`)', () => {
expect(pythonReceiverBinding(fnScope({}))).toBeNull();
});
it('returns null for non-Function scopes (Class / Module)', () => {
expect(pythonReceiverBinding(fnScope({ self: userType }, 'Class'))).toBeNull();
expect(pythonReceiverBinding(fnScope({ self: userType }, 'Module'))).toBeNull();
});
});
describe('pythonMissingReceiverSubtypeDecision', () => {
const selfType: TypeRef = {
rawName: 'Mixin',
declaredAtScope: 'scope:mixin' as ScopeId,
source: 'self',
};
it('admits instance dispatch before candidate argument-shape filtering', () => {
expect(
pythonMissingReceiverSubtypeDecision(selfType, {
receiverBindingIsStatic: false,
memberName: 'hook',
callArity: 0,
}),
).toBe(true);
expect(
pythonMissingReceiverSubtypeDecision(selfType, {
receiverBindingIsStatic: false,
memberName: 'hook',
callArity: 1,
}),
).toBe(true);
});
it('suppresses private-name mangling', () => {
expect(
pythonMissingReceiverSubtypeDecision(selfType, {
receiverBindingIsStatic: false,
memberName: '__hook',
callArity: 0,
}),
).toBe('suppress');
});
it('declines class receivers without suppressing their ordinary resolution path', () => {
expect(
pythonMissingReceiverSubtypeDecision(
{ ...selfType, source: 'cls' },
{ receiverBindingIsStatic: true, memberName: 'hook', callArity: 0 },
),
).toBe(false);
});
});
// ─── mergeBindings ─────────────────────────────────────────────────────────
describe('pythonMergeBindings — LEGB precedence', () => {
it('local shadows imported', () => {
const local = binding('local', 'L');
const imp = binding('import', 'I');
expect(pythonMergeBindings([imp, local])).toEqual([local]);
});
it('explicit import shadows wildcard', () => {
const imp = binding('import', 'I');
const wc = binding('wildcard', 'W');
expect(pythonMergeBindings([wc, imp])).toEqual([imp]);
});
it('local shadows BOTH imported and wildcard', () => {
const local = binding('local', 'L');
const imp = binding('import', 'I');
const wc = binding('wildcard', 'W');
expect(pythonMergeBindings([wc, imp, local])).toEqual([local]);
});
it('keeps multiple bindings within the same tier (overload-like)', () => {
const a = binding('local', 'A');
const b = binding('local', 'B');
expect(pythonMergeBindings([a, b])).toEqual([a, b]);
});
it('dedupes by DefId — same nodeId collapses', () => {
const a = binding('local', 'A');
const a2 = binding('local', 'A');
expect(pythonMergeBindings([a, a2])).toHaveLength(1);
});
it('returns empty when given empty', () => {
expect(pythonMergeBindings([])).toEqual([]);
});
it('namespace and reexport tie with explicit import (same tier)', () => {
const ns = binding('namespace', 'N');
const re = binding('reexport', 'R');
const imp = binding('import', 'I');
expect(pythonMergeBindings([ns, re, imp])).toHaveLength(3);
});
});
// ─── importOwningScope ─────────────────────────────────────────────────────
describe('pythonImportOwningScope', () => {
const named: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: 'm',
};
it('attaches function-local imports to the function scope', () => {
const fn = fnScope({}, 'Function');
expect(pythonImportOwningScope(named, fn, {} as never)).toBe(fn.id);
});
it('attaches class-body imports to the class scope', () => {
const cls = fnScope({}, 'Class');
expect(pythonImportOwningScope(named, cls, {} as never)).toBe(cls.id);
});
it('returns null (delegate to default) for module-level imports', () => {
const mod = fnScope({}, 'Module');
expect(pythonImportOwningScope(named, mod, {} as never)).toBeNull();
});
});
// ─── bindingScopeFor — defensive ──────────────────────────────────────────
describe('pythonBindingScopeFor', () => {
it('delegates to default for every input', () => {
expect(pythonBindingScopeFor({}, fnScope(), {} as never)).toBeNull();
});
});
// ─── resolveImportTarget ──────────────────────────────────────────────────
describe('resolvePythonImportTarget', () => {
const ws = (fromFile: string, files: string[]): WorkspaceIndex =>
({ fromFile, allFilePaths: new Set(files) }) as unknown as WorkspaceIndex;
it('resolves a relative import via PEP-328 to a concrete file', () => {
const imp: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: '.models',
};
const result = resolvePythonImportTarget(
imp,
ws('app/main.py', ['app/main.py', 'app/models.py']),
);
expect(result).toBe('app/models.py');
});
it('returns null for dynamic-unresolved imports', () => {
const imp: ParsedImport = {
kind: 'dynamic-unresolved',
localName: '',
targetRaw: 'mystery',
};
expect(resolvePythonImportTarget(imp, ws('a.py', ['a.py']))).toBeNull();
});
it('returns null when the workspace context is malformed', () => {
const imp: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: 'm',
};
expect(resolvePythonImportTarget(imp, undefined)).toBeNull();
expect(resolvePythonImportTarget(imp, {} as never)).toBeNull();
});
it('returns null when targetRaw is empty/null', () => {
const imp: ParsedImport = {
kind: 'wildcard',
targetRaw: '',
};
expect(resolvePythonImportTarget(imp, ws('a.py', ['a.py']))).toBeNull();
});
});