feat(python): scope-based resolution provider hooks + 62 tests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0eee6c69-fc17-4df5-9ac6-358ab41f5740

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-19 06:52:25 +00:00 • committed by GitHub
parent 06e17389ba
commit 0d2458c3ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1686 additions and 0 deletions

View file

@ -29,6 +29,19 @@ import { pythonVariableConfig } from '../variable-extractors/configs/python.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { pythonCallConfig } from '../call-extractors/configs/python.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
emitPythonScopeCaptures,
interpretPythonImport,
interpretPythonTypeBinding,
pythonArityCompatibility,
pythonBindingScopeFor,
pythonImportOwningScope,
pythonMergeBindings,
pythonReceiverBinding,
pythonShouldCreateScope,
pythonShouldShadow,
resolvePythonImportTarget,
} from './python/scope-hooks.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
@ -77,4 +90,20 @@ export const pythonProvider = defineLanguage({
classExtractor: createClassExtractor(pythonClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.Python),
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
// Python is the first migration. See ./python/scope-hooks.ts for the
// full per-hook rationale and the canonical capture vocabulary in
// ./python/scopes.scm.
emitScopeCaptures: emitPythonScopeCaptures,
interpretImport: interpretPythonImport,
interpretTypeBinding: interpretPythonTypeBinding,
shouldCreateScope: pythonShouldCreateScope,
bindingScopeFor: pythonBindingScopeFor,
importOwningScope: pythonImportOwningScope,
mergeBindings: pythonMergeBindings,
shouldShadow: pythonShouldShadow,
receiverBinding: pythonReceiverBinding,
arityCompatibility: pythonArityCompatibility,
resolveImportTarget: resolvePythonImportTarget,
});

View file

