GitNexus/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts
Gergo Magyar df2d3a8a81 fix(ci): prettier formatting + Python-migration test adjustments
CI run 24666612657 failed on three jobs. Fixes:

quality/format:
- Prettier --check flagged 3 files after the accumulated branch work.
  Ran prettier --write from repo root (CI's invocation cwd) to apply:
  simple-hooks.ts, resolve-references.ts, python-hooks.test.ts.

tests/{ubuntu,macos,windows} — 9 assertion failures, all traceable to
Python landing in MIGRATED_LANGUAGES (default-on registry-primary):

  - registry-primary-flag.test.ts (3 tests): the 'returns false by
    default' / 'primaryLanguages empty' / 'Python mid-process
    mutation' assertions were written in Ring 2 when MIGRATED_LANGUAGES
    was empty. Rewrote to assert MIGRATED_LANGUAGES membership is the
    default, use Java (unmigrated) for the no-stale-cache test, and
    verify env overrides work in both directions (migrated-off,
    unmigrated-on).
  - call-processor.test.ts (6 tests in SM-10 + D2-widen blocks):
    these exercise the LEGACY call-resolution DAG on .py fixtures.
    processCalls now gates Python out (isRegistryPrimary === true by
    default), returning 0 edges. Added REGISTRY_PRIMARY_PYTHON=false
    override in the relevant beforeEach + restore in afterEach, so
    the legacy DAG runs for these test-local fixtures without
    affecting the production-default behavior.

Local verification: 4126/4126 unit tests pass, prettier clean.
2026-04-20 17:42:27 +01:00

283 lines
9.4 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,
pythonShouldShadow,
pythonShouldCreateScope,
pythonBindingScopeFor,
resolvePythonImportTarget,
} from '../../../../src/core/ingestion/languages/python/index.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,
});
// ─── 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();
});
});
// ─── mergeBindings ─────────────────────────────────────────────────────────
describe('pythonMergeBindings — LEGB precedence', () => {
const scope = fnScope();
it('local shadows imported', () => {
const local = binding('local', 'L');
const imp = binding('import', 'I');
expect(pythonMergeBindings(scope, [imp, local])).toEqual([local]);
});
it('explicit import shadows wildcard', () => {
const imp = binding('import', 'I');
const wc = binding('wildcard', 'W');
expect(pythonMergeBindings(scope, [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(scope, [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(scope, [a, b])).toEqual([a, b]);
});
it('dedupes by DefId — same nodeId collapses', () => {
const a = binding('local', 'A');
const a2 = binding('local', 'A');
expect(pythonMergeBindings(scope, [a, a2])).toHaveLength(1);
});
it('returns empty when given empty', () => {
expect(pythonMergeBindings(scope, [])).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(scope, [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();
});
});
// ─── shouldShadow / shouldCreateScope / bindingScopeFor — defensive ───────
describe('pythonShouldShadow / pythonShouldCreateScope / pythonBindingScopeFor', () => {
it('shouldShadow always returns true (standard LEGB)', () => {
expect(pythonShouldShadow(fnScope(), [])).toBe(true);
});
it('shouldCreateScope always returns true (no @scope.block emitted)', () => {
expect(pythonShouldCreateScope({})).toBe(true);
});
it('bindingScopeFor 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();
});
});