diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 5773cd3dd..ce5a854a9 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -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 = 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, }); diff --git a/gitnexus/src/core/ingestion/languages/python/scope-hooks.ts b/gitnexus/src/core/ingestion/languages/python/scope-hooks.ts new file mode 100644 index 000000000..c34560446 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/scope-hooks.ts @@ -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[0]); + } + return _parser; +} + +function getQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query( + Python as Parameters[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 = {}; + 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(); + 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; +} + +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`, `.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 = { + '@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; +} diff --git a/gitnexus/src/core/ingestion/languages/python/scopes.scm b/gitnexus/src/core/ingestion/languages/python/scopes.scm new file mode 100644 index 000000000..b40f8faff --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/scopes.scm @@ -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 diff --git a/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts b/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts new file mode 100644 index 000000000..bffc709cd --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts @@ -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 + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts b/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts new file mode 100644 index 000000000..764ade0e5 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts @@ -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 = {}, + 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 => ({ + 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(); + }); +});