@ -0,0 +1,887 @@
/**
* Python scope-resolution hooks (RFC #909 Ring 3, RFC §5).
*
* Implements every `LanguageProvider` scope hook used by the central
* `ScopeExtractor` + finalize pipeline + `Registry.lookup` for Python.
* Python is the **first** language to migrate to scope-based registry
* resolution; the canonical capture vocabulary lives in `./scopes.scm`
* (kept in source for human-readable spec) and is mirrored into the
* `PYTHON_SCOPE_QUERY` template literal below (the runtime path —
* matches the existing convention in `tree-sitter-queries.ts`).
*
* ## Hook coverage
*
* - `emitScopeCaptures` — owns its own `Parser` instance; runs the
* scope query against `tree-sitter-python` and groups raw query
* matches into `CaptureMatch[]` for the central extractor. Also
* synthesizes captures the static query can't easily express:
* * `@type-binding.self` for `self` inside instance methods
* * `@type-binding.cls` for `cls` inside `@classmethod`-decorated methods
* * One `@import.statement` per imported name in `import a, b` /
* `from m import x, y` so `interpretImport` sees a single name
* per match (the central extractor expects one `ParsedImport`
* per match).
* - `interpretImport` — covers `import X`, `import X as Y`,
* `from X import Y`, `from X import Y as Z`, `from X import *`,
* PEP-328 dotted relative imports, and `importlib.import_module(...)`
* (emitted as `dynamic-unresolved`).
* - `interpretTypeBinding` — converts parameter annotations and
* synthesized `self`/`cls` captures into `ParsedTypeBinding`s.
* - `shouldCreateScope` — Python has no block scope; we never emit
* `@scope.block` so this hook is a defensive no-op (returns `true`
* for everything that does survive).
* - `bindingScopeFor` — `null` for every declaration: the default
* "innermost enclosing scope" is correct for Python because we
* suppress block scopes at emit time, so `for`-targets and
* comprehension variables already resolve to the enclosing function.
* - `importOwningScope` — function-local `from x import Y` attaches
* to the innermost Function/Module/Class scope (default walks to
* Module; we override to keep function-local imports local).
* - `mergeBindings` — Python LEGB precedence: local > import >
* wildcard. Local declarations shadow imports; explicit imports
* shadow `from x import *`.
* - `shouldShadow` — `true` (Python's standard lexical-scoping
* behavior is the central default; the hook exists for `from x
* import *` transparency, which is handled in `mergeBindings` by
* dropping wildcard bindings when a local exists, *not* by toggling
* shadowing). Documented as the explicit no-op so reviewers don't
* re-derive the analysis.
* - `receiverBinding` — looks up `self` / `cls` in the function
* scope's `typeBindings`. Returns the `TypeRef` so `Registry.lookup`
* Step 2 can resolve owner-scoped method dispatch. Today
* `lookupReceiverType` reads the same map directly via
* `IMPLICIT_RECEIVERS`; this hook supplies the same answer through
* the named contract, future-proofing against languages where the
* receiver name is something other than `self`/`this`.
* - `arityCompatibility` — Python `*args`, `**kwargs`, defaults,
* keyword-only params: returns `'compatible'` when the call's
* positional count fits between `requiredParameterCount` and
* `parameterCount` (or there's a `*args`); `'incompatible'` for a
* hard miss; `'unknown'` when the def's metadata is incomplete.
* - `resolveImportTarget` — adapter that delegates to the existing
* `pythonImportConfig` import resolver chain (PEP-328 relative
* resolution + standard suffix matching). Mirrors the wiring in
* `import-target-adapter.ts` for the other languages.
*
* ## `global` / `nonlocal`
*
* Python's `global` and `nonlocal` declarations *re-direct* a name's
* binding scope without changing visibility: a `global x` inside `f()`
* means writes to `x` mutate the module-scope `x`, not a local. From a
* **read-side / call-resolution** point of view this is invisible — the
* name still resolves via LEGB scope-chain walk, and the local-write
* inside `f` is functionally identical to a write-through-alias to the
* module's `x`.
*
* For Ring 3 we therefore treat both as no-ops: `global x` and
* `nonlocal x` produce no `@declaration.*` capture, no `BindingRef`
* shadow, no `mergeBindings` override. Calls and reads of `x` resolve
* via the standard scope-chain walk to the module/enclosing-function
* binding, which is what every reasonable consumer of the call graph
* expects. Edge case: `global x` followed by `x = 1` in a function with
* no module-scope `x` is a *new* module binding at runtime; we under-
* report this (the `@declaration.variable` lands in the function scope
* because that's where the assignment lexically lives). Documenting
* the gap; fix is a Ring 4 concern when `global`/`nonlocal` get first-
* class capture support.
*/
import Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
import {
type Capture,
type CaptureMatch,
type ParsedImport,
type ParsedTypeBinding,
type Scope,
type ScopeId,
type ScopeTree,
type SymbolDefinition,
type Callsite,
type TypeRef,
type BindingRef,
type WorkspaceIndex,
} from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { resolvePythonImportInternal } from '../../import-resolvers/python.js';
// ─── Tree-sitter query string ──────────────────────────────────────────────
//
// Mirrors `./scopes.scm` (kept in source for spec/readability). When you
// edit one, edit both — both must agree because tests reference the
// embedded constant and the file documents the contract.
export const PYTHON_SCOPE_QUERY = `
;; Scopes
(module) @scope.module
(class_definition) @scope.class
(function_definition) @scope.function
;; Declarations
(class_definition
name: (identifier) @declaration.name) @declaration.class
(function_definition
name: (identifier) @declaration.name) @declaration.function
(assignment
left: (identifier) @declaration.name) @declaration.variable
;; Declarations: for-loop target — Python for-statements do NOT introduce
;; a new scope, so the loop variable binds in the enclosing function/module
;; scope. We emit it as a Variable declaration so Pass-2 attaches it.
(for_statement
left: (identifier) @declaration.name) @declaration.variable
;; Imports — single anchor per statement; interpretImport decomposes
(import_statement) @import.statement
(import_from_statement) @import.statement
;; Type bindings (parameter annotations)
(typed_parameter
(identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.parameter
(typed_default_parameter
name: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.parameter
;; References — calls
(call
function: (identifier) @reference.name) @reference.call.free
(call
function: (attribute
object: (_) @reference.receiver
attribute: (identifier) @reference.name)) @reference.call.member
`;
// ─── Lazy parser singleton ─────────────────────────────────────────────────
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
function getParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Python as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
function getQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(
Python as Parameters<Parser['setLanguage']>[0],
PYTHON_SCOPE_QUERY,
);
}
return _query;
}
// ─── emitScopeCaptures ─────────────────────────────────────────────────────
/**
* Parse a Python source file with tree-sitter-python and emit
* `CaptureMatch[]` matching RFC §5.1 conventions. One match per query
* pattern firing, plus synthesized matches that the static query can't
* conveniently express:
*
* * `@import.statement` per imported name (split out from
* `import a, b` and `from m import x, y`)
* * `@type-binding.self` and `@type-binding.cls` from method first
* parameters
*
* Pure given the input source text. No I/O, no globals consulted.
*/
export function emitPythonScopeCaptures(
sourceText: string,
_filePath: string,
): readonly CaptureMatch[] {
const parser = getParser();
const query = getQuery();
const tree = parser.parse(sourceText);
const rawMatches = query.matches(tree.rootNode);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
// Group captures by their tag name. Tree-sitter `Match.captures` is
// already structured as `{ name: string; node: SyntaxNode }[]`.
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
// The central extractor expects capture names to be prefixed with
// `@`. tree-sitter strips the leading `@`, so we put it back.
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
}
// Anchor topic guard — empty matches (no captures whose name we
// recognize) are silently skipped; the central extractor would
// bucket them as `'unknown'` and drop them anyway.
if (Object.keys(grouped).length === 0) continue;
// ── Import-statement decomposition ────────────────────────────────
//
// `interpretImport` returns ONE `ParsedImport` per call. To honor
// that contract for `import a, b` and `from m import x, y`, we
// split here: emit one import-statement match per imported name,
// each carrying the full statement node text + a synthesized
// `@import.name` capture pointing at the per-name fragment.
if (grouped['@import.statement'] !== undefined) {
const stmtCapture = grouped['@import.statement'];
// The statement node — both `import_statement` and
// `import_from_statement` share the same range as the matched
// capture, so try both type filters.
const stmtNode =
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_from_statement') ??
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_statement');
if (stmtNode !== null) {
const split = splitImportStatement(stmtNode);
for (const piece of split) out.push(piece);
} else {
// Defensive fallback: emit the raw match.
out.push(grouped);
}
continue;
}
// ── Function-scope synthesized captures ───────────────────────────
//
// For each `@scope.function` we emit, we ALSO walk its first
// parameter to detect `self` / `cls` and emit a
// `@type-binding.self` / `@type-binding.cls` match so the central
// Pass-4 attaches a TypeRef to the function scope's typeBindings.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const fnNode = findNodeAtRange(
tree.rootNode,
grouped['@scope.function']!.range,
'function_definition',
);
if (fnNode !== null) {
const synth = synthesizeReceiverTypeBinding(fnNode);
if (synth !== null) out.push(synth);
}
continue;
}
out.push(grouped);
}
return out;
}
// ─── interpretImport ───────────────────────────────────────────────────────
/**
* Convert a `@import.statement` `CaptureMatch` (already decomposed by
* `emitPythonScopeCaptures` — one imported name per match) into a
* `ParsedImport`. Returns `null` if the match is malformed (wildcard
* captures may carry only `targetRaw`).
*/
export function interpretPythonImport(captures: CaptureMatch): ParsedImport | null {
// Markers attached by `splitImportStatement` (synthesized below):
// `@import.kind` : 'plain' | 'aliased' | 'from' | 'from-alias' | 'wildcard' | 'dynamic'
// `@import.name` : the imported symbol name (or module name for plain imports)
// `@import.alias` : the local alias name (for `as` forms)
// `@import.source`: the module path (always present except for `dynamic`)
const kindCap = captures['@import.kind'];
const nameCap = captures['@import.name'];
const aliasCap = captures['@import.alias'];
const sourceCap = captures['@import.source'];
const kind = kindCap?.text;
if (kind === undefined) return null;
switch (kind) {
case 'plain': {
// `import numpy`
if (sourceCap === undefined) return null;
return {
kind: 'namespace',
localName: sourceCap.text.split('.')[0]!, // `import a.b.c` exposes `a`
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'aliased': {
// `import numpy as np`
if (sourceCap === undefined || aliasCap === undefined) return null;
return {
kind: 'namespace',
localName: aliasCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'from': {
// `from m import x`
if (sourceCap === undefined || nameCap === undefined) return null;
return {
kind: 'named',
localName: nameCap.text,
importedName: nameCap.text,
targetRaw: sourceCap.text,
};
}
case 'from-alias': {
// `from m import x as y`
if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) return null;
return {
kind: 'alias',
localName: aliasCap.text,
importedName: nameCap.text,
alias: aliasCap.text,
targetRaw: sourceCap.text,
};
}
case 'wildcard': {
// `from m import *`
if (sourceCap === undefined) return null;
return { kind: 'wildcard', targetRaw: sourceCap.text };
}
case 'dynamic': {
// `importlib.import_module(...)` — preserved for diagnostics.
return {
kind: 'dynamic-unresolved',
localName: '',
targetRaw: sourceCap?.text ?? null,
};
}
default:
return null;
}
}
// ─── interpretTypeBinding ──────────────────────────────────────────────────
/**
* Build a `ParsedTypeBinding` from `@type-binding.parameter`,
* `@type-binding.self`, or `@type-binding.cls` captures.
*/
export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
// Synthesized `self` / `cls` captures carry `@type-binding.name` and
// `@type-binding.type` directly — same shape as parameter
// annotations, source differs. The shared `@type-binding.name` and
// `@type-binding.type` carry text we trust verbatim.
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Strip leading/trailing whitespace and surrounding quotes (PEP 484
// forward references: `def f(x: "User")`).
const rawType = stripForwardRefQuotes(typeCap.text.trim());
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
else if (captures['@type-binding.cls'] !== undefined) source = 'self'; // `cls` is a self-like receiver
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
// ─── shouldCreateScope ─────────────────────────────────────────────────────
/**
* Defensive: we never emit `@scope.block`, so this hook only ever sees
* scopes we explicitly want to materialize. Returns `true` for everything.
*/
export function pythonShouldCreateScope(_captures: CaptureMatch): boolean {
return true;
}
// ─── bindingScopeFor ───────────────────────────────────────────────────────
/**
* Python has no block scope, so the central extractor's "innermost
* enclosing scope" default is already correct: `for x in ...` creates
* `x` in the enclosing function/module scope (because we don't emit a
* `@scope.block` for the for-loop), comprehension variables stay in
* their expression context, etc.
*
* Returns `null` to delegate to the default in every case.
*/
export function pythonBindingScopeFor(
_decl: CaptureMatch,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
// ─── importOwningScope ─────────────────────────────────────────────────────
/**
* Function-local `from x import Y` should attach the binding to the
* function scope, not the module. The central default walks to the
* nearest Module/Namespace scope; we override to keep `Function`-local
* imports inside the function.
*
* Class-body imports are unusual but legal: `class A: import x` — by
* Python's semantics `x` is a class attribute. We attach to the class
* scope.
*/
export function pythonImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
if (innermost.kind === 'Function' || innermost.kind === 'Class') return innermost.id;
// Module / Namespace / Block / Expression — fall through to default.
return null;
}
// ─── mergeBindings ─────────────────────────────────────────────────────────
/**
* Python LEGB precedence: a local binding shadows imports, an explicit
* import shadows a `from x import *` wildcard. Within a tier the last
* write wins (Python semantics).
*
* The `LanguageProvider.mergeBindings(scope, bindings)` contract gives
* us every `BindingRef` for a single (scope, name) pair; we return the
* survivors after applying the LEGB tier filter and de-duping by
* `DefId`.
*/
export function pythonMergeBindings(
_scope: Scope,
bindings: readonly BindingRef[],
): readonly BindingRef[] {
const all: BindingRef[] = [...bindings];
if (all.length === 0) return all;
// Tier ranking — lower wins in shadowing.
const tier = (b: BindingRef): number => {
switch (b.origin) {
case 'local':
return 0;
case 'reexport':
case 'import':
case 'namespace':
return 1;
case 'wildcard':
return 2;
default:
return 3;
}
};
let bestTier = Number.POSITIVE_INFINITY;
for (const b of all) bestTier = Math.min(bestTier, tier(b));
const survivors = all.filter((b) => tier(b) === bestTier);
// Dedupe by DefId — last write wins.
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}
// ─── shouldShadow ──────────────────────────────────────────────────────────
/**
* Standard Python lexical scoping. The central default (`true` — any
* binding shadows) is correct. Wildcard transparency is handled by
* `mergeBindings` (drop wildcard origins when a local exists), not by
* toggling shadowing here.
*
* Implemented as an explicit pass-through so reviewers don't have to
* re-derive the analysis from absence.
*/
export function pythonShouldShadow(_scope: Scope, _bindings: readonly BindingRef[]): boolean {
return true;
}
// ─── receiverBinding ───────────────────────────────────────────────────────
/**
* Look up `self` or `cls` in the function scope's type bindings. Returns
* `null` for free functions (no `self`/`cls`) and for non-Function scopes.
*/
export function pythonReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return (
functionScope.typeBindings.get('self') ?? functionScope.typeBindings.get('cls') ?? null
);
}
// ─── arityCompatibility ────────────────────────────────────────────────────
/**
* Python arity check, accommodating `*args`, `**kwargs`, and defaults.
*
* The `def` metadata we care about (set by the existing Python method/
* function extractor):
* - `parameterCount` — total positional + keyword params
* - `requiredParameterCount` — min required (excludes defaults / `*args` / `**kwargs`)
* - `parameterTypes` — present when types are known; we also use it
* as a "we have varargs" hint (`'*args'`,
* `'**kwargs'` literals appear in the array).
*
* Verdicts:
* - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`,
* OR the def takes `*args` (then any `argCount >= required` ok).
* - `'incompatible'` — argCount is below required, OR above max with no `*args`.
* - `'unknown'` — def metadata is absent / incomplete.
*
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized but
* still considered when no compatible candidate exists), per RFC §4.
*/
export function pythonArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
// Detect varargs/kwargs from parameterTypes if present (the Python
// method extractor stores `'*args'`/`'**kwargs'` in this list).
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === '*args' || t === '**kwargs' || t.startsWith('*'));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}
// ─── resolveImportTarget ───────────────────────────────────────────────────
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
* Delegates to the existing `resolvePythonImportInternal` (PEP-328
* relative resolution + standard suffix matching). The
* `WorkspaceIndex` is opaque at this layer; consumers wire a
* `PythonResolveContext` shape carrying `fromFile` + `allFilePaths`.
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
*/
export interface PythonResolveContext {
readonly fromFile: string;
readonly allFilePaths: Set<string>;
}
export function resolvePythonImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
const ctx = workspaceIndex as PythonResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
return resolvePythonImportInternal(ctx.fromFile, parsedImport.targetRaw, ctx.allFilePaths);
}
// ─── Internal helpers ──────────────────────────────────────────────────────
function nodeToCapture(name: string, node: SyntaxNode): Capture {
return {
name,
range: {
startLine: node.startPosition.row + 1, // 1-based per RFC §2.1
startCol: node.startPosition.column,
endLine: node.endPosition.row + 1,
endCol: node.endPosition.column,
},
text: node.text,
};
}
function rangeMatches(
node: SyntaxNode,
range: { startLine: number; startCol: number; endLine: number; endCol: number },
): boolean {
return (
node.startPosition.row + 1 === range.startLine &&
node.startPosition.column === range.startCol &&
node.endPosition.row + 1 === range.endLine &&
node.endPosition.column === range.endCol
);
}
/** Walk subtree to find a node whose range exactly matches AND whose type
* matches `expectedType` (when given). When multiple nodes share the
* range (e.g., `function_definition` and its inner `block` body for a
* one-liner), the type filter disambiguates to the right one.
* O(n) over the candidate subtree — fine for the small subset of
* synthesizable matches per file. */
function findNodeAtRange(
root: SyntaxNode,
range: { startLine: number; startCol: number; endLine: number; endCol: number },
expectedType?: string,
): SyntaxNode | null {
if (rangeMatches(root, range) && (expectedType === undefined || root.type === expectedType)) {
return root;
}
// Walk only into subtrees whose span covers the target range — keeps
// this from being O(file size) per call in practice.
const startRow = range.startLine - 1;
const endRow = range.endLine - 1;
for (let i = 0; i < root.namedChildCount; i++) {
const child = root.namedChild(i);
if (child === null) continue;
if (child.endPosition.row < startRow) continue;
if (child.startPosition.row > endRow) break;
const hit = findNodeAtRange(child, range, expectedType);
if (hit !== null) return hit;
}
return null;
}
/**
* Synthesize `@type-binding.self` / `@type-binding.cls` captures for the
* first parameter of a `function_definition` that lives directly inside
* a `class_definition`. Returns `null` for free functions, lambdas, or
* static methods.
*/
function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch | null {
// Walk up to the enclosing class_definition, ignoring decorators.
const enclosingClass = findEnclosingClassDefinition(fnNode);
if (enclosingClass === null) return null;
// Skip @staticmethod-decorated methods (no implicit receiver).
const isStatic = hasDecorator(fnNode, 'staticmethod');
if (isStatic) return null;
const isClassmethod = hasDecorator(fnNode, 'classmethod');
const params = fnNode.childForFieldName('parameters');
if (params === null) return null;
const first = firstNamedParameter(params);
if (first === null) return null;
const className = classDefinitionName(enclosingClass);
if (className === null) return null;
const firstName = firstParameterName(first);
if (firstName === null) return null;
// Receiver convention: instance methods get `self`, classmethods get `cls`.
// We trust the AST literal name (Python convention is strict in practice).
if (isClassmethod) {
return {
'@type-binding.cls': nodeToCapture('@type-binding.cls', first),
'@type-binding.name': syntheticCapture('@type-binding.name', first, firstName),
'@type-binding.type': syntheticCapture('@type-binding.type', first, className),
};
}
return {
'@type-binding.self': nodeToCapture('@type-binding.self', first),
'@type-binding.name': syntheticCapture('@type-binding.name', first, firstName),
'@type-binding.type': syntheticCapture('@type-binding.type', first, className),
};
}
function syntheticCapture(name: string, atNode: SyntaxNode, text: string): Capture {
return {
name,
range: {
startLine: atNode.startPosition.row + 1,
startCol: atNode.startPosition.column,
endLine: atNode.endPosition.row + 1,
endCol: atNode.endPosition.column,
},
text,
};
}
function findEnclosingClassDefinition(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (cur.type === 'class_definition') return cur;
if (cur.type === 'function_definition') return null; // nested fn — not a method
cur = cur.parent;
}
return null;
}
function classDefinitionName(classNode: SyntaxNode): string | null {
const nameField = classNode.childForFieldName('name');
return nameField?.text ?? null;
}
function hasDecorator(fnNode: SyntaxNode, decoratorName: string): boolean {
// Decorators are siblings BEFORE function_definition under decorated_definition,
// OR they're children of the parent decorated_definition.
const parent = fnNode.parent;
if (parent === null || parent.type !== 'decorated_definition') return false;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child === null || child.type !== 'decorator') continue;
// Decorator text starts with `@`. Strip it and split off any call args.
const text = child.text.replace(/^@/, '').split('(')[0]!.trim();
// Match `staticmethod`, `classmethod`, `<module>.staticmethod`, etc.
const tail = text.split('.').pop();
if (tail === decoratorName) return true;
}
return false;
}
function firstNamedParameter(parameters: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < parameters.namedChildCount; i++) {
const child = parameters.namedChild(i);
if (child === null) continue;
// Skip `*` / `/` markers.
if (child.type === 'positional_separator' || child.type === 'keyword_separator') continue;
return child;
}
return null;
}
function firstParameterName(param: SyntaxNode): string | null {
// identifier — bare param: `def f(self): ...`
if (param.type === 'identifier') return param.text;
// typed_parameter / default_parameter / typed_default_parameter:
// first child holds the identifier / pattern.
const ident = param.childForFieldName('name') ?? findIdentifierChild(param);
return ident?.text ?? null;
}
function findIdentifierChild(node: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === 'identifier') return child;
}
return null;
}
/**
* Decompose a `tree-sitter-python` import statement into one
* `CaptureMatch` per imported name. Carries `@import.kind` /
* `@import.name` / `@import.alias` / `@import.source` markers that
* `interpretPythonImport` reads.
*/
function splitImportStatement(stmtNode: SyntaxNode): CaptureMatch[] {
const out: CaptureMatch[] = [];
if (stmtNode.type === 'import_statement') {
// `import a, b as c, d.e`
for (let i = 0; i < stmtNode.namedChildCount; i++) {
const child = stmtNode.namedChild(i);
if (child === null) continue;
if (child.type === 'dotted_name') {
out.push(buildImportMatch(stmtNode, {
kind: 'plain',
source: child.text,
name: child.text.split('.')[0]!,
atNode: child,
}));
} else if (child.type === 'aliased_import') {
const dotted = findChildOfType(child, 'dotted_name');
const alias = findChildOfType(child, 'identifier');
if (dotted !== null && alias !== null) {
out.push(buildImportMatch(stmtNode, {
kind: 'aliased',
source: dotted.text,
name: dotted.text,
alias: alias.text,
atNode: child,
}));
}
}
}
return out;
}
if (stmtNode.type === 'import_from_statement') {
// `from m import a, b as c` / `from m import *` / `from . import x`
const moduleField = stmtNode.childForFieldName('module_name');
const moduleText = moduleField?.text ?? '';
// Wildcard? tree-sitter-python represents `*` as a `wildcard_import` child.
const wildcardChild = findChildOfType(stmtNode, 'wildcard_import');
if (wildcardChild !== null) {
out.push(buildImportMatch(stmtNode, {
kind: 'wildcard',
source: moduleText,
name: '*',
atNode: wildcardChild,
}));
return out;
}
// Names = every dotted_name / aliased_import that isn't the module.
for (let i = 0; i < stmtNode.namedChildCount; i++) {
const child = stmtNode.namedChild(i);
if (child === null) continue;
if (moduleField !== null && child.startIndex === moduleField.startIndex) continue;
if (child.type === 'dotted_name') {
out.push(buildImportMatch(stmtNode, {
kind: 'from',
source: moduleText,
name: child.text,
atNode: child,
}));
} else if (child.type === 'aliased_import') {
const dotted = findChildOfType(child, 'dotted_name');
const alias = findChildOfType(child, 'identifier');
if (dotted !== null && alias !== null) {
out.push(buildImportMatch(stmtNode, {
kind: 'from-alias',
source: moduleText,
name: dotted.text,
alias: alias.text,
atNode: child,
}));
}
}
}
return out;
}
return out;
}
function findChildOfType(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === type) return child;
}
return null;
}
function buildImportMatch(
stmtNode: SyntaxNode,
spec: {
kind: 'plain' | 'aliased' | 'from' | 'from-alias' | 'wildcard' | 'dynamic';
source: string;
name: string;
alias?: string;
atNode: SyntaxNode;
},
): CaptureMatch {
const stmtCap = nodeToCapture('@import.statement', stmtNode);
const m: Record<string, Capture> = {
'@import.statement': stmtCap,
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
if (spec.alias !== undefined) {
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
}
return m;
}
function stripForwardRefQuotes(text: string): string {
if (
(text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))
) {
return text.slice(1, -1);
}
return text;
}

