feat(python-scope): arity metadata + bind function decls in parent scope

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.
This commit is contained in:
Gergo Magyar 2026-04-20 08:20:13 +01:00
parent 4013779a70
commit 206c18f836
3 changed files with 146 additions and 2 deletions

View file

@ -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,
};
}

View file

@ -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);
}

View file

@ -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 &&