mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
Both LanguageProvider hooks were dead weight: - `shouldShadow` had zero call sites — the interface declared it, Python implemented a trivial always-true no-op, but no consumer ever read it. The shadowing decision lives in pythonMergeBindings and the central merge algorithm, not in a per-scope predicate. - `shouldCreateScope` had one call site in pass1BuildScopes but the only language implementing it (Python) always returned true. No producer ever emits a `@scope.block` for Python, so the hook's "declines to create" branch was unreachable. Other languages didn't implement it at all. Removing both: - Drops the interface declarations in language-provider.ts. - Drops `shouldCreateScope` from ScopeExtractorHooks Pick and from the pass1BuildScopes conditional — the stack-based parent-resolve loop becomes unconditional. - Drops pythonShouldShadow / pythonShouldCreateScope from simple-hooks, the Python index barrel, and the python.ts provider wiring. - Drops the tests that exercised the removed hooks: one block- suppression scenario in scope-extractor.test.ts, one shouldCreateScope test in parse-worker-scope-integration.test.ts, and the pythonShouldShadow / pythonShouldCreateScope always-true assertions in python-hooks.test.ts. pythonBindingScopeFor's delegate-to-default test is preserved in its own describe block. Shadowing itself is unchanged: pythonMergeBindings still runs, LEGB ordering still applies, wildcard transparency is still handled via the merge precedence rules. The hook API just no longer has a vestigial per-scope toggle we decided not to use. Verification: 204/204 test/integration/resolvers/python.test.ts both REGISTRY_PRIMARY_PYTHON=0 and =1. 335/335 scope-resolution + graph unit tests (was 339, net -4 after removing the hook-specific assertions). tsc clean.
273 lines
9.1 KiB
TypeScript
273 lines
9.1 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';
|
|
|
|
// ─── 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();
|
|
});
|
|
});
|
|
|
|
// ─── 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();
|
|
});
|
|
});
|