View file

@ -0,0 +1,105 @@
; Tree-sitter Python query — RFC §5.1 captures for scope-based resolution
; (RFC #909 Ring 3, language: Python — first migration).
;
; Capture vocabulary consumed by the central `ScopeExtractor`:
;
; @scope.module — file root
; @scope.class — class body
; @scope.function — def / async def body (functions, methods, lambdas)
;
; @declaration.class + @declaration.name
; @declaration.function + @declaration.name
; @declaration.method + @declaration.name (functions inside class bodies)
; @declaration.variable + @declaration.name (module/class/function-level assignments)
;
; @import.statement — anchor for `interpretImport`. The hook reads
; the captured source text and tokenizes it into
; a `ParsedImport`. We do NOT decompose
; `import X, Y` here at query time — `interpretImport`
; splits multi-target statements into N matches.
;
; @type-binding.parameter + @type-binding.name + @type-binding.type
;
; @reference.call.free + @reference.name (e.g. `print(x)`)
; @reference.call.member + @reference.name + @reference.receiver
; (e.g. `obj.save()`)
;
; Python has NO block scope: `if`, `for`, `while`, `try`, `with`, `match`
; bodies do NOT introduce a new lexical scope (PEP 8 / language reference).
; We therefore do NOT emit `@scope.block` captures for those constructs;
; their contained declarations land in the enclosing function/class/module
; scope automatically (RFC §5.1 "transparent block" behavior).
;
; `@reference.call.constructor` is intentionally absent: Python has no
; `new` keyword. A call to a class is syntactically identical to a call to
; a free function; the registry decides constructor-vs-call by inspecting
; the resolved `def.type`. Stays out of the parser to avoid duplicating
; that logic in tree-sitter.
; ─── Scopes ────────────────────────────────────────────────────────────────
(module) @scope.module
(class_definition) @scope.class
(function_definition) @scope.function
; ─── Declarations: class / function ────────────────────────────────────────
(class_definition
name: (identifier) @declaration.name) @declaration.class
(function_definition
name: (identifier) @declaration.name) @declaration.function
; ─── Declarations: assignments (module-, class-, function-level variables)
;
; Note: tree-sitter-python parses both annotated and plain assignments as
; `(assignment left: ...)` — typed and untyped both surface here. The
; central extractor de-dupes by `nodeId` (file#line:col:type:name).
(assignment
left: (identifier) @declaration.name) @declaration.variable
; for-loop target — Python `for` does NOT introduce a new scope; the
; loop variable binds in the enclosing function/module scope.
(for_statement
left: (identifier) @declaration.name) @declaration.variable
; ─── Imports ───────────────────────────────────────────────────────────────
;
; The whole statement is the anchor — `interpretImport` decomposes it.
; We tag both shapes so the hook sees a single capture name (`@import.statement`).
(import_statement) @import.statement
(import_from_statement) @import.statement
; ─── Type bindings: parameter annotations ──────────────────────────────────
;
; `def f(x: User)` — `x` is bound to `User` in `f`'s scope.
;
; `interpretTypeBinding` reads `@type-binding.name` (the parameter name)
; and `@type-binding.type` (the annotation source text) to produce a
; `ParsedTypeBinding { boundName, rawTypeName, source: 'parameter-annotation' }`.
(typed_parameter
(identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.parameter
(typed_default_parameter
name: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.parameter
; ─── References: calls ─────────────────────────────────────────────────────
;
; Free call: `print(x)` — function is a bare identifier
; Member call: `obj.save()` — function is an attribute access
(call
function: (identifier) @reference.name) @reference.call.free
(call
function: (attribute
object: (_) @reference.receiver
attribute: (identifier) @reference.name)) @reference.call.member

View file

@ -0,0 +1,367 @@
/**
* End-to-end fixture tests for the Python scope-resolution migration
* (RFC #909 Ring 3, RFC §5.1 — first-rollout language).
*
* Each fixture:
* 1. Drives `extractPythonScopeCaptures` on a real Python source string.
* 2. Threads the captures through the central `ScopeExtractor` (via
* `extractParsedFile`) — exactly the path `parse-worker.ts`
* executes at ingest time.
* 3. Asserts on the resulting `ParsedFile` (scopes / declarations /
* imports / type bindings / reference sites).
*
* Coverage matrix (≥30 cases, per Ring 3 deliverables):
*
* * Module / function / class scope construction
* * No-block-scope semantics (if / for / while / with / try)
* * Class- and function-local declarations + variables
* * Imports: plain, aliased, multi-target, from, from-as, multi-from,
* wildcard, dotted-relative
* * Function-local imports
* * Receiver type binding: `self` for instance methods, `cls` for
* classmethods; no binding for `@staticmethod`; no binding for free
* functions
* * Parameter type annotations (typed_parameter / typed_default_parameter
* / forward-ref strings)
* * Call references: free vs member, with explicit-receiver capture
* * `global` / `nonlocal` no-op behaviour (documented gap)
*/
import { describe, it, expect } from 'vitest';
import type { ParsedFile } from 'gitnexus-shared';
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
import { pythonProvider } from '../../../../src/core/ingestion/languages/python.js';
// ─── Test helper ───────────────────────────────────────────────────────────
function parse(src: string, filePath = 'test.py'): ParsedFile {
const result = extractParsedFile(pythonProvider, src, filePath);
if (result === undefined) {
throw new Error(
`extractParsedFile returned undefined for:\n${src}\n— check warnings or capture shape`,
);
}
return result;
}
function scopesByKind(file: ParsedFile, kind: string) {
return file.scopes.filter((s) => s.kind === kind);
}
function findDef(file: ParsedFile, name: string) {
return file.localDefs.find((d) => d.qualifiedName === name);
}
// ─── Pass 1: scope tree ────────────────────────────────────────────────────
describe('Python scopes — module / class / function', () => {
it('case 01: minimal module produces a single Module scope', () => {
// Empty source produces a zero-range module node; the central
// extractor treats zero-range scopes as malformed (and rightly so —
// they collide with sibling-overlap detection on subsequent reparses).
// Real Python files always have at least a newline.
const f = parse('pass\n');
expect(f.scopes).toHaveLength(1);
expect(f.scopes[0]!.kind).toBe('Module');
});
it('case 02: module-level assignment produces a Variable declaration in Module scope', () => {
const f = parse('x = 1\n');
expect(scopesByKind(f, 'Module')).toHaveLength(1);
expect(findDef(f, 'x')?.type).toBe('Variable');
});
it('case 03: top-level def produces a Function scope under Module', () => {
const f = parse('def foo():\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
const mod = scopesByKind(f, 'Module')[0]!;
expect(fn.parent).toBe(mod.id);
expect(findDef(f, 'foo')?.type).toBe('Function');
});
it('case 04: top-level class produces a Class scope under Module', () => {
const f = parse('class A:\n pass\n');
const cls = scopesByKind(f, 'Class')[0]!;
const mod = scopesByKind(f, 'Module')[0]!;
expect(cls.parent).toBe(mod.id);
expect(findDef(f, 'A')?.type).toBe('Class');
});
it('case 05: method nests Function under Class under Module', () => {
const f = parse('class A:\n def m(self):\n pass\n');
const mod = scopesByKind(f, 'Module')[0]!;
const cls = scopesByKind(f, 'Class')[0]!;
const fn = scopesByKind(f, 'Function')[0]!;
expect(cls.parent).toBe(mod.id);
expect(fn.parent).toBe(cls.id);
});
it('case 06: nested function nests Function under Function', () => {
const f = parse('def outer():\n def inner():\n pass\n');
const fns = scopesByKind(f, 'Function');
expect(fns).toHaveLength(2);
const outer = fns.find((s) => s.range.startLine === 1)!;
const inner = fns.find((s) => s.range.startLine === 2)!;
expect(inner.parent).toBe(outer.id);
});
});
// ─── Pass 1: no block scope ────────────────────────────────────────────────
describe('Python scopes — no block scope (PEP language reference)', () => {
it('case 07: `if` body does NOT create a scope; declarations land in enclosing fn', () => {
const f = parse('def f():\n if True:\n x = 1\n');
expect(scopesByKind(f, 'Block')).toHaveLength(0);
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.bindings.has('x')).toBe(true);
});
it('case 08: `for` target binds in enclosing function scope, not in for body', () => {
const f = parse('def f():\n for i in range(10):\n pass\n');
expect(scopesByKind(f, 'Block')).toHaveLength(0);
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.bindings.has('i')).toBe(true);
});
it('case 09: `while`/`try`/`with` bodies do not produce Block scopes', () => {
const f = parse(
`def f():
while True:
a = 1
try:
b = 2
except Exception:
c = 3
with open('x') as fh:
d = 4
`,
);
expect(scopesByKind(f, 'Block')).toHaveLength(0);
const fn = scopesByKind(f, 'Function')[0]!;
for (const name of ['a', 'b', 'c', 'd']) expect(fn.bindings.has(name)).toBe(true);
});
});
// ─── Pass 3: imports ──────────────────────────────────────────────────────
describe('Python imports — interpretImport', () => {
it('case 10: `import numpy` → namespace import', () => {
const f = parse('import numpy\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' },
]);
});
it('case 11: `import numpy as np` → namespace import with rename', () => {
const f = parse('import numpy as np\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' },
]);
});
it('case 12: `import a.b.c` exposes the leading segment as the local name', () => {
const f = parse('import a.b.c\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'a', importedName: 'a.b.c', targetRaw: 'a.b.c' },
]);
});
it('case 13: `import a, b as c` decomposes into one ParsedImport per name', () => {
const f = parse('import a, b as c\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'a', importedName: 'a', targetRaw: 'a' },
{ kind: 'namespace', localName: 'c', importedName: 'b', targetRaw: 'b' },
]);
});
it('case 14: `from m import x` → named import', () => {
const f = parse('from m import x\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm' },
]);
});
it('case 15: `from m import x as y` → alias import', () => {
const f = parse('from m import x as y\n');
expect(f.parsedImports).toEqual([
{ kind: 'alias', localName: 'y', importedName: 'x', alias: 'y', targetRaw: 'm' },
]);
});
it('case 16: `from m import x, y, z` decomposes into three ParsedImports', () => {
const f = parse('from m import x, y, z\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm' },
{ kind: 'named', localName: 'y', importedName: 'y', targetRaw: 'm' },
{ kind: 'named', localName: 'z', importedName: 'z', targetRaw: 'm' },
]);
});
it('case 17: `from m import *` → wildcard', () => {
const f = parse('from m import *\n');
expect(f.parsedImports).toEqual([{ kind: 'wildcard', targetRaw: 'm' }]);
});
it('case 18: PEP-328 dotted relative import `from .pkg import x`', () => {
const f = parse('from .pkg import x\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: '.pkg' },
]);
});
it('case 19: PEP-328 parent-relative import `from ..pkg.sub import x`', () => {
const f = parse('from ..pkg.sub import x\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: '..pkg.sub' },
]);
});
});
// ─── Imports inside functions ─────────────────────────────────────────────
describe('Python imports — function-local', () => {
it('case 20: function-local `from x import Y` is captured (visible to importOwningScope)', () => {
const f = parse('def loader():\n from m import X\n');
// Decomposed at parse time; finalize will route via importOwningScope.
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'm' },
]);
});
});
// ─── Pass 4: type bindings ────────────────────────────────────────────────
describe('Python type bindings — parameter annotations + self/cls', () => {
it('case 21: typed parameter `def f(x: User)` binds x → User on function scope', () => {
const f = parse('def f(x: User):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
const tb = fn.typeBindings.get('x');
expect(tb).toBeDefined();
expect(tb!.rawName).toBe('User');
expect(tb!.source).toBe('parameter-annotation');
});
it('case 22: typed default parameter `def f(x: int = 0)` is captured', () => {
const f = parse('def f(x: int = 0):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.get('x')?.rawName).toBe('int');
});
it('case 23: forward-ref string `def f(x: "User")` is unquoted', () => {
const f = parse('def f(x: "User"):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.get('x')?.rawName).toBe('User');
});
it('case 24: instance method gets self → ClassName as `self` source', () => {
const f = parse('class A:\n def m(self):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
const self = fn.typeBindings.get('self');
expect(self).toBeDefined();
expect(self!.rawName).toBe('A');
expect(self!.source).toBe('self');
});
it('case 25: `@classmethod`-decorated method gets cls → ClassName', () => {
const f = parse(
`class A:
@classmethod
def make(cls):
pass
`,
);
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.get('cls')?.rawName).toBe('A');
expect(fn.typeBindings.has('self')).toBe(false);
});
it('case 26: `@staticmethod`-decorated method gets NO implicit receiver', () => {
const f = parse(
`class A:
@staticmethod
def util(x):
pass
`,
);
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.has('self')).toBe(false);
expect(fn.typeBindings.has('cls')).toBe(false);
});
it('case 27: free function gets NO `self`/`cls` binding', () => {
const f = parse('def free(x):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.has('self')).toBe(false);
expect(fn.typeBindings.has('cls')).toBe(false);
});
it('case 28: nested function inside method does NOT inherit `self`', () => {
const f = parse(
`class A:
def m(self):
def inner():
pass
`,
);
const inner = scopesByKind(f, 'Function').find((s) => s.range.startLine === 3)!;
expect(inner.typeBindings.has('self')).toBe(false);
});
});
// ─── Pass 5: reference sites ──────────────────────────────────────────────
describe('Python reference sites — calls', () => {
it('case 29: free call `print(x)` records a call reference', () => {
const f = parse('def f():\n print(1)\n');
const calls = f.referenceSites.filter((r) => r.kind === 'call');
expect(calls.some((c) => c.name === 'print' && c.callForm === 'free')).toBe(true);
});
it('case 30: member call `obj.save()` records explicit receiver `obj`', () => {
const f = parse('def f(obj):\n obj.save()\n');
const member = f.referenceSites.find((r) => r.kind === 'call' && r.name === 'save')!;
expect(member.callForm).toBe('member');
expect(member.explicitReceiver).toEqual({ name: 'obj' });
});
it('case 31: chained member call `a.b.c()` captures `c` with receiver `a.b`', () => {
const f = parse('def f(a):\n a.b.c()\n');
const member = f.referenceSites.find((r) => r.kind === 'call' && r.name === 'c')!;
expect(member.callForm).toBe('member');
expect(member.explicitReceiver?.name).toBe('a.b');
});
});
// ─── global / nonlocal — documented under-reporting ───────────────────────
describe('Python `global`/`nonlocal` — documented behavior', () => {
it('case 32: `global x` inside a function does NOT promote the binding to module scope', () => {
// Documented limitation: the assignment lexically lives in `f`, so
// we attach `x` to f's scope. A future Ring may re-bind via
// bindingScopeFor; for Ring 3 this is expected behavior.
const f = parse(
`x = 0
def f():
global x
x = 1
`,
);
const fn = scopesByKind(f, 'Function')[0]!;
const mod = scopesByKind(f, 'Module')[0]!;
expect(mod.bindings.has('x')).toBe(true); // module-level x = 0
expect(fn.bindings.has('x')).toBe(true); // local x = 1 — under-reported as fn-local
});
it('case 33: `nonlocal x` inside a closure does NOT lift binding to enclosing fn', () => {
const f = parse(
`def outer():
x = 0
def inner():
nonlocal x
x = 1
`,
);
const inner = scopesByKind(f, 'Function').find((s) => s.range.startLine === 3)!;
expect(inner.bindings.has('x')).toBe(true); // under-reported
});
});

View file

@ -0,0 +1,298 @@
/**
* 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/scope-hooks.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();
});
});