From 206c18f83651b7c6bf172145ccb93d1b000ad3eb Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 20 Apr 2026 08:20:13 +0100 Subject: [PATCH] feat(python-scope): arity metadata + bind function decls in parent scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit 2 of the python migration architectural plan (docs/plans/2026-04-19-001-refactor-python-migration-architectural-plan.md). Two changes that the registry-primary path needs before any of the arity-sensitive failures can move: 1. Arity metadata on scope-extracted Function/Method defs. - New helper `languages/python/arity-metadata.ts` reuses `pythonMethodConfig.extractParameters` so self/cls stripping, defaults, and *args/**kwargs detection match legacy semantics. - `emit-captures.ts` synthesizes `@declaration.parameter-count` / `@declaration.required-parameter-count` / `@declaration.parameter-types` captures on every `@declaration.function` match. - Generic `scope-extractor.ts buildDefFromDeclarationMatch` reads the three optional captures into `SymbolDefinition`. Absence is still the no-op default for non-Python providers. 2. Hoist function/class declaration bindings to the enclosing scope. The "innermost scope containing the anchor" default placed `def greet(...)` inside greet's OWN body — invisible to other module-level callers, so every flag-on free-call resolved to `unresolved`. The hoist condition (`anchor range == innermost range`) only fires for scope-creating declarations, so variable / for-loop captures whose anchor is a child identifier stay put. Hooks can still override via `bindingScopeFor`. Verification: - Flag-off: 191/191 (identical baseline). - Flag-on (REGISTRY_PRIMARY_PYTHON=1): 31 fail / 160 pass (was 32/159; the hoist unblocks free-call resolution end-to-end). - tsc --noEmit clean. Per-(source,target) edge collapse for multi-call-site cases (default-params, variadic) still pending — landing it without regressing the static-method find_user fixture (which expects two distinct edges through different targets) needs the ownership-aware qualified-id work that lands with Unit 4 / Unit 11. --- .../languages/python/arity-metadata.ts | 54 +++++++++++++++++++ .../languages/python/emit-captures.ts | 41 +++++++++++++- .../src/core/ingestion/scope-extractor.ts | 53 +++++++++++++++++- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/python/arity-metadata.ts diff --git a/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts new file mode 100644 index 000000000..a997cb609 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts @@ -0,0 +1,54 @@ +/** + * Extract Python arity metadata from a `function_definition` tree-sitter + * node — parameter count, required count, and (where present) a type + * list that the existing `pythonArityCompatibility` hook reads. + * + * Mirrors the legacy `buildMethodProps` conversion so scope-extracted + * defs carry the same arity semantics as the parse-worker path: + * - `self` / `cls` are stripped (consumed by `extractPythonParameters`). + * - Defaulted params contribute to `optionalCount`, flipping + * `requiredParameterCount = total − optionalCount`. + * - Variadic (`*args` / `**kwargs`) collapses `parameterCount` to + * `undefined`, which `pythonArityCompatibility` then treats as + * `'unknown'` — keeping the candidate in the registry's lookup set. + * - `parameterTypes` is populated only with real type text, matching + * legacy behavior. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { pythonMethodConfig } from '../../method-extractors/configs/python.js'; + +export interface PythonArityMetadata { + readonly parameterCount: number | undefined; + readonly requiredParameterCount: number | undefined; + readonly parameterTypes: readonly string[] | undefined; +} + +export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetadata { + const params = pythonMethodConfig.extractParameters?.(fnNode) ?? []; + + let hasVariadic = false; + let optionalCount = 0; + const types: string[] = []; + for (const p of params) { + if (p.isVariadic) hasVariadic = true; + else if (p.isOptional) optionalCount++; + if (p.type !== null) types.push(p.type); + } + + const total = params.length; + const parameterCount = hasVariadic ? undefined : total; + // Unlike legacy `buildMethodProps`, we populate `requiredParameterCount` + // whenever the function isn't variadic — even when it equals + // `parameterCount`. The scope-resolution registry needs a concrete min + // to rule out under-application (e.g. picking `write_audit(x, y)` for + // a 1-arg call). Legacy could get away with leaving it undefined + // because its call-graph builder had a separate arity pre-filter. + const requiredParameterCount = hasVariadic ? undefined : total - optionalCount; + + return { + parameterCount, + requiredParameterCount, + parameterTypes: types.length > 0 ? types : undefined, + }; +} diff --git a/gitnexus/src/core/ingestion/languages/python/emit-captures.ts b/gitnexus/src/core/ingestion/languages/python/emit-captures.ts index 968cd85db..814516bfc 100644 --- a/gitnexus/src/core/ingestion/languages/python/emit-captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/emit-captures.ts @@ -17,10 +17,11 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { findNodeAtRange, nodeToCapture } from './ast-utils.js'; +import { findNodeAtRange, nodeToCapture, syntheticCapture } from './ast-utils.js'; import { splitImportStatement } from './import-decomposer.js'; import { getPythonParser, getPythonScopeQuery } from './query.js'; import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; +import { computePythonArityMetadata } from './arity-metadata.js'; export function emitPythonScopeCaptures( sourceText: string, @@ -73,6 +74,44 @@ export function emitPythonScopeCaptures( continue; } + if (grouped['@declaration.function'] !== undefined) { + // Synthesize arity captures on the declaration match so the + // central scope-extractor picks them up alongside @declaration.name. + // The anchor range is the function_definition itself — we resolve + // the node and pipe it through the arity helper. + const anchorCap = grouped['@declaration.function']!; + const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition'); + if (fnNode !== null) { + const arity = computePythonArityMetadata(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + // Serialize as JSON so the consumer can round-trip without + // inventing a quoting convention for type names that may + // contain commas (`Dict[str, int]`). + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + out.push(grouped); + continue; + } + out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index f0fbc93b5..172c6f769 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -465,8 +465,20 @@ function pass2AttachDeclarations( // populated during Pass 2: those fields are written across passes, // so reading them mid-extraction yields a partial view. The // `scopeTree` argument is similarly snapshot-before-mutation. + // + // Auto-hoist for scope-creating declarations: when the declaration's + // anchor range is the same node that produced `innermost` (e.g. a + // `function_definition` is both `@scope.function` and the + // `@declaration.function` anchor), the name is visible OUTSIDE the + // body, not inside. Hoisting to the parent scope is what every + // mainstream language wants for function/class declarations. Hooks + // can override by returning a non-null scope id. + const autoHostedId = + innermost.parent !== null && rangesEqual(anchor.range, innermost.range) + ? innermost.parent + : innermost.id; const bindingScopeId = - provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? innermost.id; + provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? autoHostedId; const bindingHost = draftById.get(bindingScopeId) ?? innermost; const nameKey = deriveDeclarationName(match, def); @@ -495,14 +507,44 @@ function buildDefFromDeclarationMatch( const qualifiedCap = match['@declaration.qualified_name']; const qualifiedName = qualifiedCap?.text; + // Optional arity metadata — producers (e.g. Python emit-captures) + // synthesize these on function/method declarations. Their absence is + // the normal case for other producers; readers treat undefined as + // "unknown" per `SymbolDefinition` contract. + const parameterCount = parseIntCapture(match['@declaration.parameter-count']); + const requiredParameterCount = parseIntCapture(match['@declaration.required-parameter-count']); + const parameterTypes = parseJsonStringArrayCapture(match['@declaration.parameter-types']); + return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), filePath, type, ...(qualifiedName !== undefined ? { qualifiedName } : { qualifiedName: nameCap.text }), + ...(parameterCount !== undefined ? { parameterCount } : {}), + ...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}), + ...(parameterTypes !== undefined ? { parameterTypes } : {}), }; } +function parseIntCapture(cap: { readonly text: string } | undefined): number | undefined { + if (cap === undefined) return undefined; + const n = Number.parseInt(cap.text, 10); + return Number.isFinite(n) ? n : undefined; +} + +function parseJsonStringArrayCapture( + cap: { readonly text: string } | undefined, +): string[] | undefined { + if (cap === undefined) return undefined; + try { + const parsed = JSON.parse(cap.text) as unknown; + if (!Array.isArray(parsed)) return undefined; + return parsed.every((x): x is string => typeof x === 'string') ? parsed : undefined; + } catch { + return undefined; + } +} + function deriveDeclarationName(match: CaptureMatch, def: SymbolDefinition): string | undefined { const nameCap = match['@declaration.name'] ?? @@ -842,6 +884,15 @@ function extractArity(match: CaptureMatch): number | undefined { // ─── Internal: range + capture utilities ─────────────────────────────────── +function rangesEqual(a: Range, b: Range): boolean { + return ( + a.startLine === b.startLine && + a.startCol === b.startCol && + a.endLine === b.endLine && + a.endCol === b.endCol + ); +} + function rangeStrictlyContains(outer: Range, inner: Range): boolean { if ( outer.startLine === inner.startLine &&